Implementare interfață utilizator

1. Curățarea fișierului de stiluri

Mergem în tailwind.config.js și lăsăm doar liniile de configurare pentru tailwind.

// tailwind.config.js

@tailwind base;
@tailwind components;
@tailwind utilities;

2. Crearea fișierului de metode pentru manipularea entry-urilor

O să ne deplasăm în folderul utils și o să creăm un nou fișier numit recordsFunctions.js.

În acest fișier, o să apelăm toate rutele create în pașii anteriori din fișierul records.js.

// /utils/recordsFunctions.js

export const getRecords = async () => {
    try {
        const response = await fetch("/api/records", {
            method: "GET",
        });
    
        const data = await response.json();
    
        if (!data?.data) {
            return [];
        }
    
        return data.data;
    } catch (error) {
        console.error(error);
    }
}

export const getRecordById = async (id) => {
    try {
        const response = await fetch(`/api/records?id=${id}`, {
            method: "GET",
        });
    
        const data = await response.json();
    
        if (!data?.data) {
            return null;
        }
    
        return data.data;
    } catch (error) {
        console.error(error);
    }
}

export const createRecord = async (record) => {
    try {
        delete record._id;

        const response = await fetch("/api/records", {
            method: "POST",
            headers: {
                "Content-Type": "application/json",
            },
            body: JSON.stringify(record),
        });

        const data = await response.json();

        if (!data?.data) {
            return null;
        }

        return data.data;
    } catch (error) {
        console.error(error);
    }
}

export const updateRecord = async (record) => {
    try {
        const response = await fetch("/api/records", {
            method: "PUT",
            headers: {
                "Content-Type": "application/json",
            },
            body: JSON.stringify(record),
        });

        const data = await response.json();

        if (!data?.data) {
            return null;
        }

        return data.data;
    } catch (error) {
        console.error(error);
    }
}

export const deleteRecord = async (id) => {
    try {
        const response = await fetch(`/api/records?id=${id}`, {
            method: "DELETE",
        });

        const data = await response.json();

        if (!data?.data) {
            return null;
        }

        return data.data;
    } catch (error) {
        console.error(error);
    }
}

3. Pagina princpală

O să creăm un nou folder care o să se numească components, și înăuntrul lui o să adăugăm un fișier care se va numi MainPage.jsx.

Dacă folosiți Visual Studio Code și doriți un shortcut pentru un boilerplate de componentă de React, puteți tasta rafce și să apăsați enter și o să primiți boilerplate-ul.

// /components/MainPage.jsx

import React from 'react'

const MainPage = () => {
  return (
    <div>MainPage</div>
  )
}

export default MainPage

Mergem în fișierul de index.js din folderul pages, ștergem tot conținutul return-ului și chemăm componenta de MainPage.jsx.

// /pages/index.js

import MainPage from "@/components/MainPage";

export default function Home() {
  return (
    <MainPage />
  );
}

După efectuarea tuturor acestor modificări, începem să modificăm pagina MainPage.jsx și să o facem să ceară de la baza de date toate înregistrările din tabela records, și să le mapăm sub formă de carduri, oferind posibilitatea utilizatorilor să apese pe butonul de editare sau de ștergere a unui entry.

// /components/MainPage.jsx

import { useEffect, useState } from "react";
import { useRouter } from "next/router";
import { deleteRecord, getRecords } from "@/utils/recordsFunctions";

