S-008 – Ejemplo de CRUD con Svelte 5 y componentes de SVAR -versión3 y Final-

Cuando estás empezando con unos nuevos componentes, tan amplios como los de SVAR , puedes estar aprendiendo nuevas cosas más de un mes, y modificando cualquier ejercicio que hayas iniciado en ese proceso de aprendizaje.

Este artículo es la última modificación que voy a presentaros y es la solución recomendada, para que la utilicéis de modelo. Esto no significa que las versiones anteriores no sirvan, que yo creo que para aprender , al ser más simples, son más sencillas para aprender cómo se utilizan los componentes de SVAR. Pero esta versión es la mejor si la tabla de datos que estás utilizando tiene algún aspecto de complejidad, además que explica nuevas funcionalidades de estos componentes.

Objetivo

Utilizar los componentes de SVAR para el ejemplo de la gestión de las Películas, pero mejorando las validaciones de las opciones ADD y EDIT, así como cambiar los botones al pie de la ventana de actualización de los datos.

Otro aspecto importante, para mejorar la lectura del código, es separar la gestión del GRID y la de FORMS de registros, para que sea muy sencillo hacer formularios todo lo complejos que se requieran.

DEMO: https://fhumanes.com/my-svar-app5/

Solución Técnica

Técnicamente hay pocas novedades, la más relevante es cómo se pasas parámetros (datos y funciones) entre distintos ficheros de Svelte. Es muy parecido añ método que se utiliza en React.

