inertiajs / inertia

Inertia.js lets you quickly build modern single-page React, Vue and Svelte apps using classic server-side routing and controllers.
https://inertiajs.com
MIT License
6.49k stars 435 forks source link

Axios handling modals - how inertia should do it? #100

Closed kufdaw closed 4 years ago

kufdaw commented 4 years ago

Hello,

I created a modal (vue + laravel inertia stack), just wanted to send a simple post request (axios.post instead of inertia.post) to create a new item on list.

So the way I think it could work is:

  1. create an event bus to pass data from a child to a parent
  2. send a request from the parent

So right now we might have problems with error handling - doing that in classic, non-inertial way I could just take errors from response and apply to form fields without doing any extra reloading etc. Right now I should render modal first if errors occur and then find certain object, put data back inside a modal and match error fields.

Another way might be just prepare API and put auth headers and then handle custom requests - but not sure if this makes any sense.

Is there another way to handle forms in modal, the most simple solution? Am I understanding the flow correctly right now?

rodrigopedra commented 4 years ago

I usually emit a submit event from the modal to the parent and receive a errors prop with the error objects from the parent, and do all the ajax handling on the parent.

reinink commented 4 years ago

Modals are definitely something I want to try and solve for 1.0. I don't really have a great answer yet. For now, I'd recommend just making classic xhr requests for modal form submissions, and catching the response manually.

liorocks commented 4 years ago

Hey @kufdaw !

I don't think that you need classic XHR to achieve what you need. The easiest way I could come up with is the following:

Let's assume, that you share errors and flash messages with inertia, as described here.

Inertia::share([
    'flash' => function () {
        return [
            'message' => Session::get('message'),
        ];
    },
    'errors' => function () {
        return Session::get('errors')
            ? Session::get('errors')->getBag('default')->getMessages()
            : (object) [];
    }
]);

Then, in your Vue component have the following data:

data() {
  return {
    modal_opened: false, // this is not required, if you use `alternative solution`
    form: {
      name: "",
      email: ""
    }
  };
}

Then the template would look like this:

<template>
  <div>

    <!-- other elements ... -->

    <!-- check if modal should be opened -->
    <div v-if="modal_opened && !$page.flash.message">

      <!-- send post request to url with form data -->
      <form @submit.prevent="$inertia.post('/users', form)">

        <!-- check if errors exist -->
        <div v-if="Object.keys($page.errors).length">
          you have errors:
        </div>

        <!-- add .has-error class, if name has any errors  -->
        <input type="text" :class="{ 'has-error': !!$page.errors.name }" v-model="form.name" />
        <input type="email" v-model="form.email" />
        <button type="submit">Submit</button>
      </form>
    </div>

    <!-- Open Modal -->
    <button @click.prevent="modal_opened = true">Open Modal</button>    
  </div>
</template>

And finally your Controller would have something like this:

public function index()
{
    return Inertia::render('Users/Index');
}

public function store()
{    
    request()->validate([
        'name' => 'required',
        'email' => 'required|email'
    ]);

    return back()->with([
        'message' => 'User created.'          
    ]);
}

Alternative Solution

You could also manage modal state (opened/closed) from the controller like this:

public function index()
{
    return Inertia::render('Users/Index', [
        // set the modal state (closed) on the page, where you want it to be opened
        'modal_opened' => session('modal_opened', false) 
    ]);
}

public function store()
{
    // keep the modal opened, until the form is successfully submitted
    session()->flash('modal_opened', true);

    request()->validate([
        'name' => 'required',
        'email' => 'required|email'
    ]);

    // when validation passes, this will close the modal
    return back()->with([
        'modal_opened' => false          
    ]);
}

And then in your Vue component, you would have the following changes:

<!-- $page.modal_opened is passed from controller -->
<div v-if="$page.modal_opened">
  <!-- form and errors will be same as in 1st solution -->
</div>

<!-- you now open the modal by directly modifying $page object -->
<button @click.prevent="$page.modal_opened = true">Open Modal</button>    

