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