Tutorial de React (comparación con PHPRunner)

Ya hace un tiempo que escribí este ejemplo de aplicación para el móvil en PHPrunner, Gestión de la Lista de la Compra.

Además de poner a prueba PHPRunner para aplicaciones móviles, expliqué que cambios había que hacer para poderla instalar como una aplicación Web en el móvil.

Yo pude contemplar, y espero que vosotros también, que estas aplicaciones creadas con PHPRunner podían funcionar, pero se alejaban bastante de las típicas aplicaciones que disfrutábamos en los móviles y estaban entre una aplicación de escritorio y de móvil.

Cuando ya dispuse de conocimientos en  React, me propuse ver esta misma aplicación en este nuevo entorno de desarrollo y este artículo viene a explicar cómo lo he hecho.

Objetivo

Pasar una aplicación típica de móvil realizada en PHPrunner a React, para su funcionamiento, principalmente, para el móvil y para usuarios del móvil.

DEMO (PHPRunner): https://fhumanes.com/compra/
DEMO (REACT): https://fhumanes.com/compra-react/

Comparten la base de datos y si no deseáis daros de alta podéis utilizar los usuarios «user1» y «user2». El Login/User Name  y Password son iguales. Os pido que no destruyáis los datos de estos usuarios.

Por favor, dedicar un momento para leer la explicación del funcionamiento del aplicativo para diferenciar qué es un «Producto» y qué un «Artículo de la Lista» y la gestión de los Grupos.

Solución Técnica

Para desarrollar este ejemplo practiqué con la nueva bibliotecas de Material UI y además tuve que resolver los siguientes apartados:

  • Disponer de opciones de menú para usuario identificado y sin identificar. Menú dinámico según su estado.
  • Establecer una solución para la identificación del usuario y definición y gestión de un «token» que identifica en el Server las credenciales del usuario.
  • Definir y establecer un mecanismo similar a las cookie de sesión y almacenamiento del estado para cada usuario. Se mantiene el estado asociado al «token» generado y la persistencia se hace en una tabla de base de datos.
  • Definir un mecanismo (ahora en el cliente) para mantener el estado del aplicativo. Nunca se almacena ni en «contexto» y en el «Almacén local», la password y datos significativos del usuario conectado.
  • Gestión de subida de imágenes y reducción de estas para ajustarse a las características de la aplicación. Se ha resulto un problema (general y de siempre) que tiene PHPRunner que las imágenes de móvil, si es vertical y se recorta, se pierde la orientación de la imagen.
  • Se establece formar dinámicas de los botones de acciones, dependiendo del estado del producto o del usuario conectado y sus privilegios.
  • Se establece cómo hacer cambios dinámicos en texto y botones dependiendo del estado o usuario.
  • Se resuelve la configuración para la instalación automática de la aplicación en el móvil.

Para hacer este ejemplo he tenido que revisar muchas publicaciones (existe una cantidad muy grande y de calidad, de publicaciones) y combinarlas para hacer una gestión de identidad parecida a la que hace PHPRunner. Tenemos que tener siempre presente, que ambas soluciones comparten el modelo de datos y sus datos serán gestionable por ambos sistemas.

La parte del Server en PHP he seguido las indicaciones de este artículo publicado hace un tiempo.

La parte que se ha añadido para responder a la aplicación REACT es:

La parte del «Include», es la configuración de la solución, para disponer de la personalización del entorno de Desarrollo y el de Producción.
Los otros 2 ficheros son las funciones que se utilizan. En «DbCompra.php», son todas las que acceden a la base de datos y el otro, las funciones que no acceden a base de datos.

En directorio «libs» es el FrameWork SLIM v4.X y en V1, es el interfaz de la aplicación con su «.htaccess» para que todas las peticiones vayan al fichero «index.php» y el fichero «index.php», todas la peticiones posibles y sus controles de validación de parámetros y y llamadas al resto de funciones.

Para la parte de REACT, que es nueva en el Blog, voy a mostrar su contenido y luego mostraré y explicaré algunos de sus códigos.

Esta figura representa el proyecto en MS Visual Studio, una vez que el ejemplo ha sido «instalado». ver comandos aquí.

(1).- En donde hay que configurar para cuando la aplicación no va a ejecutarse en el «Document Root» , del servidor web.

(2).- Ficheros que se utilizarán para construir el directorio «build» y donde se define el fichero «index.html», «manifiesf.json», los logos e iconos, así como el fichero «.htaccess», que van a requerir para su despliegue.

(3).- Son los fuentes del programa. Donde vamos a trabajar para construir la solución.

(4),(5) y (6).-  son los directorio que yo he definido para distribuir mis ficheros . En (4) tengo la definición de todas las pantalla y sus controles.

(5).- Defino en «Contexto», para guardar información entre pantallas, del usuario conectado

(6).- Definido el control de que opciones son de usuario sin identificación y usuario identificado

(7).- Primer ejecutable que se lanza cuando se accede y que tiene la relación de las url’s y los ficheros del código a ejecutar.

Fichero: App.js

Su función es enrutar la URL al programa que debe ejecutarse. También controla si puede acceder o no, al programa, dependiendo de la identificación del usuario.

