Saltar al contenido

En esta entrada vamos a aprender a actualizar de forma completa un proyecto Laravel Jetstream a la versión 2, la actual a día de hoy. Para ello vamos a partir del proyecto final del Curso de Laravel 8 con Jetstream e Inertia el cual se llevó a cabo con la versión 1.2 de Jetstream.

Lo primero que debemos hacer es descargar el proyecto final del curso mencionado más arriba y, partir de él, tienes todos los pasos enumerando a continuación, no hay pérdida.

## Actualizar composer.json con Jetstream 2.3.3
```json
"require": {
    "laravel/jetstream": "^v2.3.3",
},
```

## Actualizar dependencias composer
```bash
composer update
```

## Publicar vistas Jetstream
```bash
php artisan vendor:publish --tag=jetstream-views
```

## Actualizar JetstreamServiceProvider
#### Importante utilizar las vistas de autenticación Blade
```php
<?php

namespace App\Providers;


use App\Actions\Jetstream\RemoveTeamMember;
// use Illuminate\Support\ServiceProvider;
// use Laravel\Jetstream\Jetstream;

class JetstreamServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     *
     * @return void
     */
    public function register()
    {
        //
    }

    /**
     * Bootstrap any application services.
     *
     * @return void
     */
    public function boot()
    {
        // $this->configurePermissions();

        Jetstream::removeTeamMembersUsing(RemoveTeamMember::class);

        // Jetstream::deleteUsersUsing(DeleteUser::class);
        
        Fortify::loginView(function () {
            return view('auth/login', [
                'canResetPassword' => Route::has('password.request'),
                'status' => session('status'),
            ]);
        });
        
        Fortify::requestPasswordResetLinkView(function () {
            return view('auth/forgot-password', [
                'status' => session('status'),
            ]);
        });
        
        Fortify::resetPasswordView(function (Request $request) {
            return view('auth/reset-password', [
                'email' => $request->input('email'),
                'token' => $request->route('token'),
            ]);
        });
        
        Fortify::registerView(function () {
            return view('auth/register');
        });
        
        Fortify::verifyEmailView(function () {
            return view('auth/verify-email', [
                'status' => session('status'),
            ]);
        });
        
        Fortify::twoFactorChallengeView(function () {
            return view('auth/two-factor-challenge');
        });
        
        Fortify::confirmPasswordView(function () {
            return view('auth/confirm-password');
        });
    }
}
```

## Actualizar package.json con nueva versión de Laravel 8.5
```json
{
    "private": true,
    "scripts": {
        "dev": "npm run development",
        "development": "mix",
        "watch": "mix watch",
        "watch-poll": "mix watch -- --watch-options-poll=1000",
        "hot": "mix watch --hot",
        "prod": "npm run production",
        "production": "mix --production"
    },
    "devDependencies": {
        "@inertiajs/inertia": "^0.8.4",
        "@inertiajs/inertia-vue3": "^0.3.5",
        "@inertiajs/progress": "^0.2.4",
        "@tailwindcss/forms": "^0.2.1",
        "@tailwindcss/typography": "^0.3.0",
        "@vue/compiler-sfc": "^3.0.5",
        "axios": "^0.21",
        "laravel-mix": "^6.0.6",
        "lodash": "^4.17.19",
        "postcss": "^8.1.14",
        "postcss-import": "^12.0.1",
        "tailwindcss": "^2.0.1",
        "vue": "^3.0.5",
        "vue-loader": "^16.1.2"
    }
}
```

## Actualizar app.css
```css
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';

.btn-spinner,
.btn-spinner:after {
    border-radius: 50%;
    width: 1.5em;
    height: 1.5em;
}

.btn-spinner {
    font-size: 10px;
    position: relative;
    text-indent: -9999em;
    border-top: .2em solid white;
    border-right: .2em solid white;
    border-bottom: .2em solid white;
    border-left: .2em solid transparent;
    transform: translateZ(0);
    animation: spinning 1s infinite linear;
}

@keyframes spinning {
    0% { transform: rotate(0deg) }
    100% { transform: rotate(360deg) }
}
```

