Saltar a contenido

Listado filtrado - Angular

En este punto ya tenemos dos listados, uno básico y otro paginado. Ahora vamos a implementar un listado un poco diferente, con filtros y con una presentación un tanto distinta.

Como ya conocemos como se debe desarrollar, en este ejemplo vamos a ir más rápidos y nos vamos a centrar únicamente en las novedades.

Vamos a desarrollar el listado de Juegos. Este listado es un tanto peculiar, porque no tiene una tabla como tal, sino que vamos a mostrar los juegos como cards. Ya tenemos creado nuestro componentes pagina pero vamos a necesitar un componente para mostrar cada uno de los juegos y otro para crear y editar los juegos.

Crear componente game

Manos a la obra:

Creamos el fichero Game.ts dentro de la carpeta types:

import { Category } from "./Category";
import { Author } from "./Author";

export interface Game {
  id: string;
  title: string;
  age: number;
  category?: Category;
  author?: Author;
}

Modificamos nuestra api de Toolkit para añadir los endpoints de juegos y aparte creamos un endpoint para recuperar los autores que necesitaremos para crear un nuevo juego, el fichero completo quedaría de esta manera:

import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
import { Game } from "../../types/Game";
import { Category } from "../../types/Category";
import { Author, AuthorResponse } from "../../types/Author";

export const ludotecaAPI = createApi({
  reducerPath: "ludotecaApi",
  baseQuery: fetchBaseQuery({
    baseUrl: "http://localhost:8080",
  }),
  tagTypes: ["Category", "Author", "Game"],
  endpoints: (builder) => ({
    getCategories: builder.query<Category[], null>({
      query: () => "category",
      providesTags: ["Category"],
    }),
    createCategory: builder.mutation({
      query: (payload) => ({
        url: "/category",
        method: "PUT",
        body: payload,
        headers: {
          "Content-type": "application/json; charset=UTF-8",
        },
      }),
      invalidatesTags: ["Category"],
    }),
    deleteCategory: builder.mutation({
      query: (id: string) => ({
        url: `/category/${id}`,
        method: "DELETE",
      }),
      invalidatesTags: ["Category"],
    }),
    updateCategory: builder.mutation({
      query: (payload: Category) => ({
        url: `category/${payload.id}`,
        method: "PUT",
        body: payload,
      }),
      invalidatesTags: ["Category"],
    }),
    getAllAuthors: builder.query<Author[], null>({
      query: () => "author",
      providesTags: ["Author"],
    }),
    getAuthors: builder.query<
      AuthorResponse,
      { pageNumber: number; pageSize: number }
    >({
      query: ({ pageNumber, pageSize }) => {
        return {
          url: "author",
          method: "POST",
          body: {
            pageable: {
              pageNumber,
              pageSize,
            },
          },
        };
      },
      providesTags: ["Author"],
    }),
    createAuthor: builder.mutation({
      query: (payload) => ({
        url: "/author",
        method: "PUT",
        body: payload,
        headers: {
          "Content-type": "application/json; charset=UTF-8",
        },
      }),
      invalidatesTags: ["Author"],
    }),
    deleteAuthor: builder.mutation({
      query: (id: string) => ({
        url: `/author/${id}`,
        method: "DELETE",
      }),
      invalidatesTags: ["Author"],
    }),
    updateAuthor: builder.mutation({
      query: (payload: Author) => ({
        url: `author/${payload.id}`,
        method: "PUT",
        body: payload,
      }),
      invalidatesTags: ["Author", "Game"],
    }),
    getGames: builder.query<Game[], { title: string; idCategory: string }>({
      query: ({ title, idCategory }) => {
        return {
          url: "game/",
          params: { title, idCategory },
        };
      },
      providesTags: ["Game"],
    }),
    createGame: builder.mutation({
      query: (payload: Game) => ({
        url: "/game",
        method: "PUT",
        body: { ...payload },
        headers: {
          "Content-type": "application/json; charset=UTF-8",
        },
      }),
      invalidatesTags: ["Game"],
    }),
    updateGame: builder.mutation({
      query: (payload: Game) => ({
        url: `game/${payload.id}`,
        method: "PUT",
        body: { ...payload },
      }),
      invalidatesTags: ["Game"],
    }),

  }),
});

