Dialogos en Laravel 13 Inertia 3 + Vue 3

- Andrés Cruz - EN In english

Video thumbnail

Los diálogos de confirmación es simplemente un modal o ventana emergente en la cual se ejecuta para mostrar antes de realizar alguna acción como la de eliminar y en la cual podemos colocar otros datos mediante por ejemplo, enlaces en Laravel Inertia.

En este apartado, quiero mostrarte la forma oficial de utilizar los diálogos o modales de confirmación. Aunque podríamos usar el confirm() nativo de JavaScript, no es lo ideal estéticamente. En el video anterior utilizamos un paquete externo, pero ahora quiero enseñarte cómo integrarlo directamente en la interfaz.

Comparativa: Inertia.js vs. Livewire (Flux)

Uno de los puntos que menos me gusta de Inertia es que, a veces, genera mucho "ruido" con tantos componentes. Creo que deberían trabajar más en la reutilización, ya que un diálogo de confirmación es un elemento esencial en cualquier sistema.

Si lo comparamos con Livewire (específicamente usando Flux), la diferencia es clara:

  • En Livewire/Flux, la implementación es directa y genérica. Declaras el componente y lo accionas con un simple clic.
    • <flux:modal.trigger name="edit-profile">
          <flux:button>Edit profile</flux:button>
      </flux:modal.trigger>
      <flux:modal name="edit-profile" class="md:w-96">
          <div class="space-y-6">
              <div>
                  <flux:heading size="lg">Update profile</flux:heading>
                  <flux:text class="mt-2">Make changes to your personal details.</flux:text>
              </div>
  • En Inertia, no tenemos uno genérico por defecto; tenemos que construirlo nosotros mismos integrando componentes de terceros o de la propia interfaz (como los de Headless UI o Radix).

Es un proceso algo tortuoso tener que hacer esto manualmente cada vez. Por eso, vamos a crear un componente genérico que podamos adaptar fácilmente, similar a cómo funciona en otras tecnologías.

Estructura del Componente de Diálogo

Aquí te explico cómo funciona nuestro componente reutilizable:

Definición de Props y Eventos

El componente recibe datos clave mediante props para ser flexible:

  • Title: El título del modal.
  • Operation: La acción que se va a realizar.
  • Confirmation Name: El texto o nombre del ítem que queremos validar.
  • Item Name: El label del recurso (por ejemplo, "Post" o "Categoría").

resources\js\components\ConfirmDeleteModal.vue

<script setup lang="ts">
import { Form } from '@inertiajs/vue3';
import { ref } from 'vue';
import { Button } from '@/components/ui/button';
import {
    Dialog,
    DialogClose,
    DialogContent,
    DialogDescription,
    DialogFooter,
    DialogHeader,
    DialogTitle,
} from '@/components/ui/dialog';

type Props = {
    open: boolean;
    title: string;
    itemName: string;
    itemLabel: string;
    deleteRoute: object;
};

const props = defineProps<Props>();
const emit = defineEmits<{
    'update:open': [value: boolean];
}>();

const confirmationName = ref('');
const formKey = ref(0);

const handleOpenChange = (nextOpen: boolean) => {
    emit('update:open', nextOpen);

    if (!nextOpen) {
        confirmationName.value = '';
        formKey.value++;
    }
};
</script>

<template>
    <Dialog :open="props.open" @update:open="handleOpenChange">
        <DialogContent>
            <Form
                :key="formKey"
                v-bind="deleteRoute"
                class="space-y-6"
                v-slot="{ processing }"
                @success="handleOpenChange(false)"
            >
                <DialogHeader>
                    <DialogTitle>Are you sure?</DialogTitle>
                    <DialogDescription>
                        This action cannot be undone. This will permanently
                        delete the {{ itemLabel }}
                        <strong>"{{ itemName }}"</strong>.
                    </DialogDescription>
                </DialogHeader>

                <DialogFooter class="gap-2">
                    <DialogClose as-child>
                        <Button variant="secondary"> Cancel </Button>
                    </DialogClose>

                    <Button
                        data-test="delete-confirm"
                        variant="destructive"
                        type="submit"
                        :disabled="processing"
                    >Delete {{ itemLabel }}
                    </Button>
                </DialogFooter>
            </Form>
        </DialogContent>
    </Dialog>
</template>

Lógica de Reactividad (Composition API)

Utilizamos la lógica de Vue 3 para controlar si el diálogo está abierto o cerrado mediante un evento update:open. Cuando el usuario hace clic en aceptar, el componente hijo emite un evento al padre para que este ejecute la petición de eliminación (usualmente un router.delete de Inertia).

