S006 – Ejemplo de CRUD con Svelte 5 y SVAR -Provisional

Como pongo en el título de la entrada, este artículo es provisional pues quiero detallaros cómo he hecho el ejemplo, qué aspectos hay que tener en cuenta y lo que me ha ayudado las IA’s, en concreto Copilot (muy poco).

El resultado es bastante bueno y cuando entiendes, cómo están construidos los componentes de SVAR, todo empieza a clarificarse a verse «relativamente» sencillo.

Objetivo

Mostrar un CRUD de un tabla con campos de texto, numérico, fecha , combos t especial (estrellas).

Demo: https://fhumanes.com/my-svar-app/

Es la misma base de datos del ejemplo este ejemplo

Solución Técnica

Quiero explicaros con mucho detalle tanto el ejemplo del Front-End (Svelte 5 con componentes de SVAR) como el de Back-End (PHP Slim 4). Este ejemplo de Server es el modelo que voy a utilizar y está bastante más claro y sencillo que el del caso S002.

Durante unos días voy a estar ocupado y no voy a poderlo publicar completamente, pero he pensado que lo mismo aprovecháis tiempo de vacaciones para revisar código y he creído que os podría interesar.

Código del Front-End:

<script>
  import { onMount } from 'svelte';
  import { ModalArea } from "wx-svelte-core"; // Ventana de Confirmación
  import { Grid } from 'wx-svelte-grid';
  import { Button, Willow, Locale } from 'wx-svelte-core';
  import { Editor } from 'wx-svelte-editor';
  import { ContextMenu } from 'wx-svelte-menu'; 
  import { registerEditorItem } from "wx-svelte-editor";
  import { Text, Select, DatePicker, RichSelect } from "wx-svelte-core";
  import { es as coreEs } from "wx-core-locales";
  import { default as gridEs} from "../locales/esGrid.js";
  import { default as editorEs } from "../locales/esEditor.js";


  import Toasts from "../notification/Toasts.svelte";
  import { addToast } from "../notification/store";

  import StarCell from './StarCell.svelte';
  import api from '../lib/api.js';

  // let lang = "es";
  // locale(lang);
  let gridRef = $state();
  let selected = $state([]);
  // --- DATOS DEL BACKEND ---
  let movies = $state([]); // Aquí se cargarán las películas de la API
  let themes = $state([]); // Aquí se cargarán los temas de la API
  let supports = $state([]); // Aquí se cargarán los soportes de la API
  
  let loading = $state(true);
  
  let menuRef = $state();

  let showEditor = $state(false);
  let editorValues = $state({});
  let editorMode = $state("add");

  // Registros necesarios para el Editor
  registerEditorItem("text", Text);
  registerEditorItem("number", Text);
  registerEditorItem("select", RichSelect);
  registerEditorItem("datepicker", DatePicker);

  // Ventana Modal de Confirmación
  let showDeleteConfirm = $state(false);
  let movieToDelete = $state(null);

