Guía R-001 – Mostrar Grid de tarjetas («card»)

En las aplicaciones de móvil es muy frecuente que las listas de los grid (rejillas) no se vean bien, por el espacio tan escaso que se dispone en la visualización de los móviles en vertical.

Por esta razón, he estado revisando en el ejemplo de «compra-react» alternativas para la visualización de los «listados» de «compras» y «productos».

Objetivo

Mejorar la visualización de los GRID en las aplicaciones para los móviles.

DEMO: https://fhumanes.com/compra2-react/

Comparte base de datos y usuarios con la aplicación de «compra«.

Solución Técnica

Manteniendo la presentación de la lista de productos he probado con distintos configuraciones y la más sencilla y práctica que he visto es esta:

El ajuste al tamaño de la caja se consigue con este código:Se puede ver que se utiliza el atributo «flex» para indicar el tamaño relativo de cada uno de los campos. También se puede combinar con el atributo «minWidth», para indicar el mínimo tamaño del campo.

La otra alternativa es la visualizaciones de tarjetas «card» y tiene esta representación:

Esto se consigue definiendo una caja contenedora de tamaño máximo, por lo que se irá ajustando al tamaño de la ventana y por tanto mostrará 3, 2 o 1 «card» en cada fila.

A nivel de código se hace uno específico para toda la página y otro para mostrar cada una de las «card».

compraList/index.js
import * as React from 'react';
import './style.css';


// import Box from '@mui/material/Box';
import Button from '@mui/material/Button';

import AddIcon from '@mui/icons-material/Add';
import EditIcon from '@mui/icons-material/Edit';
import CheckedIcon from '@mui/icons-material/ShoppingCart';
import FilterIcon from '@mui/icons-material/Search';
import CachedIcon from '@mui/icons-material/Cached';

import Item from './itemList.js'

// import SaveIcon from '@mui/icons-material/Save';
// import CancelIcon from '@mui/icons-material/Close';

import DeleteIcon from '@mui/icons-material/Delete';

// import Rating from '@mui/material/Rating';
// import Typography from '@mui/material/Typography';
// import { toast } from 'react-toastify';
import Swal from 'sweetalert2'
import withReactContent from 'sweetalert2-react-content'

import { createTheme, ThemeProvider } from '@mui/material/styles';
import axios from 'axios';
import { useAuth } from "../../hooks/AuthProvider";
import {useEffect, useState} from 'react';

// import { esES } from '@mui/material/locale';
import { esES } from '@mui/x-data-grid/locales';

import { useNavigate } from 'react-router-dom';

import Nav from '../nav';
// import Logo from '../logo';
import Config from '../config';


const theme = createTheme(
  {
    palette: {
      primary: { main: '#1976d2' },
    },
  },
  esES,
);

const withRouter = (Component) => {
  const Wrapper = (props) => {
    const navigate = useNavigate();
    return <Component navigate={navigate} {...props} />;
  };
  return Wrapper;
};

function renderFoto(params) {
  if (params.value !== null ) {
    var imagen= Config.URL_FILES+params.value;
    // console.log("URL de la imagen: ", imagen);
  return (<div> <img src={imagen} alt="imagen de Compra" width="72"  /> </div>)
}
}