You can use either option listed above. It only depends on you, do you prefer to have cleaner code in Vue components or in Laravel controllers?

The best part is that, in most cases you will not need classic XHR for that. With Inertia you can work with modals, same as you would with other CRUD pages.

I hope this helps you. If you'll have any questions, let me know.

druc commented 4 years ago

I recently said I have the "cure" ford doing modals the inertia way, but sadly, I failed to come with a solution covering all the edge cases without touching the internals. After showing Jonathan what I came up with, he asked to add all my notes here - as he plans to tackle the modals issue soon. So here it is:


The initial solution, where we use a parameter to decide what to load on the backend, and then a prop to toggle the modal on the frontend is a great starting point. It follows Inertia's core principles, where the server controls the state of our application. However, it revealed some problems:

Mixed actions. Loading props based on what request parameters we pass in leads to a mix of create/edit/index actions on the controller method.

index() {
    if(request('someparam')) {// create stuff}
    if(request('someOtherParam')) {// edit stuff}

    return [//index stuff]
}

Ideally, each action should have it’s own route and controller method.

Smarter lazy loading. Say you’re on an index page, and you want to edit a record via a modal. You wouldn’t want to re-query all the records on that page, so you make a partial request asking for specific props required for that modal.

The problem is, sometimes, you may need to load more than a few props (depending on how complex your modal is), or you have the edit link appearing in a bunch of places - passing a long list of props for every link can be quite annoying.

Unhelpful referer . Making additional requests while inside a modal will mess up the referer. Say you have a select making partial requests to search for a given term. The last search request made will be considered the referer - so redirecting back when submitting the form would redirect you to the modal + search term. Ideally, we should be able to perform a redirect to the last fully rendered page (the page before opening the modal).

Solutions

Mixed actions and smarter lazy loading. Introducing a decorate() method that will … decorate another method’s response with additional props, while still setting a default redirect in case the modal was accessed directly (say you received a link to a modal and you open it for the first time).

Unhelpful referer. Well, we’ll keep our own referer 😄. The current URI will be passed with every Inertia::render() until we hit an Inertia::decorate() response that will use the previously set URI. This will allow us to redirect back to what we know to be the last fully rendered page.

Usage example

// Contacts/Index.vue
<contact-form-modal v-if="showContactModal" 
  :contact="contact" 
  :organizations="organizations">
</contact-form-modal>

<inertia-link :href="route('contacts.edit', {contact: contact.id})" replace>
  {{ contact.name }}
</inertia-link>

// ContactsController
public function index()
{
    return Inertia::render('Contacts/Index', [
        'filters' => Request::all(['search', 'trashed']),
        'contacts' => function () {...}
    ]);
}

public function edit(Contact $contact)
{
    return Inertia::decorate($this->index(), [
        'showContactModal' => true,
        'contact' => [
            'id' => $contact->id,
            ...
        ],
        'organizations' => Auth::user()->account->organizations()
            ->orderBy('name')
            ->filter(Request::only('search'))
            ->limit(5)
            ->get()
            ->map
            ->only('id', 'name')
    ], route('contacts'));
}

TLDR:

Final thoughts

While this is a working solution and I tested a ton of cases and edge cases, introducing something like decorate() and back() might be too much, too soon. In this case, having the server decide whether the response should be treated as a partial or not would be good enough for now. decorate() could be added as a macro of the ResponseFactory and we could just live without the back() functionality.

I'll go even further and suggest that everything state related should go through the server (only, replace, preserveScroll, heck, even preserveState) - this won't have any noticeable side-effects while it will empower developers to do all kinds of sorceries 🧙

That's all, folks :) Any suggestions/alternatives/enhancements/concerns are welcomed!

reinink commented 4 years ago

Thanks so much @druc, this is awesome, and will definitely help me as I work on the modal stuff. 👍

reinink commented 4 years ago

Closing this in favour of #249. 👍