## Actualizar app.js para utilizar Inertia con Vue 3
```js
require('./bootstrap');

// Import modules...
import { createApp, h } from 'vue';
import { App as InertiaApp, plugin as InertiaPlugin } from '@inertiajs/inertia-vue3';
import { InertiaProgress } from '@inertiajs/progress';

const el = document.getElementById('app');

createApp({
    render: () =>
        h(InertiaApp, {
            initialPage: JSON.parse(el.dataset.page),
            resolveComponent: (name) => require(`./Pages/${name}`).default,
        }),
})
    .mixin({ methods: { route } })
    .use(InertiaPlugin)
    .mount(el);

InertiaProgress.init({ color: '#4B5563' });
```

## Crear webpack.config.js para añadir alias @ al directorio resources/js
```js
const path = require('path');

module.exports = {
    resolve: {
        alias: {
            '@': path.resolve('resources/js'),
        },
    },
};
```

## Actualizar webpack.mix.js con nueva versión de Mix
```js
const mix = require('laravel-mix');

/*
 |--------------------------------------------------------------------------
 | Mix Asset Management
 |--------------------------------------------------------------------------
 |
 | Mix provides a clean, fluent API for defining some Webpack build steps
 | for your Laravel applications. By default, we are compiling the CSS
 | file for the application as well as bundling up all the JS files.
 |
 */

mix.js('resources/js/app.js', 'public/js').vue()
    .postCss('resources/css/app.css', 'public/css', [
        require('postcss-import'),
        require('tailwindcss'),
    ])
    .webpackConfig(require('./webpack.config'));

if (mix.inProduction()) {
    mix.version();
}
```

## Actualizar tailwind.config.js a la nueva versión (ajústalo a tus necesidades)
```js
const defaultTheme = require('tailwindcss/defaultTheme');

module.exports = {
    purge: [
        './vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php',
        './vendor/laravel/jetstream/**/*.blade.php',
        './storage/framework/views/*.php',
        './resources/views/**/*.blade.php',
        './resources/js/**/*.vue',
    ],

    theme: {
        extend: {
            fontFamily: {
                sans: ['Nunito', ...defaultTheme.fontFamily.sans],
            },
        },
    },

    variants: {
        extend: {
            opacity: ['disabled'],
        },
    },

    plugins: [require('@tailwindcss/forms'), require('@tailwindcss/typography')],
};
```

## Actualizar componente Pagination.vue
#### Bug if/else, utilizamos link.url para poder compilar
```vue
:key="link.url"
```

## Migraciones, seeds y lanzar servidor
```bash
php artisan migrate:fresh --seed
php artisan serve
```

## Actualizar AppServiceProvider para compartir datos con Inertia
```php
<?php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use Inertia\Inertia;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     *
     * @return void
     */
    public function register()
    {
        //
    }

    /**
     * Bootstrap any application services.
     *
     * @return void
     */
    public function boot()
    {
        Inertia::share([
            "flash" => function () {
                return [
                    "success" => session("success")
                ];
            },
            "currentRouteName" =>  function () {
                return request()->route()->getName();
            },
            "errors" => function () {
                if (! request()->session()->has('errors')) {
                    return (object) [];
                }

                return (object) collect(request()->session()->get('errors')->getBags())->map(function ($bag) {
                    return (object) collect($bag->messages())->map(function ($errors) {
                        return $errors[0];
                    })->toArray();
                })->pipe(function ($bags) {
                    if ($bags->has('default') && request()->header('x-inertia-error-bag')) {
                        return [request()->header('x-inertia-error-bag') => $bags->get('default')];
                    } elseif ($bags->has('default')) {
                        return $bags->get('default');
                    } else {
                        return $bags->toArray();
                    }
                });
            }
        ]);

    }
}
```