// Definición de columnas para el Editor
 let editorItems = $derived([
  { key: "name", label: "Nombre", comp: "text", required: true, maxLength: 40 },
  { key: "price", label: "Precio", comp: "number", required: true, 
       validation: val => {
          const regEx = /^\d{1,5}(\.\d{1,2})?$/; // Se puede consultar a cualquier IA
          return val && regEx.test(val); // && val.length <= 40;
        },
        validationMessage: "Introduce un número válido (máx. 5 enteros y 2 decimales)" },
  { key: "startDate", label: "Fecha", comp: "datepicker", required: true },
  {
    key: "rating",
    label: "Valoración",
    comp: "select",
    options: [1, 2, 3, 4, 5].map(n => ({ id: n, label: `${n} estrellas` })),
    required: true
  },
  {
    key: "theme_id",
    label: "Tema",
    comp: "select",
    options: themes.map(t => ({ // cambio de los nomb res de los campos
            id: t.id_theme,
            label: t.theme
          })),
    required: true
  },
  {
    key: "support_id",
    label: "Soporte",
    comp: "select",
    options: supports.map(s => ({ // Cambio de los nombres de los campos
            id: s.id_support,
            label: s.support
          })),
    required: true
  }
]);
  // Menú de contexto para el GRID
  const contextOptions = [
    { id: "add", text: "Agregar", icon: "wxi-plus" },
    { id: "edit", text: "Editar", icon: "wxi-edit" },
    { id: "view", text: "Ver", icon: "wxi-eye" },
    { id: "delete", text: "Eliminar", icon: "wxi-delete-outline" }
  ];
  // Gestión de las Acciones de contexto del menú del GRID
  function handleContext(ev) {
      const id = gridRef.getState().selectedRows[0];
      const row = id ? gridRef.getRow(id) : null;
      if (!Array.isArray(editorItems)) {
        console.warn("editorItems no es un array:", editorItems);
      }

      switch (ev.action?.id) {
        case "add":
          editorMode = "add";
          editorValues = {};
          showEditor = true;
          break;
        case "edit":
          if (row) {
            editorMode = "edit";
            editorValues = { ...row };
            showEditor = true;
          }
          break;
        case "view":
          if (row) {
            editorMode = "view";
            editorValues = { ...row };
            // Formateamos la fecha al formato que deseemos
            editorValues = {
              ...row,
              startDate: row.startDateText // Tomamos el campo formateado para mostrarlo en View
            };
            showEditor = true;
          }
          break;
        case "delete":
          if (id) {
            const movie = movies.find(m => m.id === id);
            if (movie) {
              movieToDelete = movie;
              showDeleteConfirm = true;
            }
          }
          break;
        default:
          console.warn("Acción no reconocida:", ev.action);
          break;

      }
    }
  // Formateado de fechas para la Base de datos
  function toValidAPIDate(input) {
    const date = (input instanceof Date) ? input : new Date(input);
    return formatDateToAPI(date);
  }
  function formatDateToAPI(dateObj) {
    const year = dateObj.getFullYear();
    const month = String(dateObj.getMonth() + 1).padStart(2, "0");
    const day = String(dateObj.getDate()).padStart(2, "0");
    return `${year}-${month}-${day}`;
  }
  // Formar el Json de los campos que requiere el API
  function transformMoviePayload(values) {
  // Solo los campos que la API necesita
  const { name, price, startDate, rating, theme_id, support_id } = values;
  // Formatear fecha de "aaaa-mm-dd" → "aaaamm-dd"
  const formattedDate =toValidAPIDate(startDate);
  return {
    name, price, 
    startDate: formattedDate, // Formateamos la fecha
    rating, theme_id, support_id
  };
}
  // Manejo de la acción del Editor
  // Aquí se maneja tanto el "add" como el "edit"
  async function handleEditorAction({ item, values }) {
    // loading = true;
    // Solo los campos que la API necesita
    const payload = transformMoviePayload(values);
    console.log("Payload para API:", payload);
    try {
      if (item.id === "save") {
        if (editorMode === "add") {
          // 🔄 Envío a la API para añadir
          const res = await api.post("/movies",payload);
          values.id = res.data.id_movie ?? Date.now(); // En caso de que la API devuelva el ID
          // movies.push(values);
          addToast({
            message:'Película añadida correctamente' , 
            type: 'success', // ["success", "error", "info"]
            dismissible: true, 
            timeout: 2000
          });
          fetchAllData(); // Recargar datos después de la operación
        } else 
        if (editorMode === "edit") {
          await api.put(`/movies/${values.id}`, payload);
          // movies = movies.map(m => (m.id === values.id ? values : m));
          addToast({
            message:'Película actualizada correctamente' , 
            type: 'success', 
            dismissible: true, 
            timeout: 2000
          });
          fetchAllData(); // Recargar datos después de la operación
        }
        showEditor = false;
      } else 
      if (item.id === "close" || item.id === "cancel") {
        showEditor = false;
      }

    } catch (err) {
      console.error("Error en operación de editor:", err);
      addToast({ // Notificación
        message: "No se pudo completar la operación.", 
        type: 'error', // ["success", "error", "info"]
        dismissible: true, 
        timeout: 0
      })
    } finally {
      // fetchAllData(); // Recargar datos después de la operación
      // loading = false;
    }
  }
  // Para la Acción de Delete
  function confirmDelete(movie) {
    movieToDelete = movie;
    showDeleteConfirm = true;
  }
  async function proceedDelete() {
    // loading = true;
    try {
      // Llamada al backend para borrar la película
      await api.delete(`/movies/${movieToDelete.id}`);
      
      // Eliminar del frontend
      movies = movies.filter(m => m.id !== movieToDelete.id);

      // Mostrar notificación
      addToast({ // Notificación
        message:'Película eliminada correctamente' , 
        type: 'success', // ["success", "error", "info"]
        dismissible: true, 
        timeout: 2000
      });
    } catch (err) {
      console.error("Error al eliminar película:", err);
      addToast({ // Notificación
        message: "No se pudo eliminar la película.", 
        type: 'error', // ["success", "error", "info"]
        dismissible: true, 
        timeout: 0
      });
    } finally {
      showDeleteConfirm = false;
      movieToDelete = null;
      // loading = false;
    }
  }
  function cancelDelete() {
    showDeleteConfirm = false;
    movieToDelete = null;
  }
  // Para acceder al registro seleccionado del GRID
  function resolver(id) {
    if (id) gridRef.exec("select-row", { id });
    return id;
  }
  // --- Función para cargar todos los datos de la API ---
  async function fetchAllData() {
      // loading = true;

      try {
          const [resMovies, resThemes, resSupports] = await Promise.all([
              api.get('/movies'),
              api.get('/themes'),
              api.get('/supports')
          ]);
          // Mapear datos de películas para coincidir con tu estructura de frontend
          // Asumimos que theme_id y support_id vienen como números de la API,
          // y que los nombres de theme/support están en propiedades 'theme' y 'support' dentro del objeto movie
          movies = resMovies.data.map(m => {
            const dateObj = new Date(m.startDate); // Para adapatr al GRID
            return {
              id: m.id_movie,
              name: m.name,
              price: parseFloat(m.price),
              startDate: dateObj,                                   // 🔄 convierte string a Date
              startDateText: dateObj.toLocaleDateString("es-ES", {  // Para Utilizar en los filros del GRID
                day: "2-digit",
                month: "2-digit",
                year: "numeric"
              }),
              rating: m.rating,
              theme: m.theme,           // se usa para mostrar en el GRID
              support: m.support,       // idem
              theme_id: m.theme_id,     // se usa para editar en el formulario
              support_id: m.support_id
            };
          });

          // Para el Select
          themes= resThemes.data;
          console.log("Themes:", themes);
          /*
          themes = resThemes.data.map(t => ({ // Para cambiar losnombres de los campos
            id: t.id_theme,
            label: t.theme
          }));
          */
         supports = resSupports.data;

      } catch (err) {
          console.error('Error al cargar datos:', err);
          addToast({ // Notificación
            message: 'No se pudieron cargar los datos. Por favor, intente de nuevo más tarde.', 
            type: 'error', // ["success", "error", "info"]
            dismissible: true, 
            timeout: 0
          });
      } finally {
          loading = false;
      }
  }
    // Cargar datos al iniciar el componente
    onMount(fetchAllData);
  function clearFilters() {
    gridRef.exec("filter-rows", {});
  }
  function updateSelected() {
    selected = gridRef.getState().selectedRows;
    // console.log("Selected rows:", selected);
  }
  // Estilo de columna para alinear texto en el GRID
  const columnStyle = col => {
    if (col.id === "id") return "text-right";
    if (col.id === "price") return "text-right";
    if (col.id === "startDateText") return "text-center";
    return "";
  };
  // Definición de columnas del Grid
  const columns = $derived([
    { id: "id", header: "ID", width: 60, sort: true, resize: true },
    {
      id: "name",
      header: [
        "Nombre",
        {
          filter: {
            type: "text",
            config: { icon: "wxi-search", clear: true }
          }
        }
      ],
      flexgrow: 1,
      sort: true,
      resize: true
    },
    {
      id: "price",
      header: [
        "Precio",
        {
          filter: {
            type: "text",
            config: {
              icon: "wxi-search",
              clear: true,
              handler: (value, filter) => { // Filtro personalizado
                if (filter === "") return true;
                const num = parseFloat(filter);
                if (isNaN(num)) return false;
                return value >= num;
              }
            }
          }
        }
      ],
      type: "number",
      sort: true,
      width: 120,
      resize: true,
      template: value =>
        new Intl.NumberFormat("en-US", {
          style: "currency",
          currency: "EUR",
          minimumFractionDigits: 2,
          maximumFractionDigits: 2
        }).format(value)
    },
    {
      id: "startDateText",
      header: [
        "Fecha",
        {
          filter: {
            type: "text",
            config: { icon: "wxi-search", clear: true }
          }
        }
      ],
      width: 120,
      sort: true,
      resize: true,
      type: "text",
      /*
      template: value =>
        new Date(value).toLocaleDateString("es-ES", {
        day: "2-digit",
        month: "2-digit",
        year: "numeric"
        })
      */
    },
    {
      id: "rating",
      header: [
        "Rating",
        {
          filter: {
            type: "richselect",
            config: {
              options: [1, 2, 3, 4, 5].map(n => ({ id: n, label: `${n} estrellas` }))
            }
          }
        }
      ],
      width: 130,
      sort: true,
      resize: true,
      cell: StarCell
    },
    {
      id: "theme",
      type: "text",
      header: [
        "Tema",
        {
          filter: {
            type: "richselect",  // Type of filter (rich select dropdown)
            config: {
              options: themes.map(t => ({ id: t.theme, label: t.theme})),  // Array of options for the dropdown (e.g., country list)
              // Los COMBOS, obligatoriamente, son "id" y "label"
            },
          },
        },
      ],
      // options: themes,
      flexgrow: 1,
      sort: true,
      resize: true
    },
    {
      id: "support_id",
       type: "combo",
      header: [
        "Soporte",
        {
          filter: {
            type: "richselect",  // Type of filter (rich select dropdown)
            config: {
              options: supports.map(s => ({ id: s.id_support, label: s.support})),  // Array of options for the dropdown (e.g., country list)
              template: opt => `${opt.id}. ${opt.label}`,  // Custom template for displaying options
            },
          },
        },
      ],
      options: supports.map(s => ({ id: s.id_support, label: s.support})),
      flexgrow: 1,
      sort: true,
      resize: true
    }
  ]);