function  CompraList(props) {

  const [rows, setRows] = useState([
    {
    "id": 68,
    "ct_grupo_idct_grupo": 2,
    "ct_producto_idct_producto": 9,
    "Nombre": "Tarta de manzana",
    "Cantidad": 1,
    "ct_unidad_idct_unidad": 4,
    "Unidad": "Unidades",
    "foto": "../Documentos/Compra/thtarta de manzana_xrbb9cgk.png",
    "FechaUltCambio": "2024-11-11 20:05:02",
    "UsuarioUltCambio": 1,
    "LoginUltCambio": "admin",
    "Comprado": 0,
    "FechaComprado": null,
    "UsuarioComprado": null,
    "LoginComprado": null
  }]);
  const [refresh, setRefresh] = useState(true);
  const [isMobile, setIsMobile] = useState(false);

  const auth = useAuth();

  // console.log("Usuario conectado: ",auth.user);
  // console.log("Usuario Administrador",auth.adminGroup);

  // Notificación error grave
  function errorNotification(code, message) {

    const MySwal = withReactContent(Swal); 
    MySwal.fire({
      icon: 'error',
      title: message,
      text: '',
      timer: 5000,
      timerProgressBar: true,
      toast: true,
      position: "center",
      footer: ''
    })
    props.navigate(Config.URL_APP+'/logout');
  }

   // Para la primera carga, tomar la lista de Producctos que corresponde
  const getSituacion = () => {                 
    if ( localStorage.getItem("situacionLista") === null ) {
      localStorage.setItem("situacionLista",'pendientes'); // Si no existe
    }
    var situacionLista = localStorage.getItem("situacionLista");
    var situaciones = ['pendientes','hoy','comprados']; // Posibles botones
   if (! (situaciones.includes(situacionLista))) {  //true o false si existe o no
      localStorage.setItem("situacionLista",'pendientes');
   }
  }

  // Cargar los datos del Server cuando se carga y recarga la página
  const fetchCompra = async () => {
    // console.log("Valor de la variable Refresh: ",refresh);
    if (refresh) {        // Para intentar que sólo sea una vez
      setRefresh(false);

      var formWitdh = window.innerWidth;
      // console.log("Ancho de pantalla: ",formWitdh);
      if (formWitdh < 768 ) {
        setIsMobile(true);
        // console.log("Configuarado para Móvil ");
      }

      getSituacion();     // Para saber la opción de acceso
      var situacion = localStorage.getItem("situacionLista");
      const url = Config.RUTA_API + "/listadoCompra/" + situacion;
      const formData = new FormData();
      const config = {
        headers: {
            "Content-Type": "application/x-www-form-urlencoded",
            "token-user": auth.token
        },
      };
      try {  
        const response = await axios.post(url,formData,config)     
            var data = response.data;
            // console.log("Respuesta del fetch: ",data);
            setRows(data);
            
            if (data.error === true )  { 
              console.log("Hay un error: ",data.message);
              errorNotification(data.error, data.message);
            } else {
              // console.log("Registros de respuesta: ", data.length);
            }
              
          }
      catch(error) {
            console.error("Error get Lista Compra ", error);
            errorNotification('000', error);
          }
    }
  };
  

  // Cargar los datos del Server cuando se carga la página
  useEffect(() => {
    console.log("Ejecutándose 'useEffect");
    fetchCompra();
     }, [refresh ]);


     const deleteRow = (id) => {
      // console.log("Registro a eliminar: ",id);

      const row = rows.find((rowid) => rowid.id === id);

      const text = `¿Eliminar registro de: ${row.Nombre} ?`; // ${row.nombre}
      const MySwal = withReactContent(Swal); 
      MySwal.fire({
        title: 'Confirmación',
        text: text,
        icon: 'question',
        showCancelButton: true,
        confirmButtonColor: '#3298dc',
        cancelButtonColor: '#f14668',
        cancelButtonText: 'No',
        confirmButtonText: 'Sí, eliminar'
      })

        .then(response => {
          if (response.isConfirmed) {
            // console.log("Comfirmed OK",response);

             // Acceso al server y status de la operación
            const url = Config.RUTA_API + "/compra/delete/"+id;
            const formData = new FormData();
            const config = {
              headers: {
                "Content-Type": "application/x-www-form-urlencoded",
                "token-user": auth.token
                },
              };  
            axios.post(url, formData, config)
        
              .then((response) => {
                const res = response.data;
                // console.log("Respuesta del fetch: ",res);
                if (res.error === false) {
                    setRows((prevRows) => prevRows.filter((row) => row.id !== id));
                    // console.log("Delete row ID: ",id);
      
                    MySwal.fire({
                      icon: 'info',
                      title: 'Producto eliminado!!!!',
                      text: '',
                      timer: 1000,
                      timerProgressBar: true,
                      toast: true,
                      position: "center",
                      footer: ''
                    })
                  } else {
                    MySwal.fire({
                      icon: 'error',
                      title: res.message,
                      text: '',
                      timer: 3000,
                      timerProgressBar: true,
                      toast: true,
                      position: "center",
                      footer: ''
                    })
                }
              })
            .catch((error) => {
              console.error("Error delete Producto: ", error);
              setRefresh(true);  // Reload Data from Server
            })
          }
        })
    };

  // Comprar o descomprar roducto 
  const checkRow = (id) =>  {
      const row = rows.find((rowid) => rowid.id === id);
      setRows((prevRows) => prevRows.filter((row) => row.id !== id));
      // console.log("Datos del registro seleccionado: ",row);

      // Acceso al server y status de la operación
      const url = Config.RUTA_API + "/compra/check/"+id;
      const formData = new FormData();
      const config = {
        headers: {
          "Content-Type": "application/x-www-form-urlencoded",
          "token-user": auth.token
          },
        };  
      axios.post(url, formData, config)
  
        .then((response) => {
          const res = response.data;
          // console.log("Respuesta del fetch: ",res);
          if (res.error === false) {
              // console.log("Check row ID: ",id);
          }  
        })
      .catch((error) => {
        console.error("Error check Producto: ", error);
        setRefresh(true);  // Reload Data from Server
      })
    };

  const editRow = (id) => {
      props.navigate(Config.URL_APP+"/compraEdit/"+id);

    };


  const addRow = (event) => {
    props.navigate(Config.URL_APP+'/compraAdd');
  };

  const [rowSelectionModel, setRowSelectionModel] = React.useState([]); // Para control del registro seleccionado en el GRID

  // Cambio de la Lista de Productos.
  const changeList = (param) => {
    localStorage.setItem("situacionLista",param);
    setRefresh('true');
  }
  // Intercambo de evento entre Maestro y Detalle
  const handleButtonClickFromItem = (id,action) => {
    // console.log("Se ha pulsado un botón, id: "+ id, action);
    switch(action) {
      case 'comprar':
        checkRow(id);
        break;
      case 'editar':
        editRow(id);
        break;
      case 'eliminar':
        deleteRow(id);
        break;
      default:
    }
  };

  return (
    <>
    <Nav />
    <div className='titlePage'>
            <h3 className="head_title">Compras de {localStorage.getItem("listaCompra")} </h3>
    </div>
    <ThemeProvider theme={theme}>

      <div className='titleButtons2' >
        <Button className="buttonTab" color="primary"  startIcon={<CachedIcon />} onClick={(event) => setRefresh(true)}></Button>
        <Button className="buttonTab" color="primary"  startIcon={<AddIcon />} onClick={(event) => addRow(event)}>Nuevo</Button>
      </div>
      <div className='titleButtons2' >
        {localStorage.getItem("situacionLista") === 'pendientes' ?
        <Button className="buttonTab" color="secondary" variant="contained" startIcon={<FilterIcon />} onClick={(event) => changeList('pendientes')}>
            Pendientes
        </Button>
        :
        <Button className="buttonTab" color="secondary" variant="outlined" startIcon={<FilterIcon />} onClick={(event) => changeList('pendientes')}>
            Pendientes
        </Button>}
        <span>&nbsp;</span>
        {localStorage.getItem("situacionLista") === 'hoy'?
        <Button className="buttonTab" color="secondary" variant="contained" startIcon={<FilterIcon />} onClick={(event) => changeList('hoy')}>
            Hoy
        </Button>
        :
        <Button className="buttonTab" color="secondary" variant="outlined" startIcon={<FilterIcon />} onClick={(event) => changeList('hoy')}>
            Hoy
        </Button>}
        <span>&nbsp;</span>
        {localStorage.getItem("situacionLista") === 'comprados'?
        <Button className="buttonTab" color="secondary" variant="contained" startIcon={<FilterIcon />} onClick={(event) => changeList('comprados')}>
            Comprados
        </Button>
        :
        <Button className="buttonTab" color="secondary" variant="outlined" startIcon={<FilterIcon />} onClick={(event) => changeList('comprados')}>
            Comprados
        </Button>}
      </div>
        <div className='panel2'>
          <div className='box-compraList2'>
            {rows.map(item => {
                return <Item key={item.id} item={item} onButtonClick={handleButtonClickFromItem}></Item>;
            })}
          </div>  
        </div>
        
      </ThemeProvider>
    </>
  );
}
// IMPORTANT ----------------------------------------
export default withRouter( CompraList );
compraList/itemList.js
import * as React from 'react';
import Card from '@mui/material/Card';
import CardActions from '@mui/material/CardActions';
import CardContent from '@mui/material/CardContent';
import CardMedia from '@mui/material/CardMedia';
import Button from '@mui/material/Button';
import DeleteIcon from '@mui/icons-material/Delete';
import EditIcon from '@mui/icons-material/Edit';
import CheckedIcon from '@mui/icons-material/ShoppingCart';