const MainPage = () => {
  const router = useRouter();
  const [records, setRecords] = useState([]);

  const fetchRecords = async () => {
    try {
      const response = await getRecords();

      setRecords(response);
    } catch (error) {
      console.error(error);
    }
  };

  const handleDeleteRecord = async (id) => {
    try {
      const response = await deleteRecord(id);

      if (response.deletedCount === 1) {
        const newRecords = records.filter((record) => record._id !== id);
        setRecords(newRecords);
      }
    } catch (error) {
      console.log(error)
    }
  };

  const handleUpdateRecord = (id) => {
    router.push(`/records/edit?id=${id}`);
  };

  useEffect(() => {
    fetchRecords();
  }, []);

  return (
    <div className="p-4 flex flex-wrap gap-4">
      {records.map((record) => (
        <div
          className="max-w-sm p-6 bg-white border border-gray-200 rounded-lg shadow dark:bg-gray-800 dark:border-gray-700"
          key={record._id}
        >
          <h5 className="mb-2 text-2xl font-bold tracking-tight text-gray-900 dark:text-white">
            {record.title}
          </h5>
          <p className="mb-3 font-normal text-gray-700 dark:text-gray-400">
            {record.description}
          </p>
          <div className="flex justify-center">
            <button
              type="button"
              className="text-white bg-gradient-to-r from-green-400 via-green-500 to-green-600 hover:bg-gradient-to-br focus:ring-4 focus:outline-none focus:ring-green-300 dark:focus:ring-green-800 font-medium rounded-lg text-sm px-5 py-2.5 text-center me-2 mb-2"
              onClick={() => handleUpdateRecord(record._id)}
            >
              Update
            </button>
            <button
              type="button"
              className="text-white bg-gradient-to-r from-red-400 via-red-500 to-red-600 hover:bg-gradient-to-br focus:ring-4 focus:outline-none focus:ring-red-300 dark:focus:ring-red-800 font-medium rounded-lg text-sm px-5 py-2.5 text-center me-2 mb-2"
              onClick={() => handleDeleteRecord(record._id)}
            >
              Delete
            </button>
          </div>
        </div>
      ))}
    </div>
  );
};

export default MainPage;

Această componentă folosește două hook-uri foarte importante puse la dispoziție de către React:

  1. useState -> care ne ajută să menținem valori pe care să le urmărim și să ne folosim de ele într-un mod dinamic atunci când aceste sunt actualizate.

În cazul nostru, salvăm lista de records pe care o mapăm sub formă de carduri.

!! Metoda de set este async !!

  1. useEffect -> un hook care are rolul de a efectua anumite acțiuni atunci când o variabilă din array-ul de dependențe se schimbă, sau de a efectua o singură acțiune atunci când componenta se montează și array-ul de dependințe este gol.

În cazul nostru, ne-am folosit de useEffect pentr a apela metoda fetchRecords care cere la baza de date lista cu toate entry-urile.

Cel mai important lucru pe care trebuie să îl facem atunci când dorim să mapăm o listă de valori, este să oferim fiecărui item mapat o cheie unică de identificare care să ajute sistemul să identifice în orice secundă ce element se dorește a fi modificat/șters.

4. Crearea unui entry

Pentru crearea unui nou entry, o să ne mutăm în folderul pages și o să creăm un nou folder numit records cu un fișier create.jsx.

// /pages/records/create.jsx

import RecordForm from "@/components/RecordForm";
import { recordDefaultValues } from "@/utils/constants";
import { createRecord } from "@/utils/recordsFunctions";
import { useRouter } from "next/router";

const Create = () => {
  const router = useRouter();
  const entry = recordDefaultValues;

  const onSubmit = async (data) => {
    const response = await createRecord(data);

    if (response) {
      router.push("/");
    } else {
      alert("Failed to create record");
    }
  }

  return (
      <RecordForm data={entry} onSubmit={onSubmit} />
  );
};

export default Create;

Ruta poate fi accesată la http://localhost:3000/records/create

După cum putem vedea, am creat un entry pe baza unui template și o metodă de submit (callback function), pe care le-am pasat către altă componentă sub formă de PROPS.

Un callback function este o funcție pasată ca și prop unei componente copil, cu scopul ca acesta să o poată apela oricând are nevoie.

Pentru mai multe detalii despre callback functions: https://developer.mozilla.org/en-US/docs/Glossary/Callback_function

Pentru a crea respectivul template de entry, o să ne mutăm în folderul utils, și o să creăm fișierul constants.js.

// /utils/constants.js

export const recordDefaultValues = {
    _id: "",
    title: "",
    description: ""
};

Pentru crearea formularului, o să ne mutăm în folderul components și o să creăm fișierul RecordForm.jsx.

// /components/RecordForm.jsx

import React, { useState } from "react";
import { useRouter } from "next/router";