import { BrowserRouter as Router, Route, Routes } from "react-router-dom";
import * as React from 'react';

import Config from './components/config';
import Login from "./components/login";
import Logout from "./components/logout";
import Register from "./components/register";
import SelectGroup from "./components/selectGroup";
import CompraList from "./components/compraList";
import CompraAdd from "./components/compraAdd";
import CompraEdit from "./components/compraEdit";
import CompraView from "./components/compraView";
import ProductoList from "./components/productoList";
import ProductoAdd from "./components/productoAdd";
import ProductoEdit from "./components/productoEdit";
import ProductoView from "./components/productoView";
import GrupoList from "./components/grupoList";
import GrupoAdd from "./components/grupoAdd";
import GrupoEdit from "./components/grupoEdit";
import UserList from "./components/userList";
import UserAdd from "./components/userAdd";

import UserConnect from "./components/userConnect";
import NotFound from "./components/notFound";
import AuthProvider from "./hooks/AuthProvider";

import PrivateRoute from "./router/route";
// import AdminRoute from "./router/routeAdmin";

function App() {

  return (
    <div className="App">
      <Router>
        <AuthProvider>
          <Routes>
            <Route path={`${Config.URL_APP}/`} element={<Login />} />
            <Route path={`${Config.URL_APP}/login`} element={<Login />} />
            <Route path={`${Config.URL_APP}/register`} element={<Register />} />
            <Route element={<PrivateRoute />}>
              <Route path={`${Config.URL_APP}/userconnect`} element={<UserConnect />} />
              <Route path={`${Config.URL_APP}/logout`} element={<Logout />} />
              <Route path={`${Config.URL_APP}/selectgroup`} element={<SelectGroup />} />   
              <Route path={`${Config.URL_APP}/compraList`} element={<CompraList />} />
              <Route path={`${Config.URL_APP}/compraAdd`} element={<CompraAdd />} />
              <Route path={`${Config.URL_APP}/compraEdit/:id`} element={<CompraEdit />} />
              <Route path={`${Config.URL_APP}/compraView/:id`} element={<CompraView />} />
              <Route path={`${Config.URL_APP}/productoList`} element={<ProductoList />} />
              <Route path={`${Config.URL_APP}/productoAdd`} element={<ProductoAdd />} />
              <Route path={`${Config.URL_APP}/productoEdit/:id`} element={<ProductoEdit />} />
              <Route path={`${Config.URL_APP}/productoView/:id`} element={<ProductoView />} />
              <Route path={`${Config.URL_APP}/grupoList`} element={<GrupoList />} />
              <Route path={`${Config.URL_APP}/grupoAdd`} element={<GrupoAdd />} />
              <Route path={`${Config.URL_APP}/grupoEdit/:id`} element={<GrupoEdit />} />
              <Route path={`${Config.URL_APP}/userList`} element={<UserList />} />
              if (auth.adminGroup !== null ) {  // Administrador del Grupo
                <Route path={`${Config.URL_APP}/userAdd`} element={<UserAdd />} />
              }
            </Route>
            {/* Other routes  */}
            <Route path="*" element={<NotFound />} />
           
          </Routes>
        </AuthProvider>
      </Router>
    </div>
  );
}
export default App;
Fichero: hooks/AuthProvider.js
Mantiene el Contexto de la aplicación (variables entre diferentes pantallas, y mantiene el «token» que requiere todas las peticiones Ajax, para que el server autentique , identifique al usuario y conteste.

import { useContext, createContext, useState } from "react";

const AuthContext = createContext();

const AuthProvider = ({ children }) => {

  const [user, setUser] = useState(localStorage.getItem("siteUser") || null);
  const [token, setToken] = useState(localStorage.getItem("site") || "");
  const [groupName, setGroupName] = useState(localStorage.getItem("siteGroup") || null);
  const [adminGroup, setAdminGroup] = useState(localStorage.getItem("siteAdmin") || null);
  const loginAction = (user,token) => {

      setUser(user);
      setToken(token);
      localStorage.setItem("site", token);  // si se quita, no se puede hacer "reload" de la página
      localStorage.setItem("siteUser", user);  // si se quita, no se puede hacer "reload" de la página

  };
  const groupAction = (group,admin) => {

    setGroupName(group);
    setAdminGroup(admin);
    localStorage.setItem("siteGroup", group);  // si se quita, no se puede hacer "reload" de la página
    localStorage.setItem("siteAdmin", admin);  // si se quita, no se puede hacer "reload" de la página
  };

  const logOut = () => {
    setUser(null);
    setToken("");
    setGroupName(null);
    setAdminGroup(null);

    localStorage.removeItem("site");
    localStorage.removeItem("siteUser");
    localStorage.removeItem("siteGroup");
    localStorage.removeItem("siteAdmin");

    localStorage.removeItem("listaCompra"); 
    localStorage.removeItem("situacionLista");
  };
  return (
    <AuthContext.Provider value={{ token, user, groupName, adminGroup, loginAction, logOut, groupAction  }}>
      {children}
    </AuthContext.Provider>
  );
};
export default AuthProvider;

export const useAuth = () => {
  return useContext(AuthContext);
};
Fichero: router/route.js
Controla qué URL’s son con usuario identificado y cuáles no. Este control lo hace por la existencia o no de la clave «Token».

import React from "react";
import { Navigate, Outlet } from "react-router-dom";
import { useAuth } from "../hooks/AuthProvider";
import Config from '../components/config';

const PrivateRoute = () => {
  const user = useAuth();
  const urlLogin = Config.URL_APP+"/login";
  if (!user.token) return <Navigate to={urlLogin} />;
  return <Outlet />;
};

export default PrivateRoute;
Fichero: components/config
En este fichero se define la información que depende de la instalación, por ejemplo del entorno de Desarrollo y del de Producción, así como los mensajes y cualquier otra definición que sea general.

// Parámetros de configuración de la App
const configuration = 'production';  // 'test' or 'production'
var Config =null;

if (configuration === 'test') {
    Config = {
        RUTA_API: "http://localhost/compra/restapi/v1",
        URL_APP: "/compra-react",
        TITLE_APP: "COMPRA",
        AUTHORIZATION: "3d524a53c110e4c22463b10ed32cef9d",
        URL_FILES: "http://localhost/files/"
    };
} else {
    // Config edl Server Linux
    Config = {
        RUTA_API: "https://fhumanes.com/compra/restapi/v1",
        URL_APP: "/compra-react",
        TITLE_APP: "COMPRA",
        AUTHORIZATION: "3d524a53c110e4c22463b10ed32cef9d",
        URL_FILES: "https://fhumanes.com/files/"
    };
};
export default Config;
Este conjunto de ficheros adicionales va a mostrar la forma básica de List, Add, Edit, View y Delete de una de la Entidades de base de datos.

Fichero: components/grupoList
Este programa muestra todos los grupos a los que está asociado el usuario conectado. Dependiendo de si el usuario conectado es el Administrador del grupo, podrá o no hacer operaciones y actualizaciones diferentes.

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

import {
  DataGrid,
  GridToolbarContainer,
  GridToolbarColumnsButton,
  GridToolbarFilterButton,
  GridToolbarDensitySelector,
  GridToolbarExport,
  GridActionsCellItem,
  // GridToolbar,
} from '@mui/x-data-grid';

// 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 DeleteIcon from '@mui/icons-material/Delete';

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 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  GrupoList(props) {
 
  const [rows, setRows] = useState(null);

  // const [users, setUsers] = useState(null);  // API  /usersGroupList
  
  const [refresh, setRefresh] = useState(true);
  const [isMobile, setIsMobile] = useState(false);

  const auth = useAuth();

  // 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');
  }

  // Cargar los datos del Server cuando se carga y recarga la página
  const fetchGrupo= 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 ");
      }

      const url = Config.RUTA_API + "/grupoList";
      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);
            
            if (data.error === true )  { 
              console.log("Hay un error: ",data.message);
              errorNotification(data.error, data.message);
            } else {
              setRows(data);
            }
              
          }
      catch(error) {
            console.error("Error get Lista Grupos ", error);
            errorNotification('000', error);
          }
    }
  };

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

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

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

      var msg = '';
      if (row.Login === null ) {
        msg = `¿<b>Te quieres dar de baja</b> <br>del Grupo: ${row.Nombre} ?`;
      } else {
        msg = `¿Quieres <b>ELIMINAR el Grupo:</b> ${row.Nombre}? <br><br><b>TODA LA INFORMACIÓN ASOCIADA (Lista, Productos, Usuarios del Grupo) será Eliminada</b>`;
      }
      const MySwal = withReactContent(Swal); 
      MySwal.fire({
        title: 'Confirmación',
        html: msg,
        icon: 'warning',
        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 + "/grupo/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: 'Grupo eliminado!!!!',
                      text: '',
                      timer: 1000,
                      timerProgressBar: true,
                      toast: true,
                      position: "center",
                      footer: ''
                    })
                    if ( row.Nombre === auth.groupName ) { // Si ha eliminado del grupo al que está conectado
                      props.navigate(Config.URL_APP+'/logout');
                    }
                  } else {
                    errorNotification(res.code, res.message);
                }
              })
            .catch((error) => {
              console.error("Error delete Grupo: ", error);
              setRefresh(true);  // Reload Data from Server
            })
          }
        })
    },
    [rows, auth.token],
  );

  const editRow = React.useCallback(
    (id) => () => {
      const row = rows.find((rowid) => rowid.id === id);
     
      if (row.Login === null) {  // No es administrador del Grupo y no puede modificar
        const text = `<p>¡¡¡ No eres administrador de: <b> ${row.Nombre}</b> !!!</p><p><b>No puedes modificar el Grupo.</b></p>`; // ${row.nombre}
        const MySwal = withReactContent(Swal); 
        MySwal.fire({
          title: "<h3>Acción no se puede ejecutar</h3>",
          icon: "info",
          html: text,
          showCloseButton: true,
          showCancelButton: false,
          focusConfirm: false,
          closeButtonText: 'OK',
          // confirmButtonAriaLabel: "Thumbs up, great!",
          // cancelButtonText: `<i class="fa fa-thumbs-down"></i>`,
          // cancelButtonAriaLabel: "Thumbs down"
        });
      } else {
        props.navigate(Config.URL_APP+'/grupoEdit/'+id);
      }
    },
    [rows],
  );

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

  function CustomToolbar() {
    return (

      <GridToolbarContainer>
        <GridToolbarColumnsButton />
        <GridToolbarFilterButton />
        { isMobile ? '' 
          : <GridToolbarDensitySelector /> 
        }
        <GridToolbarExport />
      </GridToolbarContainer>
    );
  }

  const isRowEditable = (row) => {
    // console.log(" Datos del registro: ",row);
    if (row.Login === null) {
      return false
    } else {
      return true
    }
  }

  const columns = React.useMemo(
    () => [
      {
        field: 'actions',
        hideable: false,
        headerName: 'Acciones',
        type: 'actions',
        minWidth: 75,
        getActions: ({ id, row }) => {
          if (isRowEditable(row)) {
            return [
                <GridActionsCellItem
                  icon={<EditIcon />}
                  label="Edit"
                  // className="textPrimary"
                  onClick={editRow(id)}
                  color="inherit"
                />,
                <GridActionsCellItem
                icon={<DeleteIcon />}
                label="Delete"
                // className="textPrimary"
                onClick={deleteRow(id)}
                color="inherit"
              />,
            ];
          } else {
            return[
              <GridActionsCellItem
              icon={<DeleteIcon />}
              label="Delete"
              // className="textPrimary"
              onClick={deleteRow(id)}
              color="inherit"
            />,
          ]}
        }

      },
      //  { field: 'groupId', type: 'number', width: 40}, 
      { field: 'Nombre', hideable: false, headerName: 'Nombre', type: 'string', minWidth: 200 }, 
      { field: 'NombreUsuario', headerName: 'Administrador', type: 'string', minWidth: 200 },
    ],
    [deleteRow, editRow],
  );

  return (
    <>
    <Nav />
    <div className='titlePage'>
            <h3 className="head_title">Estos son tus Grupos / Listas de Compra </h3>
    </div>
    <ThemeProvider theme={theme}>
      <div className='titleButtons' >
        <Button color="primary"  startIcon={<CachedIcon />} onClick={(event) => setRefresh(true)}></Button>
        <Button color="primary"  startIcon={<AddIcon />} onClick={(event) => addRow(event)}>
            Nuevo
        </Button>
      </div>

        <div className='panel'>
          <div className='box-compraList'>
           <DataGrid  columns={columns} rows={rows}
              initialState={{
                pagination: {
                  paginationModel: { page: 0, pageSize: 100 },
                },
                columns: {
                  columnVisibilityModel: {

                  },
                },
              }}
              autosizeOptions={{
                columns: ['Nombre', 'Login'],
                includeOutliers: true,
                includeHeaders: true,
              }}
              pageSizeOptions={[5, 10, 15, 20, 100]}
              // enableRowNumbers
              slots={{
                toolbar: CustomToolbar,
              }}

            />
          </div>  
        </div>
        
      </ThemeProvider>
    </>
  );
}
// IMPORTANT ----------------------------------------
export default withRouter( GrupoList );
Fichero: components/grupoAdd