El emisor:

 <!-- Definición de Edición   -->
{#if showEditor}
  <SideArea>
    <MovieForm {editorMode} {editorValues} {movies} {themes} {supports} {handleSave} {closeEdit} />
  </SideArea>
{/if}

El receptor:

// El orden en $props() NO importa para recibir
let { editorMode, editorValues, movies, themes,supports , handleSave, closeEdit } = $props();

Realmente, es muy elegante, sencillo y claro. Se pasan variable, array de datos (JSON) y cmo en los dos últimos ítem, funciones.

Al separar así, el GRID y el FORMS, he podido darle una cabecera a la ventana y a mi me gusta, que los botones estén en el pie del formulario.

Este son los códigos del GRID y de Forms de la gestión de Películas.

MovieGrid.svelte
<script>
  import { onMount } from 'svelte';
  import { ModalArea } from "wx-svelte-core"; // Ventana de Confirmación
  import { Grid } from 'wx-svelte-grid';
  import { Button, Locale } from 'wx-svelte-core'; // Eliminamos Willow de aquí
  import { Editor } from 'wx-svelte-editor';
  import { ContextMenu } from 'wx-svelte-menu';
 
  import { Text, Select, DatePicker, RichSelect } from "wx-svelte-core";
  import { SideArea } 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 { default as filterEs } from "../locales/esFilter.js";
  // Filter external
  import { Tabs } from "wx-svelte-core";
  import { FilterBar, createFilter, getOptions, getFilters, createArrayFilter } from "wx-svelte-filter";
  import * as XLSX from "xlsx"
  //
  // Sistema de notificación de las acciones
  import Toasts from "../notification/Toasts.svelte"; // Se mantiene porque es para notificaciones específicas
  import { addToast } from "../notification/store";

  import StarCell from './StarCell.svelte';         // Imppresión de las estrellas
  import api from '../lib/api.js';                  // Importación de la API para las peticiones
  import { toValidAPIDate, createDateFromMySQL, formatDateToDDMMYYYY } from '../lib/utils.js'; // Para formatear fechas para MySQL

  import MovieForm from './MovieForm.svelte';      // Formulario de edición de películas

  // Definiciones y código para la gestión del DataGrid
  let gridRef = $state();
  let selected = $state([]);
  let movies = $state([]);
  let themes = $state([]);
  let supports = $state([]);

  let loading = $state(true);    // Estado de carga para mostrar un mensaje mientras se cargan los datos

  let showDeleteConfirm = $state(false);
  let movieToDelete = $state(null);

  let menuRef = $state();         // Referencia al menú contextual

  // Definición de las columnas del Grid ------------------------------------------------------------------------------------
  const columns = $derived([
    { id: "id", header: "ID", width: 60, sort: true, resize: true },
    {
      id: "name",
      header: [
        "Nombre"
      ],
      // flexgrow: 1,
      width: 250,
      sort: true,
      resize: true
    },
    {
      id: "price",
      header: [
        "Precio"
      ],
      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: "startDate",
      header: [
        "Fecha"
      ],
      width: 120,
      sort: true,
      resize: true,
      type: "date",
      template: (v) => (v ? formatDateToDDMMYYYY(v) : ""),
    },
    {
      id: "rating",
      header: [
        "Rating"
      ],
      width: 130,
      sort: true,
      resize: true,
      cell: StarCell
    },
    {
      id: "theme",
      type: "text",
      header: [
        "Tema"
      ],
      //flexgrow: 1,
      width:150,
      sort: true,
      resize: true
    },
    {
      id: "support_id",
       type: "combo",
      header: [
        "Soporte"      ],
      options: supports.map(s => ({ id: s.id_support, label: s.support})),
      // flexgrow: 1,
      width: 150,
      sort: true,
      resize: true
    }
  ]);

  // Función para resolver el ID de la película seleccionada
  function resolver(id) {
    if (id) gridRef.exec("select-row", { id });
    return id;
  }
  // Función para cargar todos los datos al inicio y reload de datos después de actualizaciones de Base de Datos
  async function fetchAllData() {
      try {
          const [resMovies, resThemes, resSupports] = await Promise.all([
              api.get('/movies'),
              api.get('/themes'),
              api.get('/supports')
          ]);
          movies = resMovies.data.map(m => {
            const dateObj = createDateFromMySQL(m.startDate);
            return {
              id: m.id_movie,
              name: m.name,
              price: parseFloat(m.price),
              startDate: dateObj,
              startDateText: dateObj.toLocaleDateString("es-ES", {
                day: "2-digit",
                month: "2-digit",
                year: "numeric"
              }),
              rating: m.rating,
              theme: m.theme,
              support: m.support,
              theme_id: m.theme_id,
              support_id: m.support_id
            };
          });
          // console.log("Películas cargadas:", movies);
          filterMovies(); // Aplicar filtro a los datos cargados
          themes = resThemes.data;
          supports = resSupports.data;

      } catch (err) {
          console.error('Error al cargar datos:', err);
          addToast({
            message: 'No se pudieron cargar los datos. Por favor, intente de nuevo más tarde.',
            type: 'error',
            dismissible: true,
            timeout: 0
          });
      } finally {
          loading = false;
      }
  }
  onMount(fetchAllData); // Llamar a la función al montar el componente

  // Función para limpiar los filtros aplicados en el Grid
  function clearFilters() {
    // gridRef.exec("filter-rows", {});
    filter = null;
    filterMovies()
    filterId = 1; // Para poder eliminar valores de estos filtros
    // Esto es útil si no tienes un id o una clase para el input
    const inputElement = document.querySelector('input[placeholder="Buscar en todos los campos"]'); // Buscar campo de entrada del filtro
    if (inputElement) {
        inputElement.value = ''; // Limpiar el valor del input
    }
  }
  // Función para actualizar la selección de filas en el Grid
  function updateSelected() {
    selected = gridRef.getState().selectedRows;
  }
  // Estilo de las columnas del Grid
  const columnStyle = col => {
    if (col.id === "id") return "text-right";
    if (col.id === "price") return "text-right";
    if (col.id === "startDate") return "text-center";
    return "";
  };

  // Menú conextual del DataGrid
    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" }
  ];

  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 };
            editorValues = {
              ...row,
              startDate: row.startDateText
            };
            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;
      }
    }


// Definiciones del Editor -------------------------------------------------------------------------------------------------------------
let showEditor = $state(false);
let editorValues = $state({});
let editorMode = $state("add")