## Modificar AppLayout.vue, ahora los datos están dentro de props
```vue
<template>
    <div class="min-h-screen bg-gray-100">
        <nav class="bg-white border-b border-gray-100">
            <!-- Primary Navigation Menu -->
            <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
                <div class="flex justify-between h-16">
                    <div class="flex">
                        <!-- Logo -->
                        <div class="flex-shrink-0 flex items-center">
                            <a href="/dashboard">
                                <jet-application-mark class="block h-9 w-auto" />
                            </a>
                        </div>

                        <!-- Navigation Links -->
                        <div class="hidden space-x-8 sm:-my-px sm:ml-10 sm:flex">
                            <jet-nav-link href="/dashboard" :active="$page.props.currentRouteName === 'dashboard'">
                                Dashboard
                            </jet-nav-link>
                            <jet-nav-link href="/projects" :active="$page.props.currentRouteName.includes('projects')">
                                Proyectos
                            </jet-nav-link>
                        </div>
                    </div>

                    <!-- Settings Dropdown -->
                    <div class="hidden sm:flex sm:items-center sm:ml-6">
                        <div class="ml-3 relative">
                            <jet-dropdown align="right" width="48">
                                <template #trigger>
                                    <button class="flex text-sm border-2 border-transparent rounded-full focus:outline-none focus:border-gray-300 transition duration-150 ease-in-out">
                                        <img class="h-8 w-8 rounded-full object-cover" :src="$page.props.user.profile_photo_url" :alt="$page.props.user.name" />
                                    </button>
                                </template>

                                <template #content>
                                    <!-- Account Management -->
                                    <div class="block px-4 py-2 text-xs text-gray-400">
                                        Manage Account
                                    </div>

                                    <jet-dropdown-link href="/user/profile">
                                        Profile
                                    </jet-dropdown-link>

                                    <jet-dropdown-link href="/user/api-tokens" v-if="$page.props.jetstream.hasApiFeatures">
                                        API Tokens
                                    </jet-dropdown-link>

                                    <div class="border-t border-gray-100"></div>

                                    <!-- Team Management -->
                                    <template v-if="$page.props.jetstream.hasTeamFeatures">
                                        <div class="block px-4 py-2 text-xs text-gray-400">
                                            Manage Team
                                        </div>

                                        <!-- Team Settings -->
                                        <jet-dropdown-link :href="'/teams/' + $page.props.user.current_team.id">
                                            Team Settings
                                        </jet-dropdown-link>

                                        <jet-dropdown-link href="/teams/create" v-if="$page.props.jetstream.canCreateTeams">
                                            Create New Team
                                        </jet-dropdown-link>

                                        <div class="border-t border-gray-100"></div>

                                        <!-- Team Switcher -->
                                        <div class="block px-4 py-2 text-xs text-gray-400">
                                            Switch Teams
                                        </div>

                                        <template v-for="team in $page.props.user.all_teams">
                                            <form @submit.prevent="switchToTeam(team)">
                                                <jet-dropdown-link as="button">
                                                    <div class="flex items-center">
                                                        <svg v-if="team.id == $page.props.user.current_team_id" class="mr-2 h-5 w-5 text-green-400" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" stroke="currentColor" viewBox="0 0 24 24"><path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
                                                        <div>{{ team.name }}</div>
                                                    </div>
                                                </jet-dropdown-link>
                                            </form>
                                        </template>

                                        <div class="border-t border-gray-100"></div>
                                    </template>

                                    <!-- Authentication -->
                                    <form @submit.prevent="logout">
                                        <jet-dropdown-link as="button">
                                            Logout
                                        </jet-dropdown-link>
                                    </form>
                                </template>
                            </jet-dropdown>
                        </div>
                    </div>

                    <!-- Hamburger -->
                    <div class="-mr-2 flex items-center sm:hidden">
                        <button @click="showingNavigationDropdown = ! showingNavigationDropdown" class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:bg-gray-100 focus:text-gray-500 transition duration-150 ease-in-out">
                            <svg class="h-6 w-6" stroke="currentColor" fill="none" viewBox="0 0 24 24">
                                <path :class="{'hidden': showingNavigationDropdown, 'inline-flex': ! showingNavigationDropdown }" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
                                <path :class="{'hidden': ! showingNavigationDropdown, 'inline-flex': showingNavigationDropdown }" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
                            </svg>
                        </button>
                    </div>
                </div>
            </div>

            <!-- Responsive Navigation Menu -->
            <div :class="{'block': showingNavigationDropdown, 'hidden': ! showingNavigationDropdown}" class="sm:hidden">
                <div class="pt-2 pb-3 space-y-1">
                    <jet-responsive-nav-link href="/dashboard" :active="$page.props.currentRouteName == 'dashboard'">
                        Dashboard
                    </jet-responsive-nav-link>
                    <jet-responsive-nav-link href="/projects" :active="$page.props.currentRouteName.includes('projects')">
                        Proyectos
                    </jet-responsive-nav-link>
                </div>

                <!-- Responsive Settings Options -->
                <div class="pt-4 pb-1 border-t border-gray-200">
                    <div class="flex items-center px-4">
                        <div class="flex-shrink-0">
                            <img class="h-10 w-10 rounded-full" :src="$page.props.user.profile_photo_url" :alt="$page.props.user.name" />
                        </div>

                        <div class="ml-3">
                            <div class="font-medium text-base text-gray-800">{{ $page.props.user.name }}</div>
                            <div class="font-medium text-sm text-gray-500">{{ $page.props.user.email }}</div>
                        </div>
                    </div>

                    <div class="mt-3 space-y-1">
                        <jet-responsive-nav-link href="/user/profile" :active="$page.props.currentRouteName == 'profile.show'">
                            Profile
                        </jet-responsive-nav-link>

                        <jet-responsive-nav-link href="/user/api-tokens" :active="$page.props.currentRouteName == 'api-tokens.index'" v-if="$page.props.jetstream.hasApiFeatures">
                            API Tokens
                        </jet-responsive-nav-link>

                        <!-- Authentication -->
                        <form method="POST" @submit.prevent="logout">
                            <jet-responsive-nav-link as="button">
                                Logout
                            </jet-responsive-nav-link>
                        </form>

                        <!-- Team Management -->
                        <template v-if="$page.props.jetstream.hasTeamFeatures">
                            <div class="border-t border-gray-200"></div>

                            <div class="block px-4 py-2 text-xs text-gray-400">
                                Manage Team
                            </div>

                            <!-- Team Settings -->
                            <jet-responsive-nav-link :href="'/teams/' + $page.props.user.current_team.id" :active="$page.props.currentRouteName == 'teams.show'">
                                Team Settings
                            </jet-responsive-nav-link>

                            <jet-responsive-nav-link href="/teams/create" :active="$page.props.currentRouteName == 'teams.create'">
                                Create New Team
                            </jet-responsive-nav-link>

                            <div class="border-t border-gray-200"></div>

                            <!-- Team Switcher -->
                            <div class="block px-4 py-2 text-xs text-gray-400">
                                Switch Teams
                            </div>

                            <template v-for="team in $page.props.user.all_teams">
                                <form @submit.prevent="switchToTeam(team)">
                                    <jet-responsive-nav-link as="button">
                                        <div class="flex items-center">
                                            <svg v-if="team.id == $page.props.user.current_team_id" class="mr-2 h-5 w-5 text-green-400" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" stroke="currentColor" viewBox="0 0 24 24"><path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
                                            <div>{{ team.name }}</div>
                                        </div>
                                    </jet-responsive-nav-link>
                                </form>
                            </template>
                        </template>
                    </div>
                </div>
            </div>
        </nav>

        <!-- Page Heading -->
        <header class="bg-white shadow">
            <div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
                <slot name="header"></slot>
            </div>
        </header>

        <!-- Page Content -->
        <main>
            <div class="py-4" v-if="$page.props.flash.success">
                <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
                    <div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded relative" role="alert">
                        <span class="block sm:inline">{{ $page.props.flash.success }}</span>
                    </div>
                </div>
            </div>
            <slot></slot>
        </main>
    </div>
</template>

<script>
import JetApplicationLogo from './../Jetstream/ApplicationLogo'
import JetApplicationMark from './../Jetstream/ApplicationMark'
import JetDropdown from './../Jetstream/Dropdown'
import JetDropdownLink from './../Jetstream/DropdownLink'
import JetNavLink from './../Jetstream/NavLink'
import JetResponsiveNavLink from './../Jetstream/ResponsiveNavLink'

export default {
    components: {
        JetApplicationLogo,
        JetApplicationMark,
        JetDropdown,
        JetDropdownLink,
        JetNavLink,
        JetResponsiveNavLink,
    },

    data() {
        return {
            showingNavigationDropdown: false,
        }
    },

    methods: {
        switchToTeam(team) {
            this.$inertia.put('/current-team', {
                'team_id': team.id
            }, {
                preserveState: false
            })
        },

        logout() {
            axios.post('/logout').then(response => {
                window.location = '/';
            })
        },
    },

    computed: {
        path() {
            return window.location.pathname
        }
    }
}
</script>
```