Funcionalmente se utiliza para dar de alta un Grupo, donde el usuario conectado será el Administrador de dicho Grupo.

Podemos ver que los campos de entrada los guardamos en formato JSON/array ne este caso y en la parte de Edit, como campos independientes.

También, de forma general, se utilizan 2 variables para el control global de formulario «validacionForms» y «errorForms». De forma similar, se utilizan otros 2 campos de control para cada uno de los campos de entrada «errorXXXXX» y «helperXXXXXX».

Lo normal en estos ejemplos es que las validaciones de la información se hagan a través de «Expresiones Regulares».

import * as React from 'react';
import {useEffect, useState} from 'react';
import './style.css';

import { createTheme, ThemeProvider } from '@mui/material/styles';
import { esES } from '@mui/material/locale';

// import { styled } from '@mui/material/styles';
import Grid from '@mui/material/Grid2';

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

import ReplySharpIcon from '@mui/icons-material/ReplySharp';

// import { Select, MenuItem, InputLabel, FormHelperText } from '@mui/material';

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

import Swal from 'sweetalert2'
import withReactContent from 'sweetalert2-react-content'

import axios from 'axios';
import { useAuth } from "../../hooks/AuthProvider";

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

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

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

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


function GrupoAdd(props) {

  const auth = useAuth();

  // console.log("Grupo al que está identificado: ",auth.groupName);
  
  const [validacionForms, setValidacionForms] = useState(true);
  const [errorForms, setErrorForms] = useState('');

  const [input, setInput] = useState({
    grupo: "",
    descripcion: ""
  });

  const [errorGrupo, setErrorGrupo] = useState(false);
  const [helperGrupo, setHelperGrupo] = useState('');

  const [errorDescripcion, setErrorDescripcion] = useState(false);
  const [helperDescripcion, setHelperDescripcion] = useState('');

  const [menu, setMenu] = useState(false);

  // 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');
    }


  // Cargar los datos del Server cuando se carga la página
  useEffect(() => {
    // console.log("Ejecutando 'useEffect() en grupoAdd", auth);
    if (auth.groupName === null) {
      setMenu(false);
    } else {
      setMenu(true);
    }
  }, [auth]);


     // Envío del Formulario
  const saveForms = (e) => { 
    e.preventDefault();
    setValidacionForms(true); // Quitamos errores del formulario
    setErrorForms('');

    var existeError = false;
    var Error = '';
  
    if (input.grupo === '' || errorGrupo ) {
      setHelperGrupo('Hay que facilitar un Grupo Válido');
      setErrorGrupo(true);
      existeError = true;
      Error += ' (Grupo) ';
    }

    if ( errorDescripcion ) {
      setHelperDescripcion('Hay que facilitar una Descripción Válida');
      setErrorDescripcion(true);
      existeError = true;
      Error += ' (Descripción) ';
    }

    if (!(existeError)) { // se han introducido los valores de los campos
      // console.log("accediendo al Server");
      // Acceso al server y status de la operación
      const url = Config.RUTA_API + "/grupo/add/0";
      const formData = new FormData();
        formData.append('Nombre', input.grupo);
        formData.append('Descripcion', input.descripcion);
      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) {
            
            setValidacionForms(true); // Quitamos errores del formulario
            setErrorForms('');
            postSave();               // Alert y navegar a la aplicación
  
          } else {
            setValidacionForms(false); 
            setErrorForms(res.message);  
          }
        })
        .catch((error) => {
          console.error("Error send Register: ", error);
          setValidacionForms(false); 
          setErrorForms("Error send Register: ", error); 
          errorNotification('000', error)

        });
  
    } else {
    
      setValidacionForms(false); // Ponemos los errores del formulario
      
      Error = 'Falta la entrada de los datos o la información de estos campos está mal: '+ Error;
      setErrorForms(Error);
    }
  };

  const postSave = (e) => {

    const MySwal = withReactContent(Swal); 
    MySwal.fire({
      icon: 'info',
      title: 'Nuevo Grupo creado!!!!',
      text: '',
      timer: 1000,
      timerProgressBar: true,
      toast: true,
      position: "center",
      footer: ''
     })
    if (auth.groupName === null ) {
        props.navigate(Config.URL_APP+'/selectgroup');
    } else {
        props.navigate(Config.URL_APP+'/grupoList');
    }
  }

  const handleValidationGrupo = (e) => {
    const { name, value } = e.target;
    setInput((prev) => (
    { ...prev,
      [name]: value,
    }));

    const reg = new RegExp("^([a-zA-Z0-9_áéíóúüñÑ -]){3,50}$");
    if (! reg.test(e.target.value)) {
      setHelperGrupo('Entrada inválida para "Nombre de grupo"');
    } else {
      setHelperGrupo('');
    }
    setErrorGrupo(!reg.test(e.target.value));
  };

  const handleValidationDescripcion = (e) => {
    const { name, value } = e.target;
    setInput((prev) => (
    { ...prev,
      [name]: value,
    }));

    const reg = new RegExp("^([a-zA-Z0-9_áéíóúüñÑ, -]){0,200}$");
    if (! reg.test(e.target.value)) {
      setHelperDescripcion('Entrada inválida para "Descripción"');
    } else {
      setHelperDescripcion('');
    }
    setErrorDescripcion(!reg.test(e.target.value));
  };

  
  return (
    <>
    <Nav />
    <div className='titlePage'>
        <h3 className="head_title">Añadir Grupo</h3>
    </div>
    <ThemeProvider theme={theme}>
    <div className='panel-compraAdd'>
      <div className='box-compraAdd'>
        <Box component="form" noValidate autoComplete="off" >  
          <Grid container spacing={2}>
            <Grid xs={12}>
              <TextField  
                  error={errorGrupo} 
                  required={false}
                  id="grupo"
                  name="grupo" 
                  label="Grupo" 
                  variant="standard" 
                  type= 'text' 
                  className= 'CustomField'  
                  helperText={helperGrupo}
                  value={input.grupo }
                  onChange={(e) => handleValidationGrupo(e)}
                  />
            </Grid>
            <Grid xs={12}>
              <TextField  
                  error={errorDescripcion} 
                  required={false}
                  id="descripcion"
                  name="descripcion" 
                  label="Descripcion" 
                  variant="standard" 
                  type= 'text' 
                  multiline
                  maxRows={4}
                  className= 'CustomField'  
                  helperText={helperDescripcion}
                  value={input.Descripcion }
                  onChange={(e) => handleValidationDescripcion(e)}
                  />
            </Grid>

          </Grid>
          <Stack spacing={2} direction="row" sx={{ width: '100%', padding: '15px 10px 15px 10px'}} >
              <Button variant="contained" type='submit'  onClick={(e) => saveForms(e)}>Guardar</Button>
              {menu?
              <Button variant="outlined"  startIcon={<ReplySharpIcon />}  onClick={(e) =>props.navigate(Config.URL_APP+'/grupoList')}>Volver</Button> 
              :''}
          </Stack>
          <Stack sx={{ width: '100%' }} spacing={2}>
            { validacionForms? '': <Alert severity="error">{errorForms}</Alert>}
          </Stack>
        </Box>
      </div>
    </div>
    </ThemeProvider>
    </>
  );
}

