whitecube / nova-flexible-content

Flexible Content & Repeater Fields for Laravel Nova
MIT License
778 stars 230 forks source link

Dependent Fields not working #492

Open davinyoeun opened 7 months ago

davinyoeun commented 7 months ago

I have 2 fields below:

  1. I have one select field is called "Type"
  2. I have one select field is called "Unit" that depend on field "Type" Action:
  3. I select field type
  4. I select field unit suddenly it was hidden from the form
wize-wiz commented 6 months ago

I can confirm this is indeed a problem. The field depending on another field will just disappear.

wize-wiz commented 5 months ago

@davinyoeun As far as I can tell, the support for field dependency was added in times of Laravel 9 and Nova 3 where Nova 4 was just released. I've tested this feature with several early versions of Nova 4 and this feature just doesn't work.

The commit seems be mostly #347

Can anyone even confirm this worked with Laravel 10 / Nova 4? Or at all?

I seem to get an empty PATCH response, can anyone confirm?

wize-wiz commented 5 months ago

@toonvandenbos @voidgraphics I've been diving into this problem and in dire need to catch up some time I haven't spent with Laravel and Nova the last +/- 3 years.

To my understanding, Nova 4 uses an update-fields PATCH request to get the new state of a field. The middleware intercepting requests using requestHasParsableFlexibleInputs only acts on ['POST', 'PUT'] defined in ParsesFlexibleAttributes.

// InterceptFlexibleAttributes
public function handle(Request $request, Closure $next): Response
{
    if (! $this->requestHasParsableFlexibleInputs($request)) {
        return $next($request);
    }

    ...
}
// ParsesFlexibleAttributes
protected function requestHasParsableFlexibleInputs(Request $request)
{
    return in_array($request->method(), ['POST', 'PUT']) &&
            is_string($request->input(FlexibleAttribute::REGISTER));
}