Opcional: Confirmar mediante texto

En este otro ejemplo, puedes ver una validación de seguridad extra: el botón de eliminar se mantiene deshabilitado hasta que el usuario escriba correctamente el nombre del recurso (título). Esto evita eliminaciones accidentales.

resources\js\components\ConfirmDeleteTextConfirmModal.vue

<script setup lang="ts">
import { Form } from '@inertiajs/vue3';
import { computed, ref } from 'vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import {
    Dialog,
    DialogClose,
    DialogContent,
    DialogDescription,
    DialogFooter,
    DialogHeader,
    DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';

type Props = {
    open: boolean;
    title: string;
    itemName: string;
    itemLabel: string;
    deleteRoute: object;
};

const props = defineProps<Props>();
const emit = defineEmits<{
    'update:open': [value: boolean];
}>();

const confirmationName = ref('');
const formKey = ref(0);

const canDelete = computed(() => {
    return (
        confirmationName.value.toLowerCase() === props.itemName.toLowerCase()
    );
});

const handleOpenChange = (nextOpen: boolean) => {
    emit('update:open', nextOpen);

    if (!nextOpen) {
        confirmationName.value = '';
        formKey.value++;
    }
};
</script>

<template>
    <Dialog :open="props.open" @update:open="handleOpenChange">
        <DialogContent>
            <Form
                :key="formKey"
                v-bind="deleteRoute"
                class="space-y-6"
                v-slot="{ errors, processing }"
                @success="handleOpenChange(false)"
            >
                <DialogHeader>
                    <DialogTitle>Are you sure?</DialogTitle>
                    <DialogDescription>
                        This action cannot be undone. This will permanently
                        delete the {{ itemLabel }}
                        <strong>"{{ itemName }}"</strong>.
                    </DialogDescription>
                </DialogHeader>

                <div class="space-y-4 py-4">
                    <div class="grid gap-2">
                        <Label for="confirmation-name">
                            Type
                            <strong>"{{ itemName }}"</strong> to confirm
                        </Label>
                        <Input
                            id="confirmation-name"
                            name="confirmation"
                            data-test="delete-confirmation-name"
                            v-model="confirmationName"
                            placeholder="Enter name"
                            autocomplete="off"
                        />
                        <InputError :message="errors.confirmation" />
                    </div>
                </div>

                <DialogFooter class="gap-2">
                    <DialogClose as-child>
                        <Button variant="secondary"> Cancel </Button>
                    </DialogClose>

                    <Button
                        data-test="delete-confirm"
                        variant="destructive"
                        type="submit"
                        :disabled="!canDelete || processing"
                    >
                        Delete {{ itemLabel }}
                    </Button>
                </DialogFooter>
            </Form>
        </DialogContent>
    </Dialog>
</template>

Utilizar

Integramos en el listado de post:

resources\js\pages\dashboard\post\Index.vue

import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
***
const openDeleteModal = (post: { id: number; title: string }) => {
    postToDelete.value = post;
    deleteModalOpen.value = true;
};
***
<DropdownMenuItem
    class="cursor-pointer text-destructive focus:text-destructive"
    @click="
        openDeleteModal({
            id: row.id,
            title: row.title,
        })
    "
>
    <Trash2 class="mr-2 h-4 w-4" />
    Delete
</DropdownMenuItem>
***
<ConfirmDeleteModal
    v-if="postToDelete"
    :open="deleteModalOpen"
    :title="postToDelete.title"
    :item-name="postToDelete.title"
    item-label="post"
    :delete-route="destroy.form(postToDelete.id)"
    @update:open="deleteModalOpen = $event"
/>

Diálogos de confirmación y mensajes toast

Inertia ofrece varios componentes interesantes, pero, como podrás darte cuenta, se echan de menos algunos otros como para manejar diálogos de confirmación y mensajes toast; sin embargo, el hecho que no exista es algo bueno, ya que, Inertia al usar Vue, ya contamos con una gran cantidad de plugins listos para usar como hemos demostrado en los anteriores capítulos y con esto, podemos personalizar nuestra aplicación y prescindir de lo más posible de integraciones que pudieran venir por defecto en Inertia.js

Para los diálogos y toasts, puedes usar cualquier plugin, pero, al tener instalado Oruga UI; vamos a aprovechar los componentes que ya existen en la misma y con esto, evitar instalar paquetes adicionales.

Configurar el diálogo de confirmación