const RecordForm = (props) => {
  const { data, onSubmit } = props;
  const router = useRouter();
  const [entry, setEntry] = useState(data);

  const updateEntry = (type, value) => {
    setEntry({ ...entry, [type]: value });
  };

  const handleCancel = () => {
    router.push("/");
  }

  return (
    <div className="flex justify-center p-4">
      <div className="border p-4 rounded-md shadow-sm flex flex-col gap-4 w-full max-w-80">
        <div>
          <label
            htmlFor="title"
            className="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
          >
            Title
          </label>
          <input
            type="text"
            id="title"
            value={entry.title}
            onChange={(e) => updateEntry("title", e.target.value)}
            className="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
            placeholder="Title"
            required
          />
        </div>
        <div>
          <label
            htmlFor="description"
            className="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
          >
            Description
          </label>
          <textarea
            id="description"
            rows="4"
            value={entry.description}
            onChange={(e) => updateEntry("description", e.target.value)}
            className="block p-2.5 w-full text-sm text-gray-900 bg-gray-50 rounded-lg border border-gray-300 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
            placeholder="Write your thoughts here..."
          ></textarea>
        </div>
        <div className="w-full flex justify-center gap-4">
            <button
            type="button"
            onClick={handleCancel}
            className="focus:outline-none text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-red-600 dark:hover:bg-red-700 dark:focus:ring-red-900"
          >
            Cancel
          </button>
          <button
            type="button"
            onClick={() => onSubmit(entry)}
            className="focus:outline-none text-white bg-green-700 hover:bg-green-800 focus:ring-4 focus:ring-green-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-green-600 dark:hover:bg-green-700 dark:focus:ring-green-800"
          >
            {entry._id ? "Update" : "Create"}
          </button>
        </div>
      </div>
    </div>
  );
};

export default RecordForm;

După cum putem observa, componenta RecordForm primește ca și props data și onSubmit, setează un state cu obiectul data primit, și umple valorile input-urilor cu valorile primite, punând la dispoziție metoda onSubmit atunci când se apasă pe butonul Create/Update.

5. Editarea unui entry

Ne mutăm în folderul records din pages și adăugăm o nouă componentă, numită edit.jsx.

// /pages/records/edit.jsx

import { useEffect, useState } from "react";
import { useRouter } from "next/router";
import Spinner from "@/components/Spinner";
import { recordDefaultValues } from "@/utils/constants";
import { getRecordById, updateRecord } from "@/utils/recordsFunctions";
import RecordForm from "@/components/RecordForm";

const Edit = () => {
  const router = useRouter();
  const [isLoading, setIsLoading] = useState(true);
  const [entry, setEntry] = useState(recordDefaultValues);

  const getRecord = async (id) => {
    const data = await getRecordById(id);

    if (data) {
      setEntry(data);
    }

    setIsLoading(false);
  };

  const onSubmit = async (data) => {
    const response = await updateRecord(data);

    if (response) {
      router.push("/");
    } else {
      alert("Failed to update record");
    }
  }

  useEffect(() => {
    const searchParams = new URLSearchParams(router.asPath.split(/\?/)[1]);
    const id = searchParams.get("id");

    if (!id) {
      router.push("/");
    }

    getRecord(id);
  }, []);

  if (isLoading) {
    return <Spinner />;
  }

  return (
    <>
      {entry._id ? (
        <RecordForm data={entry} onSubmit={onSubmit}/>
      ) : (
        <div className="text-center">Record not found</div>
      )}
    </>
  );
};

export default Edit;

Principalul rol al acestei componente este ca atunci când este montată (prin intermediul useEffect-ului) să verifice dacă în path-ul accesat există sau nu un Query Param cu id-ul record-ului care se dorește a fi editat.

  • În cazul în care găsește id-ul, va încerca să tragă de la baza de date record-ul cu id-ul furnizat. În cazul fericit, o să paseze către componenta RecordForm următoarele prop-uri:

    • entry -> entry-ul găsit în baza de date

    • onSubmit -> un callback function care are rolul de a actualiza entry-ul atunci când componenta copil cere acest lucru.

  • În cazul în care nu se găsește id-ul ca și Query Param, utilizatorul va fi redirecționat către HomePage.

A fost implementată și funcționalitatea de loading, astfel încât până când nu se primește de la server un răspuns pozitiv sau nu, un Spinner va fi afișat pe ecran.

Pentru testare;

Pentru crearea Spinner-ului, o să mergem în folderul components și o să creăm un fișier numit Spinner.jsx.

// /components/Spinner.jsx

import React from "react";

const Spinner = () => {
  return (
    <div className="flex w-full h-screen justify-center items-center">
      <svg
        aria-hidden="true"
        className="w-8 h-8 text-gray-200 animate-spin dark:text-gray-600 fill-blue-600"
        viewBox="0 0 100 101"
        fill="none"
        xmlns="http://www.w3.org/2000/svg"
      >
        <path
          d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
          fill="currentColor"
        />
        <path
          d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
          fill="currentFill"
        />
      </svg>
      <span className="sr-only">Loading...</span>
    </div>
  );
};

export default Spinner;

Last updated