## Eliminar this.$once de Dropdown.vue y Modal.vue de Jetstream Vue, ya no existe en Vue 3

## Actualizar método index en ProjectController
```php
public function index() {
    if ( ! request("page")) {
        session()->put("search", request("search"));
        session()->put("trashed", request("trashed"));
    }

    return Inertia::render("Projects/Index", [
        "filters" => session()->only(["search", "trashed"]),
        "projects" => Project::with("user")
            ->orderByDesc("id")
            ->filter()
            ->paginate(5),
    ]);
}
```

## Actualizar Scope modelo Project
```php
public function scopeFilter(Builder $query) {
    $query->when(session("search"), function ($query, $search) {
        $query->where('name', 'LIKE', '%'.$search.'%');
    })->when(session("trashed"), function ($query, $trashed) {
        if ($trashed === 'with') {
            $query->withTrashed();
        } elseif ($trashed === 'only') {
            $query->onlyTrashed();
        }
    });
}
```

## Actualizar watch Projects/Index.vue, ahora utilizamos get en lugar de replace
```js
watch: {
    form: {
        handler: debounce(function() {
            let query = pickBy(this.form)
            this.$inertia.get(this.route('projects.index', query))
        }, 500),
        deep: true,
    }
},
```

## Actualizar TextInput.vue para que funcione con Vue 3
```vue
<template>
    <div class="w-full">
        <label
            :class="{ 'text-red-400': error }"
            class="block text-gray-700 text-sm font-bold mb-2"
            :for="id"
        >
            {{ label }}
        </label>
        <input
            :id="id"
            :class="{ 'border-red-400': error }"
            :value="value"
            class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
        />
        <div v-if="error" class="text-red-500">{{ error }}</div>
    </div>
</template>

<script>
import FormInput from "../Mixins/FormInput";
export default {
    name: "TextInput",
    mixins: [FormInput]
}
</script>
```