// IMPORTANT ----------------------------------------
export default withRouter( GrupoAdd);
Fichero: components/grupoEdit

Funcionalmente, se verá el icono de edición si el usuario conectado es el administrador del grupo que se desea editar.

import * as React from 'react';
import {useEffect, useState} from 'react';
import './style.css';

// import { styled } from '@mui/material/styles';
import Grid from '@mui/material/Grid2';

import ReplySharpIcon from '@mui/icons-material/ReplySharp';

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

import { createTheme, ThemeProvider } from '@mui/material/styles';
import { esES } from '@mui/material/locale';

import { Select, MenuItem, InputLabel, FormHelperText } from '@mui/material';

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

import Swal from 'sweetalert2'
import withReactContent from 'sweetalert2-react-content'

import axios from 'axios';
import { useAuth } from "../../hooks/AuthProvider";

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

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

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

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

function GrupoEdit(props) {

  const [users, setUsers] = useState([{
    idct_usuario: '',
    NombreyApellidos: ''
  }]);
 
  const [grupo, setGrupo] = useState(null);

  const [refresh, setRefresh] = useState(true);


  const auth = useAuth();
  
  const [validacionForms, setValidacionForms] = useState(true);
  const [errorForms, setErrorForms] = useState('');

  const [in_grupo, setIn_grupo] = useState('');
  const [in_administrador, setIn_administrador] = useState(null);

  const [errorGrupo, setErrorGrupo] = useState(false);
  const [helperGrupo, setHelperGrupo] = useState('');

  const [errorAdministrador, setErrorAdministrador] = useState(false);
  const [helperAdministrador, setHelperAdministrador] = useState('');

  const { id } = useParams()                                        // Parámetro con el "id" de la Compra

  // 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');
    }

    // const sleep = ms => new Promise(r => setTimeout(r, ms));

    // Cargar los datos del Server cuando se carga y recarga la página
    const fetchGrupo = async () =>
    {
      if (refresh) {        // Para intentar que sólo sea una vez
        setRefresh(false);

        fetchUsuarios();

        const url = Config.RUTA_API + "/grupo/view/"+id; 
        // console.log("Valor de URL: ",url);
        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;
              var datos = response.data.data;
              console.log("Respuesta del fetch(compra): ",data);
              // console.log("Respuesta del fetch(compra/Datos): ",datos);
              if (data.error === true )  { 
                console.log("Hay un error: ",data.message);
                errorNotification(data.error,data.message);
              } else {
                setGrupo(datos);
                setIn_grupo(datos.Nombre);
                setIn_administrador(datos.Administrador);
              }
                
            }
          catch(error) {
              console.log("Error get View Grupo", error);
              errorNotification('000',error);
            }
      }
    };