// $inspect(gridRef).with(console.trace); // Para inspeccionar variables reactivas, aparece siempre que cambie valor
</script>
<Toasts />
<Willow>
  <div class="d-flex justify-content-between align-items-center mb-3">
    <h2>🎬 Películas con Filtros y Ordenación</h2>
    <div class="d-flex gap-2">
      <Button type="primary" text="🧹 Borrar filtros" onclick={clearFilters} />
      <Button type="primary" text="➕ Agregar película" onclick={() => {
        editorMode = "add";
        editorValues = {};
        showEditor = true;
      }} />
    </div>
    <div>Para el acceso a las acciones CRUD, botón derecho en el Grid</div>
  </div>
  {#if loading}
          <p class="text-center">Cargando películas...</p>
      {:else}
    <ContextMenu
      options={contextOptions}
      onclick={handleContext}
      at="point"
      resolver={resolver}
      api={gridRef}
    >   
      <Locale words={{ ...gridEs, ...coreEs }}>
        <Grid
          bind:this={gridRef}
          data={movies}
          {columns}
          {columnStyle}
          pager={false}
          onselectrow={updateSelected}
        />
      </Locale>
    </ContextMenu>
    {/if}
    {#if showEditor}
    <div class="variations">
      <div>
        <!-- <h3>Cabecera del editor</h3> --> <!-- Sólo tiene sentido en {placement="inline"}  -->
        <div class="bg">        
          <Locale words={{ ...editorEs, ...coreEs }}>  
            <Editor
              header={true}
              placement="sidebar"
              layout="default"
              autoSave={false}
              readonly={editorMode === "view"}
              items={editorItems}
              values={editorValues}
              onaction={handleEditorAction}
            />
          </Locale>
        </div>
      </div>
    </div>
    {/if}
    <!-- Modal de confirmación Borrado -->
    {#if showDeleteConfirm}
      <ModalArea> 
        <div class="modal-content">
          <h3>¿Eliminar esta película?</h3>
          <p>Nombre: <b>{movieToDelete?.name}</b></p>
          <p>Fecha: {movieToDelete?.startDateText}</p>
          <div class="actions">
            <Button  type="danger" onclick={proceedDelete}>Confirmar</Button>
            <Button onclick={cancelDelete} color="secondary">Cancelar</Button>
          </div>
        </div>
      </ModalArea>
    {/if}
</Willow>
<style>
  .modal-content {
    padding: 30px;
    text-align: center;
    background-color: #f1f5f9;          /* gris claro elegante */
    border: 2px solid #f97316;          /* borde naranja suave */
    border-radius: 0px;                /* esquinas redondeadas */
    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);  /* sombra profunda */
    font-family: "Segoe UI", "Roboto", sans-serif;
    max-width: 400px;
    margin: 0 auto;
  }
  .modal-content h3 {
    font-size: 1.3rem;
    margin-bottom: 20px;
    color: #1e293b;                     /* texto oscuro elegante */
  }
  .modal-content p {
    margin: 10px 0;
    font-size: 1rem;
    color: #334155;
  }
  .actions {
    margin-top: 30px;
    display: flex;
    justify-content: center;
    gap: 20px;
  }
  :global(.wx-toast) {
    border-radius: 8px;
    box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
    font-weight: 500;
    font-family: "Segoe UI", sans-serif;
    padding: 10px 20px;
  }
  :global(.wx-cell.text-right) {
    text-align: right;
  }
  :global(.wx-cell.text-center) {
    text-align: center;
  }
  .variations {
    display: flex;
    flex-direction: row;
    flex-wrap: wrap;
  }
  .variations > div {
    margin: 0 20px 60px 20px;
    width: 400px;
  }
  .bg {
    border-top: 1px solid #ccc;
    padding: 10px;
    height: 100%;
    width: 300px;
  }
  :global(.wx-sidearea) { /* Cambio del tamaño de área de edición */
    width: 300px;
  }
  :global(.wx-willow-theme) {
    --wx-table-select-background: #eaedf5;
    --wx-table-select-color: var(--wx-color-font);
    --wx-table-border: 1px solid #e6e6e6;
    --wx-table-select-border: inset 3px 0 var(--wx-color-primary);
    --wx-table-header-border: var(--wx-table-border);
    --wx-table-header-cell-border: var(--wx-table-border);
    --wx-table-footer-cell-border: var(--wx-table-border);
    --wx-table-cell-border: var(--wx-table-border);
    --wx-header-font-weight: 600;
    --wx-table-header-background: #f2f3f7;
    --wx-table-fixed-column-border: 3px solid #e6e6e6;
    --wx-table-editor-dropdown-border: var(--wx-table-border);
    --wx-table-editor-dropdown-shadow: 0px 4px 20px rgba(44, 47, 60, 0.12);
    --wx-table-drag-over-background: var(--wx-background-alt);
    --wx-table-drag-zone-shadow: var(--wx-box-shadow);
  }
</style>

Os dejo los fuentes, tanto del Front-end, como del Back-end

S-005 – Componentes SVELTE 5 de SVAR – Excelente para todo tipo de desarrollo

Como habréis observado en el artículo S-003 os mostré las posibilidades de DataGrid y os hablé que estaba revisando las posibilidades de Gantt y eso es lo que he hecho. Al ver todas las posibilidades que ofrece el fabricante SVAR, mi sorpresa ha sido grande y en este artículo lo que vengo a mostrar en los ejemplos, son todas las posibilidades que ofrece de forma gratuita.

Como podréis comprobar casi hay soluciones para cualquier necesidad que podamos tener en el desarrollo de una aplicación de gestión.

Objetivo

Preparar los ejemplos que tiene el fabricante para en caso e desarrollos en Svelte 5 y facilitar los ejemplos para que se disponga de código para toda la casuística que se contempla en estos ejemplos.

Os facilito todos los ejemplos, que he codificado con «demo» y termina con el nombre del producto.

DEMOS:

Tipos de campos, etc.
Páginas de tipo LIST
Filtros estáticos y dinámicos
Gráficos de Gantt
Menús de acciones, de formularios, etc.
Menús de otro tipo de acciones
Gestor de carga de ficheros

(1) Menú donde vais a poder ver muchas opciones. Se pueden combinar todas ella para obtener la solución que requiere tu aplicación.

(2) Temas que se pueden utilizar directamente. Se pueden definir temas personalizados.

(3) Espacio en donde se muestra el resultado de la selección del punto (1)

Si te interesa este artículo, accede a él en este enlace.

S-004 – Alternativas para Data-Calendar

Disponer de una solución  de «data-Calendar», es decir, poder representar datos en un calendario es básico en casi todos los desarrollo, dado que mucha de nuestras informaciones están asociadas a una echa.

La solución «histórica» de Open Source es FullCalendar que es la que hemos utilizado en PHPRunner en mis ejemplos y en los Templates de XlineSoft.

Este producto, también, ha creado una «saga» de seguidores y de creadores de solución similar en diversas plataformas.

Objetivo

Disponer de solución para incorporar FullCalendar en plataforma Svelte 5 y disponer de una alternativa nativa de Svelte, que disponga de la funcionalidad de FullCalendar (svelte-calendar).

DEMOS:

Estos ejemplos simples los he hecho son la IA Gemini. Me ha costado mucho llegar a ellos, porque en esta sesión Gemini nada más que hacía deducir erróneamente los parámetros de configuración  por más información de la documentación que le facilitaba, no era capaz de interpretar adecuadamente los contenidos de los manuales.

Si te interesa este tema y deseas acceder a los fuentes, lee el artículo completo en este enlace.

S-003 – Excelente DataGrid para Svelte 5

Algo que en todas las aplicaciones se usa mucho, mucho es un DataGrid, Cuadrícula o Rejilla, que de muchas formas se llama dependiendo del país en donde nos hayamos educado.

Consultando las IA’s me comentaron de la solución de la empresa SVAR y como podréis ver tiene excelente solución para los DataGrid y Gantt, en el entorno Svelte.

Estuve intentando trabajar con Gemini, ChatGPT, Deep Seek y Copìlot, y en este caso os tengo que decir que fue un tremendo fracaso. Se inventan las cosas «Alucinaciones en el argot» y cansado de ellas me puse a analizar la información en metodología clásica y el resultado, a mi entender, me parece estupendo.

Objetivo

Disponer de una solución de DataGrid para aplicar a mis proyectos de Svelte. Además, de que tiene que ser sin coste (Free), debe disponer de muchísimas funcionalidad y disponer de buena documentación y ejemplos.

DEMO: https://fhumanes.com/demo-datagrid

Si estás interesado en este tema sigue este enlace para leer todo el artículo.

S-002 – Típico CRUD con Bootstrap 5+ y Server SLIM PHP

Creo que este ejemplo os va a mostrar lo fácil o difícil, que es trabajar en esta plataforma, de acuerdo a vuestros conocimientos.

Es un ejemplo típico de LIST, ADD, EDIT, VIEW y DELETE, de una tabla «Películas», con la, también, gestión de las talas auxiliares de «Temas» y «Soportes».

El desarrollo lo ha hecho GEMINI (la IA de Google), con mi revisión. El proceso que he seguido es facilitarle el modelo de datos, que he hecho con MySQL Workbench y le he especificado que de backend quería que utilizara SLIM 4.0 y MySQL. Y como Frontend quería Svelte 5 y Sveltestrap (que utiliza internamente Bootstrap 5.3. Hay que solicitar que establezca las FASES que vamos a utilizar para construir la solución y tener mucha paciencia, pues aunque el resultado final es bueno, son muchas las confusiones y pérdidas de información que tiene, con lo que hay que recordarle continuamente las soluciones que se van adoptando, porque tiende al olvidar muchos de los detalles.

Objetivo

Obtener una aplicación CRUD completa con la arquitectura de Svelte 5 + Bootstrap 5.3 + SLIM 4.0 + MySQL.

DEMO: https://fhumanes.com/my-movie-app1

Si te interesa este tema, haz clic en este enlace.