Como vimos en el libro básico de Laravel, en Oruga UI, contamos con un componente de modal:

https://oruga.io/components/modal.html

Que luce como:

Diálogo en Oruga

Que en definitiva no es más que un contenedor vacío en el cual, podemos colocar textos, imágenes, formularios y, en definitiva, cualquier contenido HTML.

Diálogo de confirmación para eliminar

Comencemos adaptando el diálogo de confirmación para la eliminación de los posts; para ello, vamos a colocar el texto de confirmación y los botones de acción; uno para eliminar y otro para cancelar:

resources/js/pages/dashboard/category/Index.vue

<template>
    <Head title="Categories" />

    <o-modal v-model:active="confirmDeleteActive">
    <p class="p-4 text-black">Are you sure you want to delete the selected record?</p>
    <div class="flex flex-row-reverse gap-2 bg-gray-100 p-3">
      <o-button variant="danger" @click="deleteCategory">Delete</o-button>
      <o-button @click="confirmDeleteActive = false">Cancel</o-button>
    </div>
  </o-modal>

***
<DropdownMenuSeparator />
    <DropdownMenuItem as-child>
    <Button
        variant="destructive"
        @click="confirmDeleteActive = true; deleteCategoryRow = category.id"
        class="w-full"
    >
        <Trash2 class="mr-2 h-4 w-4" />
        Delete
    </Button>
</DropdownMenuItem>
<!-- <Form
    v-bind="destroy.form(category.id)"
    v-slot="{ processing }"
>
    <DropdownMenuItem
        class="cursor-pointer text-destructive focus:text-destructive"
        :disabled="processing"
        as="button"
        type="submit"
    >
        <Trash2 class="mr-2 h-4 w-4" />
        Delete
    </DropdownMenuItem>
</Form> -->

<script>
import { ref } from 'vue';
***
const confirmDeleteActive = ref(false);
const deleteCategoryRow = ref<number | string>(""); // Guardamos el ID aquí

const deleteCategory = () => {
    // 1. Usamos .value para obtener el ID guardado
    // 2. Ejecutamos la acción de borrado
    router.delete(destroy(deleteCategoryRow.value).url, {
        preserveScroll: true,
        onSuccess: () => {
            confirmDeleteActive.value = false;
            deleteCategoryRow.value = "";
        }
    });
};
</script>

Recuerda que todos estos cambios se aplican sobre:

  • resources/js/pages/dashboard/category/Index.vue

Para mostrar el modal, usamos una propiedad booleana y para manejar el registro que queremos eliminar, usamos otra propiedad que contiene el id del registro que queremos eliminar; así que, creamos las propiedades respectivas:

const confirmDeleteActive = ref(false);
const deleteCategoryRow = ref<number | string>(""); // Guardamos el ID aquí

Desde el apartado de acciones en el listado, ahora, la operación para eliminar, lo único que va a realizar es, registrar el ID del post que se quiere eliminar:

Creamos una función que elimine el post:

const deleteCategory = () => {
    // 1. Usamos .value para obtener el ID guardado
    // 2. Ejecutamos la acción de borrado
    router.delete(destroy(deleteCategoryRow.value).url, {
        preserveScroll: true,
        onSuccess: () => {
            confirmDeleteActive.value = false;
            deleteCategoryRow.value = "";
        }
    });
};

Y finalmente, volviendo a los botones de acción, el de cancelar, lo único que hace es ocultar el modal con la propiedad que se encarga de indicar si el modal es visible o no:

<o-button @click="confirmDeleteActive = false">Cancel</o-button>

Y el de conformación, llama a la función definida anteriormente:

<o-button variant="danger" @click="deleteCategory">Delete</o-button>

Y desde el listado:

<DropdownMenuSeparator />
    <DropdownMenuItem as-child>
    <Button
        variant="destructive"
        @click="confirmDeleteActive = true; deleteCategoryRow = category.id"
        class="w-full"
    >
        <Trash2 class="mr-2 h-4 w-4" />
        Delete
    </Button>
</DropdownMenuItem>           

Con esto, al presionar sobre eliminar en el listado, tendremos:

Diálogo de eliminar de Oruga

El siguiente paso es que aprendas a usar los Mensajes Flash en Laravel Inertia.

Implementamos un dialogo de confirmación nativo en Laravel Inertia y también, empleando Oruga UI con Vue.


Únete a la comunidad de desarrolladores que han decidido dejar de picar código y empezar a construir productos reales. Recibe mis mejores trucos de arquitectura cada semana:

Acepto recibir anuncios de interes sobre este Blog.