export const {
  useGetCategoriesQuery,
  useCreateCategoryMutation,
  useDeleteCategoryMutation,
  useUpdateCategoryMutation,
  useCreateAuthorMutation,
  useDeleteAuthorMutation,
  useGetAllAuthorsQuery,
  useGetAuthorsQuery,
  useUpdateAuthorMutation,
  useCreateGameMutation,
  useGetGamesQuery,
  useUpdateGameMutation
} = ludotecaAPI;

Creamos una nueva carpeta components dentro de src/pages/Game y dentro creamos un archivo llamado CreateGame.tsx con el siguiente contenido:

import { ChangeEvent, useContext, useEffect, useState } from "react";
import Button from "@mui/material/Button";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import DialogTitle from "@mui/material/DialogTitle";
import {
  useGetAllAuthorsQuery,
  useGetCategoriesQuery,
} from "../../../redux/services/ludotecaApi";
import { LoaderContext } from "../../../context/LoaderProvider";
import { Game } from "../../../types/Game";
import { Category } from "../../../types/Category";
import { Author } from "../../../types/Author";

interface Props {
  game: Game | null;
  closeModal: () => void;
  create: (game: Game) => void;
}

const initialState = {
  id: "",
  title: "",
  age: 0,
  category: undefined,
  author: undefined,
};

export default function CreateGame(props: Props) {
  const [form, setForm] = useState<Game>(initialState);
  const loader = useContext(LoaderContext);
  const { data: categories, isLoading: isLoadingCategories } =
    useGetCategoriesQuery(null);
  const { data: authors, isLoading: isLoadingAuthors } =
    useGetAllAuthorsQuery(null);

  useEffect(() => {
    setForm({
      id: props.game?.id || "",
      title: props.game?.title || "",
      age: props.game?.age || 0,
      category: props.game?.category,
      author: props.game?.author,
    });
  }, [props?.game]);

  useEffect(() => {
    loader.showLoading(isLoadingCategories || isLoadingAuthors);
  }, [isLoadingCategories, isLoadingAuthors]);

  const handleChangeForm = (
    event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
  ) => {
    setForm({
      ...form,
      [event.target.id]: event.target.value,
    });
  };

  const handleChangeSelect = (
    event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
  ) => {
    const values = event.target.name === "category" ? categories : authors;
    setForm({
      ...form,
      [event.target.name]: values?.find((val) => val.id === event.target.value),
    });
  };

  return (
    <div>
      <Dialog open={true} onClose={props.closeModal}>
        <DialogTitle>
          {props.game ? "Actualizar Juego" : "Crear Juego"}
        </DialogTitle>
        <DialogContent>
          {props.game && (
            <TextField
              margin="dense"
              disabled
              id="id"
              label="Id"
              fullWidth
              value={props.game.id}
              variant="standard"
            />
          )}
          <TextField
            margin="dense"
            id="title"
            label="Titulo"
            fullWidth
            variant="standard"
            onChange={handleChangeForm}
            value={form.title}
          />
          <TextField
            margin="dense"
            id="age"
            label="Edad Recomendada"
            fullWidth
            type="number"
            variant="standard"
            onChange={handleChangeForm}
            value={form.age}
          />
          <TextField
            id="category"
            select
            label="Categoría"
            defaultValue="''"
            fullWidth
            variant="standard"
            name="category"
            value={form.category ? form.category.id : ""}
            onChange={handleChangeSelect}
          >
            {categories &&
              categories.map((option: Category) => (
                <MenuItem key={option.id} value={option.id}>
                  {option.name}
                </MenuItem>
              ))}
          </TextField>
          <TextField
            id="author"
            select
            label="Autor"
            defaultValue="''"
            fullWidth
            variant="standard"
            name="author"
            value={form.author ? form.author.id : ""}
            onChange={handleChangeSelect}
          >
            {authors &&
              authors.map((option: Author) => (
                <MenuItem key={option.id} value={option.id}>
                  {option.name}
                </MenuItem>
              ))}
          </TextField>
        </DialogContent>
        <DialogActions>
          <Button onClick={props.closeModal}>Cancelar</Button>
          <Button
            onClick={() =>
              props.create({
                id: "",
                title: form.title,
                age: form.age,
                category: form.category,
                author: form.author,
              })
            }
            disabled={
              !form.title || !form.age || !form.category || !form.author
            }
          >
            {props.game ? "Actualizar" : "Crear"}
          </Button>
        </DialogActions>
      </Dialog>
    </div>
  );
}

