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

Adjuntos

Archivo Tamaño de archivo Descargas
zip my-svar-app 31 KB 12
zip movie-server 4 MB 18