## Actualizar TextAreaInput.vue para que funcione con Vue 3
```vue
<template>
    <div class="w-full">
        <label
            :class="{ 'text-red-400': error }"
            class="block text-gray-700 text-sm font-bold mb-2"
            :for="id"
        >
            {{ label }}
        </label>
        <textarea
            :rows="rows"
            :id="id"
            :class="{ 'border-red-400': error }"
            :value="value"
            class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
        ></textarea>
        <div v-if="error" class="text-red-500">{{ error }}</div>
    </div>
</template>

<script>
import FormInput from "../Mixins/FormInput";
export default {
    name: "TextAreaInput",
    mixins: [FormInput],
    props: {
        rows: Number,
    }
}
</script>
```

## Actualizar ProjectForm.vue para que funcione con Vue 3
```vue
<template>
    <form
        @submit.prevent="$emit('submit')"
        class="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4"
    >
        <div class="mb-4">
            <text-input
                label="Nombre del proyecto"
                :value="name"
                @input="$emit('update:name', $event.target.value)"
                :error="errors.name"
            />
        </div>

        <div class="mb-6">
            <text-area-input
                label="Extracto"
                :value="excerpt"
                @input="$emit('update:excerpt', $event.target.value)"
                :rows="3"
                :error="errors.excerpt"
            />
        </div>

        <div class="mb-6">
            <text-area-input
                label="Contenido"
                :value="content"
                @input="$emit('update:content', $event.target.value)"
                :rows="6"
                :error="errors.content"
            />
        </div>
        <div class="px-8 py-4 border-t border-gray-200 flex justify-center items-center">
            <slot name="buttons" />
        </div>
    </form>
</template>

<script>
import TextInput from "./TextInput";
import TextAreaInput from "./TextAreaInput";
export default {
    name: "ProjectForm",
    components: {
        TextInput, TextAreaInput
    },
    props: {
        errors: Object,
        name: {
            type: String,
        },
        excerpt: {
            type: String,
        },
        content: {
            type: String,
        },
    }
}
</script>
```