Ahora en esa misma carpeta crearemos el componente GameCard.tsx para mostrar nuestros juegos con un diseño de carta:

import Card from "@mui/material/Card";
import CardContent from "@mui/material/CardContent";
import CardMedia from "@mui/material/CardMedia";
import CardHeader from "@mui/material/CardHeader";
import List from "@mui/material/List";
import ListItem from "@mui/material/ListItem";
import ListItemAvatar from "@mui/material/ListItemAvatar";
import ListItemText from "@mui/material/ListItemText";
import Avatar from "@mui/material/Avatar";
import PersonIcon from "@mui/icons-material/Person";
import LanguageIcon from "@mui/icons-material/Language";
import CardActionArea from "@mui/material/CardActionArea";
import red from "@mui/material/colors/red";
import imageGame from "./../../../assets/foto.png";
import { Game } from "../../../types/Game";

interface GameCardProps {
  game: Game;
}

export default function GameCard(props: GameCardProps) {
  const { title, age, category, author } = props.game;
  return (
    <Card sx={{ maxWidth: 265 }}>
      <CardHeader
        sx={{
          ".MuiCardHeader-title": {
            fontSize: "20px",
          },
        }}
        avatar={
          <Avatar sx={{ bgcolor: red[500] }} aria-label="age">
            +{age}
          </Avatar>
        }
        title={title}
        subheader={category?.name}
      />
      <CardActionArea>
        <CardMedia
          component="img"
          height="140"
          image={imageGame}
          alt="game image"
        />
        <CardContent>
          <List dense={true}>
            <ListItem>
              <ListItemAvatar>
                <Avatar>
                  <PersonIcon />
                </Avatar>
              </ListItemAvatar>
              <ListItemText primary={`Autor: ${author?.name}`} />
            </ListItem>
            <ListItem>
              <ListItemAvatar>
                <Avatar>
                  <LanguageIcon />
                </Avatar>
              </ListItemAvatar>
              <ListItemText primary={`Nacionalidad: ${author?.nationality}`} />
            </ListItem>
          </List>
        </CardContent>
      </CardActionArea>
    </Card>
  );
}

En la carpeta src/pages/game vamos a crear un fichero para los estilos llamado Game.module.css:

.filter {
    display: flex;
    align-items: center;
}

.cards {
    display: flex;
    gap: 20px;
    padding: 10px;
    flex-wrap: wrap;
}

.card {
    cursor: pointer;
}

@media (max-width: 800px) {
    .cards {
        display: flex;
        flex-direction: column;
        align-items: center;
    }

    .filter {
        display: flex;
        flex-direction: column;
    }
}

Y por último modificamos nuestro componente página Game y lo dejamos de esta manera:

import { useState, useContext, useEffect } from "react";
import MenuItem from "@mui/material/MenuItem";
import FormControl from "@mui/material/FormControl";
import TextField from "@mui/material/TextField";
import Button from "@mui/material/Button";
import GameCard from "./components/GameCard";
import styles from "./Game.module.css";
import {
  useCreateGameMutation,
  useGetCategoriesQuery,
  useGetGamesQuery,
  useUpdateGameMutation,
} from "../../redux/services/ludotecaApi";
import CreateGame from "./components/CreateGame";
import { LoaderContext } from "../../context/LoaderProvider";
import { useAppDispatch } from "../../redux/hooks";
import { setMessage } from "../../redux/features/messagesSlice";
import { Game as GameModel } from "../../types/Game";
import { Category } from "../../types/Category";