// Cargar los datos del Server cuando se carga y recarga la página
function fetchUsuarios() 
{

  const url = Config.RUTA_API + "/usersGroupList"; 
  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) => {       
        var data = response.data;

        if (data.error === true )  { 
          console.log("Hay un error: ",data.message);
          errorNotification(data.error,data.message);
        } else {
          console.log("Usuarios del grupo: ",data)
          setUsers(data);
        }
          
      })
    .catch((error) => {
        console.log("Error get Usuarios", error);
        errorNotification('000',error);
      })
};
    
  
    // Cargar los datos del Server cuando se carga la página
    useEffect(() => {
      console.log("Ejecutándose 'useEffect");

      fetchGrupo();
       }, [refresh]);

  // Envío del Formulario
  const saveForms = (e) => { 
    e.preventDefault();
    setValidacionForms(true); // Quitamos errores del formulario
    setErrorForms('');

    var existeError = false;
    var Error = '';
  
    if (in_grupo === '' || errorGrupo ) {
      setHelperGrupo('Hay que facilitar un Grupo Valido');
      setErrorGrupo(true);
      existeError = true;
      Error += ' (Grupo) ';
    }
    if (in_administrador === '') {
      setHelperAdministrador('Hay indicar un Administrador');
      setErrorAdministrador(true);
      existeError = true;
      Error += ' (Administrador) ';
    }

    if (!(existeError)) { // se han introducido los valores de los campos
      // console.log("accediendo al Server");
      // Acceso al server y status de la operación
      const url = Config.RUTA_API + "/grupo/update/"+id;
      const formData = new FormData();
        formData.append('Nombre', in_grupo);
        formData.append('Administrador', in_administrador);
      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) { 
            setValidacionForms(true); // Quitamos errores del formulario
            setErrorForms('');
            postSave();               // Alert y navegar a la aplicación
          } else {
            setValidacionForms(false); 
            setErrorForms(res.message);  
          }
        })
        .catch((error) => {
          console.error("Error send Register: ", error);
          setValidacionForms(false); 
          setErrorForms("Error send Register: ", error); 
          errorNotification('000', error)
        });
  
    } else {
    
      setValidacionForms(false); // Ponemos los errores del formulario 
      Error = 'Falta la entrada de los datos o la información de estos campos está mal: '+ Error;
      setErrorForms(Error);
    }
  };

  const postSave = (e) => {
    const MySwal = withReactContent(Swal); 
    MySwal.fire({
      icon: 'info',
      title: 'Grupo actualizado!!!!',
      text: '',
      timer: 1000,
      timerProgressBar: true,
      toast: true,
      position: "center",
      footer: ''
     })
    props.navigate(Config.URL_APP+'/grupoList');
  }

  const handleValidationAdministrador = (e) => {

    const { name, value } = e.target;

      setIn_administrador(value);
  };

  const handleValidationGrupo = (e) => {
    const { name, value } = e.target;
    setIn_grupo(value);

    //  var regxp = /^([a-zA-Z0-9_-áéíóúñÑ]){3,60}$/;
    const reg = new RegExp("^([a-zA-Z0-9_áéíóúüñÑ -]){3,50}$");
    if (! reg.test(e.target.value)) {
      setHelperGrupo('Entrada inválida para "Nombre de Grupo"');
    } else {
      setHelperGrupo('');
    }
    setErrorGrupo(!reg.test(e.target.value));
  };


  return (
    <>
    <Nav /> 
    <div className='titlePage'>
        <h3 className="head_title">Editar Grupo</h3>
    </div>

    <ThemeProvider theme={theme}>
    <div className='panel-compraAdd'>
      <div className='box-compraAdd'>
        <Box component="form" noValidate autoComplete="off" >  
          <Grid container spacing={2}>
          <Grid xs={12}>
              <TextField  
                  error={errorGrupo} 
                  required={true}
                  id="grupo"
                  name="grupo" 
                  label="Grupo" 
                  variant="standard" 
                  type= 'text' 
                  className= 'CustomField'  
                  helperText={helperGrupo}
                  value={in_grupo || ''}
                  onChange={(e) => handleValidationGrupo(e)}
                  />

            </Grid>
            <Grid xs={12}>
                <InputLabel id="select-label">Administrador</InputLabel>
                <Select 
                  error={errorAdministrador} 
                  id="administrador" 
                  name="administrador"
                  label="Administrador" 
                  required={true}
                  variant="standard"   
                  className= 'CustomField'  
                  value={in_administrador || ''}
                  onChange={handleValidationAdministrador}
                >
                {users.map((row, index) => (
                  <MenuItem key={row.idct_usuario}  value={row.idct_usuario}> {row.NombreyApellidos} 
                  </MenuItem> 
                ))}
                </Select>
                {errorAdministrador && <FormHelperText>{helperAdministrador}</FormHelperText>}
            </Grid>
          </Grid>
          <Stack spacing={2} direction="row" sx={{ width: '100%', padding: '15px 10px 15px 10px'}} >
              <Button variant="contained" type='submit'  onClick={(e) => saveForms(e)}>Guardar</Button>
              <Button variant="outlined"  startIcon={<ReplySharpIcon />}  onClick={(e) =>props.navigate(Config.URL_APP+'/grupoList')}>Volver</Button> 
          </Stack>
          <Stack spacing={2} direction="row" sx={{ width: '100%', padding: '15px 10px 15px 10px'}} >
              
          </Stack>
          {
          <Stack sx={{ width: '100%' }} spacing={2}>
            { validacionForms? '': <Alert severity="error">{errorForms}</Alert>}
          </Stack>
          }
        </Box>
      </div>
    </div>
    </ThemeProvider>
    </>
  );
}