// Función para transformar los valores del editor a un formato adecuado para la API
function transformMoviePayload(values) {
  const { name, price, startDate, rating, theme_id, support_id } = values;
  const formattedDate = toValidAPIDate(startDate);
  return {
    name, price,
    startDate: formattedDate,
    rating, theme_id, support_id
  };
}

async	function handleSave(ev) {
  const values = ev.values;
    // console.log("Guardando valores en SAVE:",values);
    const payload = transformMoviePayload(values);
    try {
        if (editorMode === "add") {
          const res = await api.post("/movies",payload);
          values.id = res.data.id_movie ?? Date.now();
          addToast({
            message:'Película añadida correctamente' ,
            type: 'success',
            dismissible: true,
            timeout: 2000
          });
          fetchAllData();
        } else if (editorMode === "edit") {
          await api.put(`/movies/${values.id}`, payload);
          addToast({
            message:'Película actualizada correctamente' ,
            type: 'success',
            dismissible: true,
            timeout: 2000
          });
          fetchAllData();
        }
        showEditor = false
    } catch (err) {
      console.error("Error en operación de editor:", err);
      addToast({
        message: "No se pudo completar la operación.",
        type: 'error',
        dismissible: true,
        timeout: 0
      })
      showEditor = false
    }
  }

function closeEdit() { // Función para cerrar el editor desde MovieForm
  showEditor = false;
}
// Definiciones para Delete -------------------------------------------------------------------------------------------------------------

// Función para confirmar la eliminación de una película
  function confirmDelete(movie) {
    movieToDelete = movie;
    showDeleteConfirm = true;
  }
// Función para proceder con la eliminación de una película
  async function proceedDelete() {
    try {
      await api.delete(`/movies/${movieToDelete.id}`);
      movies = movies.filter(m => m.id !== movieToDelete.id); // Eliminación local Original
      filteredMovies = filteredMovies.filter(m => m.id !== movieToDelete.id); // Eliminación local Filtrado
      addToast({
        message:'Película eliminada correctamente' ,
        type: 'success',
        dismissible: true,
        timeout: 2000
      });
    } catch (err) {
      console.error("Error al eliminar película:", err);
      addToast({
        message: "No se pudo eliminar la película.",
        type: 'error',
        dismissible: true,
        timeout: 0
      });
    } finally {
      showDeleteConfirm = false;
      movieToDelete = null;
    }
  }
// Función para cancelar la eliminación de una película
  function cancelDelete() {
    showDeleteConfirm = false;
    movieToDelete = null;
  }

  // Filter External -------------------------------------------------------------------------------------------------  
  let filterId = $state(1);
  const filterTabs = [
    { id: 1, label: "Todos los campos" },
    { id: 2, label: "Por Tema" },
    { id: 3, label: "Por un campo" },
  ];

  let fieldFilter = $derived([
            { id: "name", label: "Nombre", type: "text",  placeholder: "Buscar por Nombre" },
            { id: "price", type: "number" , placeholder: "Buscar por Precio, igual o mayor", filter: "greaterOrEqual" },
            { id: "startDate", type: "date" , placeholder: "Buscar por Fecha, igual o mayor", filter: "greaterOrEqual" },
            { id: "rating",  type: "text", placeholder: "Buscar por Valoración" },
            { id: "theme",  type: "text", options: getOptions(movies, "theme"), placeholder: "Buscar por Tema" },
            { id: "support",  type: "text", options: getOptions(movies, "support"), placeholder: "Buscar por Soporte" }
          ]);

 let filteredMovies = $state(null);
 let filter = $state(null);

  function handleFilterChange({ value }) {
    filterId = value;
    gridRef.exec("filter-rows", { filter: null });
  }
 // Filtra las películas según el filtro actual
 function filterMovies() {
  // console.log("Filtrando películas con el filtro:", filter);
  if (!filter) {
    filteredMovies = movies; // Si no existe filtro, mostramos todas las películas
    } else {
    filteredMovies  = filter(movies); // Aplicamos el filtro a las películas
    }
  // console.log("Películas filtradas:", filteredMovies);
 }