export const Game = () => {
  const [openCreate, setOpenCreate] = useState(false);
  const [filterTitle, setFilterTitle] = useState("");
  const [filterCategory, setFilterCategory] = useState("");
  const [gameToUpdate, setGameToUpdate] = useState<GameModel | null>(null);
  const loader = useContext(LoaderContext);
  const dispatch = useAppDispatch();

  const { data, error, isLoading, isFetching } = useGetGamesQuery({
    title: filterTitle,
    idCategory: filterCategory,
  });

  const [updateGameApi, { isLoading: isLoadingUpdate, error: errorUpdate }] =
    useUpdateGameMutation();

  const { data: categories } = useGetCategoriesQuery(null);

  const [createGameApi, { isLoading: isLoadingCreate, error: errorCreate }] =
    useCreateGameMutation();

  useEffect(() => {
    loader.showLoading(
      isLoadingCreate || isLoadingUpdate || isLoading || isFetching
    );
  }, [isLoadingCreate, isLoadingUpdate, isLoading, isFetching]);

  useEffect(() => {
    if (errorCreate || errorUpdate) {
      setMessage({
        text: "Se ha producido un error al realizar la operación",
        type: "error",
      });
    }
  }, [errorUpdate, errorCreate]);

  if (error) return <p>Error cargando!!!</p>;

  const createGame = (game: GameModel) => {
    setOpenCreate(false);
    if (gameToUpdate) {
      updateGameApi({
        ...game,
        id: gameToUpdate.id,
      })
        .then(() => {
          dispatch(
            setMessage({
              text: "Juego actualizado correctamente",
              type: "ok",
            })
          );
          setGameToUpdate(null);
        })
        .catch((err) => console.log(err));
    } else {
      createGameApi(game)
        .then(() => {
          dispatch(
            setMessage({
              text: "Juego creado correctamente",
              type: "ok",
            })
          );
          setGameToUpdate(null);
        })
        .catch((err) => console.log(err));
    }
  };

  return (
    <div className="container">
      <h1>Catálogo de juegos</h1>
      <div className={styles.filter}>
        <FormControl variant="standard" sx={{ m: 1, minWidth: 220 }}>
          <TextField
            margin="dense"
            id="title"
            label="Titulo"
            fullWidth
            value={filterTitle}
            variant="standard"
            onChange={(event) => setFilterTitle(event.target.value)}
          />
        </FormControl>
        <FormControl variant="standard" sx={{ m: 1, minWidth: 220 }}>
          <TextField
            id="category"
            select
            label="Categoría"
            defaultValue="''"
            fullWidth
            variant="standard"
            name="author"
            value={filterCategory}
            onChange={(event) => setFilterCategory(event.target.value)}
          >
            {categories &&
              categories.map((option: Category) => (
                <MenuItem key={option.id} value={option.id}>
                  {option.name}
                </MenuItem>
              ))}
          </TextField>
        </FormControl>
        <Button
          variant="outlined"
          onClick={() => {
            setFilterCategory("");
            setFilterTitle("");
          }}
        >
          Limpiar
        </Button>
      </div>
      <div className={styles.cards}>
        {data?.map((card) => (
          <div
            key={card.id}
            className={styles.card}
            onClick={() => {
              setGameToUpdate(card);
              setOpenCreate(true);
            }}
          >
            <GameCard game={card} />
          </div>
        ))}
      </div>
      <div className="newButton">
        <Button variant="contained" onClick={() => setOpenCreate(true)}>
          Nuevo juego
        </Button>
      </div>
      {openCreate && (
        <CreateGame
          create={createGame}
          game={gameToUpdate}
          closeModal={() => {
            setGameToUpdate(null);
            setOpenCreate(false);
          }}
        />
      )}
    </div>
  );
};

Y por último descargamos la siguiente imagen y la guardamos en la carpeta src/assets.

En este listado realizamos el filtro de manera dinámica, en el momento en que cambiamos el valor de la categoría o el título a filtrar, como estas variables están asociadas al estado de nuestro componente, se vuelve a renderizar y por lo tanto se actualiza el valor de "data" modificando así los resultados.

El resto es muy parecido a lo que ya hemos realizado antes. Aquí no tenemos una tabla, sino que mostramos nuestros juegos como Cards y si pulsamos sobre cualquier Card se mostrará el formulario de edición del juego.

Si ahora arrancamos el proyecto y nos vamos a la pagina de juegos podremos crear y ver nuestros juegos.