## Actualizar Projects/Create.vue para que funcione con Vue 3
```vue
<template>
    <app-layout>
        <template #header>
            <h2 class="font-semibold text-xl text-gray-800 leading-tight">
                Crea un nuevo proyecto con Inertia
            </h2>
        </template>

        <div class="py-12">
            <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
                <project-form
                    :errors="errors"
                    v-model:name="form.name"
                    v-model:excerpt="form.excerpt"
                    v-model:content="form.content"
                    @submit="submit"
                >
                    <template #buttons>
                        <loading-button
                            :loading="processing"
                            class="btn-indigo"
                            type="submit"
                        >
                            Crear proyecto
                        </loading-button>
                    </template>
                </project-form>
            </div>
        </div>
    </app-layout>
</template>
```

## Actualizar Projects/Edit.vue para que funcione con Vue 3
```vue
<template>
    <app-layout>
        <template #header>
            <h2 class="font-semibold text-xl text-gray-800 leading-tight">
                Edita el proyecto {{ project.name }} con Inertia
            </h2>
        </template>

        <div class="py-12">

            <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
                <project-form
                    :errors="errors"
                    v-model:name="form.name"
                    v-model:excerpt="form.excerpt"
                    v-model:content="form.content"
                    @submit="submit"
                >
                    <template #buttons>
                        <button
                            class="text-red-600 hover:underline"
                            type="button"
                            @click="showModal = true"
                        >
                            Eliminar proyecto
                        </button>
                        <loading-button
                            :loading="processing"
                            class="ml-5 btn-indigo"
                            type="submit"
                        >
                            Editar proyecto
                        </loading-button>
                    </template>
                </project-form>
            </div>
        </div>

        <jet-confirmation-modal :show="showModal" @close="showModal = false">
            <template #title>
                Eliminar proyecto
            </template>

            <template #content>
                ¿Estás seguro que quieres eliminar el proyecto?
            </template>

            <template #footer>
                <jet-secondary-button @click.native="showModal = false">
                    Cancelar
                </jet-secondary-button>

                <jet-danger-button @click.native="destroy" class="ml-2" :class="{ 'opacity-25': processing }" :disabled="processing">
                    Eliminar proyecto
                </jet-danger-button>
            </template>
        </jet-confirmation-modal>
    </app-layout>
</template>
```

## Compilar, todo debe funcionar
```bash
yarn watch
```
school Curso completo

Curso Laravel 12
Completo 2026

El único curso 100% actualizado que incluye Laravel 12, Livewire 3, Vue 3, React 19 e Inertia 2. Aprende con proyectos reales y las últimas funcionalidades.

access_time 8 horas de contenido
layers 4 tecnologías en 1
update 100% actualizado
code Proyectos prácticos
Ver Curso Laravel 12 arrow_forward

star Incluido en cualquier suscripción

Rutas de aprendizaje