// IMPORTANT ----------------------------------------
export default withRouter(GrupoEdit);
Fichero: components/productoView

Como los Grupos no disponen de una página de View, muestro la de «Producto». La característica más relevante es que muestra la imagen del producto.

import * as React from 'react';
import {useEffect, useState} from 'react';
import './style.css';

// import { styled } from '@mui/material/styles';
import Grid from '@mui/material/Grid2';

import EditIcon from '@mui/icons-material/Edit';

import Box from '@mui/material/Box';
import TextField from '@mui/material/TextField';
import Stack from '@mui/material/Stack';
import Button from '@mui/material/Button';
// import Rating from '@mui/material/Rating';
// import Typography from '@mui/material/Typography';
// import StarIcon from '@mui/icons-material/Star';

import { Select, MenuItem, InputLabel } from '@mui/material';

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

import Swal from 'sweetalert2'
import withReactContent from 'sweetalert2-react-content'

import axios from 'axios';
import { useAuth } from "../../hooks/AuthProvider";

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

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

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

function ProductoView(props) {


  const [unidad, setUnidad] = useState([
    {
      "idct_unidad": 0,
      "Titulo": ""
    }
  ]);
  const [producto, setProducto] = useState(
    {

    }
  );
  const [refresh, setRefresh] = useState(true);

  const auth = useAuth();
 
  const [in_producto, setIn_producto] = useState('');
  const [in_cantidad, setIn_cantidad] = useState('');
  const [in_unidad, setIn_unidad] = useState('');


  const { id } = useParams()                                        // Parámetro con el "id" de la Compra

  // var formWitdh = window.innerWidth;
  // console.log("Ancho de pantalla: ",formWitdh);
  
  // const auth = useAuth();
  // console.log("Función AUTH: ",auth);

  // 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');
    }

    function renderFoto() {
      if (producto.foto !== null ) {
        var imagen= Config.URL_FILES+producto.foto;
        // console.log("URL de la imagen: ", imagen);
        return (<div> <img className="imageProducto" src={imagen} alt="imagen de Producto" /> </div>)
    }
    }

    // Cargar los datos del Server cuando se carga y recarga la página
    const fetchProducto = async () =>
    {
      if (refresh) {        // Para intentar que sólo sea una vez
        setRefresh(false);

        // fetchProductos();
        fetchUnidades();

        const url = Config.RUTA_API + "/producto/view/"+id; 
        // console.log("Valor de URL: ",url);
        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;
              var datos = response.data.data;
              // console.log("Respuesta del fetch(compra): ",data);
              // console.log("Respuesta del fetch(compra/Datos): ",datos);
  
              setProducto(datos);
              // console.log("Valor de 'compra':",compras);
              setIn_producto(datos.Nombre);
              setIn_cantidad(datos.Cantidad);
              setIn_unidad(datos.ct_unidad_idct_unidad);
              // console.log("valor de 'in_producto':",in_producto);
              // console.log("Valor de Data:",datos.Nombre);

              if (data.error === true )  { 
                console.log("Hay un error: ",data.message);
                errorNotification(data.error,data.message);
              }
                
            }
          catch(error) {
              console.log("Error get Producto", error);
              errorNotification('000',error);
            }
      }
    };