// Crea el filtro 
function applyFilter(value) { 
  // console.log("Valor del filtro aplicado:", value);
  filter = createArrayFilter(value);
  // console.log("Filtro creado:", filter)
  filterMovies()
}
  // End Filter External

// Exportar Películas Filtradas --------------------------------------------------------------------------------------------------
function exportToExcel() {
    const worksheet = XLSX.utils.json_to_sheet(filteredMovies);
    const workbook = XLSX.utils.book_new();
    XLSX.utils.book_append_sheet(workbook, worksheet, "Datos");
    XLSX.writeFile(workbook, "peliculas-filtradas.xlsx");
}
 // End Exportar Películas Filtradas

</script>

<div class="d-flex justify-content-between align-items-center mb-3">
  <h3>🎬 Películas con Filtros y Ordenación</h3>
   <!-- Definición de los filtros Externos -->
  <strong>Filtros:</strong> 
  <Tabs value={filterId} options={filterTabs} onchange={handleFilterChange} />
  <Locale words={{ ...filterEs, ...coreEs }}>
    {#if filterId === 1}
      <FilterBar
        fields={[
          {
            type: "all",
            by:  ["name", "price", "startDateText", "rating", "theme", "support"],
            placeholder: "Buscar en todos los campos",
          },
        ]}
        onchange={({ value }) => applyFilter(value)}
      />
    {:else if filterId === 2}
      <FilterBar
        fields={[
          {
            type: "text",
            id: "theme",
            /* label: "Tema",*/
            placeholder: "Buscar por tema",
            options:  getOptions(movies, "theme"),
          },
        ]}
        onchange={({ value }) => applyFilter(value)}
      />
    {:else if filterId === 3}
      <FilterBar
        fields={[
          {
            type: "dynamic",
            label: "Campo:",
            by: fieldFilter ,
          },
        ]}
        onchange={({ value }) => applyFilter(value)}
      />
    {/if}
  </Locale>
  <!-- Fin de la definición de los filtros Externos-->

  <!-- Definición del DataGrid -->
  <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;
    }} />

    <Button type="default" title="Cliqueame para exportar los datos a un Excel" text="📝 Export Excel" onclick={exportToExcel} />
  </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 }}>
      <div style="height: 360px; max-width: 1200px;">
      <Grid
        bind:this={gridRef}
        data={filteredMovies}
        {columns}
        {columnStyle}
        pager={false}
        onselectrow={updateSelected}
      />
      </div>
    </Locale>
  </ContextMenu>
{/if}
 <!--Fin Definición del DataGrid -->

 <!-- Definición de Edición   -->
{#if showEditor}
  <SideArea>
    <MovieForm {editorMode} {editorValues} {movies} {themes} {supports} {handleSave} {closeEdit} />
  </SideArea>

{/if}
<!-- Fin Definición de Edición -->
{#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}

<style>
  .modal-content {
    padding: 30px;
    text-align: center;
    background-color: #f1f5f9;
    border: 2px solid #f97316;
    border-radius: 0px;
    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
    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;
  }
  .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;
  }

  :global(.wx-sidearea) {
    width: 300px;
  }
</style>
MovieForms.svelte
<script>
  import { Button, Locale } from 'wx-svelte-core'; // Eliminamos Willow de aquí
  import { Editor } from 'wx-svelte-editor';
  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 editorEs } from "../locales/esEditor.js"

  // Sistema de notificación de las acciones
  import Toasts from "../notification/Toasts.svelte"; // Se mantiene porque es para notificaciones específicas
  import { addToast } from "../notification/store.js";

  import api from '../lib/api.js';                  // Importación de la API para las peticiones
  import { toValidAPIDate, createDateFromMySQL, formatDateToDDMMYYYY } from '../lib/utils.js'; // Para formatear fechas para MySQL
  
  // Parámetros del DataGrid
  // El orden en $props() NO importa para recibir
  let { editorMode, editorValues, movies, themes,supports , handleSave, closeEdit } = $props();

  // Definiciones del Editor -------------------------------------------------------------------------------------------------------------

  registerEditorItem("text", Text);
  registerEditorItem("number", Text);
  registerEditorItem("select", RichSelect);
  registerEditorItem("datepicker", DatePicker);
  
  let updated = false; // Para controlar si se ha actualizado el formulario

  const bottomBarView={ // Definición del bottomBar para modo view
          items: [
            {
              comp: "button",
              type: "primary",
              text: "Cerrar",
              id: "close",
            },
          ],
        };

    const bottomBarEdit={ // Definición del bottomBar para modo add/edit
          items: [
            {
              comp: "button",
              type: "primary",
              text: "Guardar",
              id: "save",
            },
            						{
              comp: "button",
              type: "normal",
              text: "Cancelar",
              id: "cancel",
            },
          ],
        };

  let editorItems = [
    { 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})?$/;
          return val && regEx.test(val);
        },
        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 => ({
            id: t.id_theme,
            label: t.theme
          })),
      required: true
    },
    {key: "support_id",
      label: "Soporte",
      comp: "select",
      options: supports.map(s => ({
            id: s.id_support,
            label: s.support
          })),
      required: true
    }
  ];