import Typography from '@mui/material/Typography';

import Config from '../config';

function renderFoto(params) {
  if (params !== null ) {
    var imagen= Config.URL_FILES+params;
    // console.log("URL de la imagen: ", imagen);
  // return (<div> <img src={imagen} alt="imagen de Compra" width="72"  /> </div>)
  return (imagen)
  }
}

export default function viewItem({item, onButtonClick}) {
  // console.log("Valores de Item: ",item);
  const imageItem = renderFoto(item.foto2);

  const handleButtonClick = (id, action) => {
    // Lógica cuando se hace clic en un botón dentro de Detalle
    //   console.log("Se ha pulsado un botón, id: "+ id, action);
    onButtonClick(id,action);
  };

  return (
    <div className='box-item' >
    <Card>
      <CardContent>
        <Typography gutterBottom variant="h5" sx={{ width: 331, display: 'block' }}>
          {item.Nombre}
        </Typography>
        <Typography variant="body2" sx={{ color: 'text.secondary', display: 'inline' }}>
          <Typography variant="subtitle2"  sx={{ display: 'inline' }}>Cantidad: </Typography>
          {item.Cantidad} {item.Unidad}
        </Typography>
      </CardContent>
      { (item.foto2 !== null ) ?
        <CardMedia
        sx={{ height: 224 }}
        image = {imageItem}
      />
      : ''}
      <CardActions>
        <Button size="small" variant="outlined" startIcon={<CheckedIcon/>} onClick={(event) => handleButtonClick(item.id,'comprar')}>Comprar</Button>
        <Button size="small" variant="outlined" startIcon={<EditIcon/>} onClick={(event) => handleButtonClick(item.id,'editar')} >Editar</Button>
        <Button size="small" variant="outlined" startIcon={<DeleteIcon/>} onClick={(event) => handleButtonClick(item.id,'eliminar')} >Borrar</Button>
      </CardActions>
    </Card>
    </div>
  );
}
compraList/style.css
.titlePage { 
    padding: 0px 5px 0px 5px; 
    text-align: center; 
    margin: auto
}
.titleButtons2 { 
    padding: 0px 5px 0px 5px; 
    text-align: left; 
    margin: auto;
    /* margin: 5px; */
}
.panel2 { 

    /* height: 400px ; */
    padding: 0px 5px 0px 5px; 
    text-align: left; 
    margin: auto;
} 
.box-compraList2 {
    padding: 3px 3px 3px 3px; 
    box-shadow: rgba(0, 0, 0, 0.35) 0px 5px 15px;
    border: 1px;
    border-color: #1976D2;
    border-style: solid;
    text-align: left; 
    /* display: grid; */
    /* color:dimgray */
}
.box-item {
    padding: 0px 0px 0px 0px; 
    margin: 3px 3px 3px 3px;
    /* box-shadow: rgba(0, 0, 0, 0.35) 0px 5px 15px;*/
    border: 1px;
    border-color: #1976D2;
    border-style: solid;
    width: 345px;
    display: inline-flex;
    /* color:dimgray */
}

.buttonTab {
        font-size: 12px !important;
}

@media screen and (max-width:768px)  {
    .titleButtons2 { 
        width: 100%; 
    } 
    .panel2 { 
        width: 100%;  
    } 
  }

  @media screen and (min-width:768px) {
    .titleButtons2 { 
        max-width: 1080px;
    } 
    .panel2 { 
        max-width: 1080px; 
        /* width: fit-content; */
    } 
  }

Cómo podéis apreciar, los botones del evento está en el código de «detalle», mientras que el código de cada uno de los eventos están en el código del «maestro». Realmente, la potencia de distribución de la presentación y la codificación de los eventos es grandiosa y se puede hacer cualquier tipo de distribución, la que mejor se ajuste a la funcionalidad y a la claridad del código.

Os dejo el código de este proyecto modificado. Me podéis preguntar sobre este tema lo que deseéis.

 

Adjuntos

Archivo Tamaño de archivo Descargas
zip Fuentes de REACT "compra2-react" 352 KB 28

Blog personal para facilitar soporte gratuito a usuarios de React y PHPRunner