// Cargar los datos del Server cuando se carga y recarga la página
function fetchUnidades() 
{

  const url = Config.RUTA_API + "/listadoUnidades"; 
  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) => {       
        var data = response.data;
        // console.log("Respuesta del fetch(Unidades): ",data);
        setUnidad(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.log("Error get Listados Producto", error);
        errorNotification('000',error);
      })
};
    
    // Cargar los datos del Server cuando se carga la página
    useEffect(() => {
      console.log("Ejecutándose 'useEffect");

      fetchProducto();
       }, [refresh]);


  return (
    <>
    <Nav /> 
    <div className='titlePage'>
        <h3 className="head_title">Visualizar Producto</h3>
    </div>
    <div className='panel-compraView'>
      <div className='box-compraView'>
        <Box component="form" noValidate autoComplete="off" >  
          <Grid container spacing={2}>
          <Grid xs={12}>
            <TextField  
                  required={true}
                  id="producto"
                  name="producto" 
                  label="Producto" 
                  variant="standard" 
                  type= 'text' 
                  className= 'CustomField'  
                 
                  value={in_producto || ''}
                  slotProps={{ input: { readOnly: true, }, }}
                  />           
            </Grid>

            <Grid xs={12} >
                <TextField  
                  required={true}
                  id="cantidad"
                  name="cantidad" 
                  label="Cantidad" 
                  variant="standard" 
                  type= 'text' 
                  className= 'CustomField'  
                 
                  value={in_cantidad || ''}
                  slotProps={{ input: { readOnly: true, }, }}
                  />
            </Grid>
            <Grid xs={12}>
                <InputLabel id="select-label">Unidad/Medida *</InputLabel>
                <Select 
                 
                  id="unidad" 
                  name="unidad"
                  label="Unidad" 
                  required={true}
                  variant="standard"   
                  className= 'CustomField'  
                  value={in_unidad || ''}
                  slotProps={{ input: { readOnly: true, }, }}
                >
                {unidad.map((row, index) => (
                  <MenuItem key={row.idct_unidad}  value={row.idct_unidad}> {row.Titulo} 
                  </MenuItem> 
                ))}
                </Select>
            </Grid>
            <Grid xs={12}>
                <InputLabel id="select-label">Foto</InputLabel>
                {renderFoto()}
            </Grid>
          </Grid>
          <Stack spacing={2} direction="row" sx={{ width: '100%', padding: '15px 10px 15px 10px'}} >
              <Button variant="contained"  onClick={(e) =>props.navigate(Config.URL_APP+'/productoList')}>Volver</Button>
              <Button variant="outlined" startIcon={<EditIcon />}  onClick={(e) =>props.navigate(Config.URL_APP+'/productoEdit/'+id)}>Editar</Button>
          </Stack>
        </Box>
      </div>
    </div>
    </>
  );
}

// IMPORTANT ----------------------------------------
export default withRouter(ProductoView);

Os facilito los fuentes de ambos proyectos.

Actualizaciones

11/12/2024:
Se ha cambiado en PHP, la distribución de las funciones. Todas las que acceden a Base de Datos, están en el fichero «DbCompra.php».
Se ha añadido en JavaScript, la consulta del usuario conectado y la función de «Logout», porque como estaba, en algunos momentos daba un error porque no le daba tiempo a «repintar» una pantalla.

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