// Manejo de acciones del editor
  function onchange(ev) {
    //console.log(`field ${ev.key} was changed to ${ev.value}`);
    // console.log("all not saved changes", ev.update);
    updated = true; // Marcamos que se ha actualizado el formulario
  }

  function handleClick( ev) {
    // need to check that there are changes and close editor after successful validation
    // otherwise, even if "save" event is not triggered, editor will be closed anyway
    // but we still can close editor if there are not any changes
    // console.log ("handleClick ev: ", ev);

    const changes = ev.changes;
    const values = ev.values;
    const item = ev.item;

    if (item.id === "save" && changes.length === 0 && Object.keys(values).length == 0  && editorMode == "add") {

        addToast({
            message:'Se deben completar los datos del formlario o cliquear Cancelar' ,
            type: 'error',
            dismissible: true,
            timeout: 4000
          });
        console.warn("ADD - No hay cambios para guardar.");
    }

    if (item.id === "save" && updated === false && editorMode == "edit") {

        addToast({
            message:'Se deben modificar algún cambio o cliquear Cancelar' ,
            type: 'error',
            dismissible: true,
            timeout: 4000
          });
        console.warn("EDIT - No hay cambios para guardar.");
    }

    if (item.id === "cancel" || item.id === "close") closeEdit();
  }


</script>

<div class="d-flex justify-content-between align-items-center mb-3">
  <div class="formHeader">
    <h3>🎬 Gestión de Películas</h3>
  </div>
  
  <div class="variations">
    <div>
      <div class="bg">
        <Locale words={{ ...editorEs, ...coreEs }}>
          <Editor
            focus={true}
            placement="inline"
            bottomBar = {editorMode === "view"?bottomBarView:bottomBarEdit}
            layout="default"
            autoSave={false}
            readonly={editorMode === "view"}
            items={editorItems}
            values={editorValues}
            onaction={handleClick}
            onsave={handleSave}
            onchange={onchange}
          />
        </Locale>
      </div>
    </div>
  </div>
</div>

<style>
  .formHeader {
    text-align: center;
  }
  .variations .bg {
    background: var(--slate3);
    border-radius: 8px;
    padding: 8px;
    display: inline-block;
  }

  .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;
  }

</style>

Como siempre, os dejo los fuentes para que podáis instalar en vuestros equipos y hacer los cambios que consideréis. También pongo la versión del Server PHP, que gestiona el acceso a la base de datos y que explico en el artículo S-006.

Si tenéis dudas, por favor, hacédmelas llegar a través de mi email.

Adjuntos

Archivo Tamaño de archivo Descargas
zip movie-server 4 MB 44
zip my-svar-app5 42 KB 2

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