Now is_string($request->input(FlexibleAttribute::REGISTER) checks if a specific attribute is present FlexibleAttribute::REGISTER which again contains ___nova_flexible_content_fields. But this attribute is never added to the PATCH request by nova. This attribute is added by FormField.vue when doing a fill.

So I'm just wondering, did this ever work? Did Nova 4 started splitting the requests using a PATCH request?

wize-wiz commented 5 months ago

My final take on this matter, more or less a proof of concept.

The support for field dependency native to Nova isn't supported by the middleware InterceptFlexibleAttributes. It doesn't take into account that a patch request is send to only update the state of the dependent fields, therefor the response of /nova-api/{resource}/{resourceId}/update-fields returns only one field because each dependent field sends out its own syncField request. In its current state it simply returns an empty json response because the field requesting for its current state is never found due to the nature of how this package groups fields (nested) in form of layouts.

The support for dependent fields can only be achieved if the native update-fields Nova offers is replaced with a patched one.

-- edit

I've moved the constant FLEXIBLE_FIELD_OFFSET from the trait to the class FlexibleAttribute because constants in traits is supported from php >= 8.2 and onwards.

The modification

The following three files from this package needs to be modified:

A new route file should be created in the project routes directory

A controller extending Novas UpdateFieldController controller:

A definition in App\Providers\RouteServiceProvider::boot registering the routes/nova-api.php routes file:

namespace App\Providers;

....
use Illuminate\Routing\Middleware\SubstituteBindings;

class RouteServiceProvider extends ServiceProvider
{

    public function boot()
    {
    ....

      $this->routes(function () {
        ....

        $this->novaApiRouteOverwrite();
      });

    ....
    }

    /**
     * Project based Nova API route file.
     * @return void
     */
    protected function novaApiRouteOverwrite() {
        Route::group([
            'domain' => config('nova.domain', null),
            'as' => 'nova.api.',
            'prefix' => 'nova-api',
            'middleware' => 'nova:api',
            'excluded_middleware' => [SubstituteBindings::class],
        ], function () {
            $this->loadRoutesFrom(base_path('routes/nova-api.php'));
        });
    }

The nova-api.php route file:

Route::patch('/{resource}/{resourceId}/update-fields', [PatchedUpdateFieldController::class, 'sync']);


#### The `PatchedUpdatedFieldController`
- app/Http/Controllers/Nova/PatchedUpdatedFieldController.php

<?php

namespace App\Http\Controllers\Nova;

use Laravel\Nova\Http\Requests\ResourceUpdateOrUpdateAttachedRequest; use Laravel\Nova\Http\Resources\UpdateViewResource; use Laravel\Nova\Http\Controllers\UpdateFieldController; use Whitecube\NovaFlexibleContent\Flexible;

class PatchedUpdateFieldController extends UpdateFieldController {

/**
 * @param ResourceUpdateOrUpdateAttachedRequest $request
 * @return \Illuminate\Http\JsonResponse
 * @throws \Illuminate\Auth\Access\AuthorizationException
 */
public function sync(ResourceUpdateOrUpdateAttachedRequest $request)
{
    $resource = UpdateViewResource::make()->newResourceWith($request);

    return response()->json($resource->updateFields($request)
        // we first need to extract all fields from a given group/layout
        ->map(function($field) use ($resource) {
            if($field instanceof Flexible) {
                $resolved = $field->jsonSerialize()['layouts']->map(function($layout) {
                    return $layout->fields();
                })->flatten();

                return $resolved;
            }
            return $field;
        })
        // we need to flatten the collection with all nested collections given previously
        ->flatten()
        // here is everything as usual
        ->filter(function ($field) use ($request) {
            return $request->query('field') === $field->attribute &&
                $request->query('component') === $field->dependentComponentKey();
        })->each->syncDependsOn($request)->first());

}

}


#### Modifications to the `InterceptFlexibleAttributes` middleware class, concerning only the `handle` method.
- file: src/Http/Middleware/
public function handle(Request $request, Closure $next): Response
{

    // has been adapted to also parse patch requests of `update-fields`
    if (! $this->requestHasParsableFlexibleInputs($request)) {
        return $next($request);
    }

    // we assume we can quite here because we don't need the rest after if statement
    if($request->method() === 'PATCH') {
        return $next($request);
    }

    $request->merge($this->getParsedFlexibleInputs($request));
    $request->request->remove(FlexibleAttribute::REGISTER);

    $response = $next($request);

    if (! $this->shouldTransformFlexibleErrors($response)) {
        return $response;
    }

    return $this->transformFlexibleErrors($response);
}

#### Modifications to the `FlexibleAttribute` class:

<?php

namespace Whitecube\NovaFlexibleContent\Http;

use Illuminate\Support\Arr;

class FlexibleAttribute { .... /**

Modifications to the ParsesFlexibleAttributes trait:

<?php

namespace Whitecube\NovaFlexibleContent\Http;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;

trait ParsesFlexibleAttributes
{

    /**
     * Offset of a generated flexible attribute where the group
     * separator (FlexibleAttribute::GROUP_SEPARATOR) starts
     * @note: supported from php >= 8.2
     */
    // const FLEXIBLE_FIELD_OFFSET = 15;

    /**
     * @param $attribute
     * @return void
     */
    protected function splitFlexPartsFromFieldName(string $attribute) {
        return array_combine(['key', 'field'], explode('__', $attribute));
    }

    /**
     * Modify the request for a PATCH update-fields request
     * @param $request
     * @return bool
     */
    protected function parseFlexableFieldForPatchRequest($request) : bool {
        $field = $request->input('field');
        // we firstly check if the group separator starts at char index 15 (16th char)
        if(!(strlen($field) >= FlexibleAttribute::FLEXIBLE_FIELD_OFFSET &&
             strpos($field, FlexibleAttribute::GROUP_SEPARATOR, FlexibleAttribute::FLEXIBLE_FIELD_OFFSET) !== false)) {
            return false;
        }
        // flexible keys converted to original and to be merged with the request
        $flex_fields = [];
        $parts = $this->splitFlexPartsFromFieldName($field);
        // here we overwrite the query parameter 'field'.
        $request->instance()->query->set('field', $parts['field']);
        foreach($request->all() as $field => $value) {
            // check if we a have a flexible generated field name
            if(Str::startsWith($field, $parts['key'])) {
                // remove flexible generate input
                $request->request->remove($field);
                // pretend it's an original field input
                $flex_fields[
                    str_replace($parts['key'] . FlexibleAttribute::GROUP_SEPARATOR, '', $field)
                ] = $value;
            }
        }
        $request->merge($flex_fields);

        return true;
    }

    /**
     * Check if given request should be handled by the middleware
     *
     * @param  \Illuminate\Http\Request  $request
     * @return bool
     */
    protected function requestHasParsableFlexibleInputs(Request $request)
    {
        if($request->method() === 'PATCH' && Str::contains($request->getRequestUri(), '/update-fields?')) {
            return $this->parseFlexableFieldForPatchRequest($request);
        }

        return in_array($request->method(), ['POST', 'PUT']) &&
               is_string($request->input(FlexibleAttribute::REGISTER));
    }

}

I'm not quite familiar with this package so some parts can certainly be done better. This part is quite ugly $resolved = $field->jsonSerialize()['layouts']->map but I didn't find a better solution where a flexible field could resolve its groups and return a collection of fields. Maybe I just missed it.

Anyhow, it works as it should and I'll update this issue along with any future progress.