whitecube / nova-flexible-content

Flexible Content & Repeater Fields for Laravel Nova
MIT License
790 stars 234 forks source link

Nested layouts with Custom Resolver and saving to the database #309

Open SanjinOsmanbasic opened 3 years ago

SanjinOsmanbasic commented 3 years ago

This is going to be a bit lengthy, so bear with me. But I also think that this could be later used as a concrete example of how to use Custom Resolver to save OneToMany relationships in the database - instead of JSON. And there is also a nested Flexible fields component that adds additional complexity that I didn't see covered in other issues

Ok, I'll try to explain:

  1. What I'm trying to do
  2. What did I do so far
  3. Where I'm stuck

Let's start with what I'm trying to do: I'm creating a Quiz admin panel where users can create Quiz, corresponding questions and answers to those questions. Flexible Nova content fields seemed like a perfect solution that i was having - Laravel nova relationships have too many clicks all around - so I wanted to use the Flexible fields to display these relationships in Quiz editing screen so user can enter everything right away - and the results will be saved in corresponding Questions and Answers tables which have there own models, etc.

  1. What I did so far: ( I will cut examples a bit short to reduce clutter, but will leave the most important functions)

App/Nova/Quiz.php

<?php

namespace App\Nova;

use App\Nova\Flexible\Layouts\QuestionsLayout;
use App\Nova\Flexible\Resolvers\QuestionResolver;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Laravel\Nova\Fields\BelongsTo;
use Laravel\Nova\Fields\DateTime;
use Laravel\Nova\Fields\HasMany;
use Laravel\Nova\Fields\Hidden;
use Laravel\Nova\Fields\ID;
use Laravel\Nova\Fields\Text;
use Laravel\Nova\Http\Requests\NovaRequest;
use Spatie\TagsField\Tags;
use Whitecube\NovaFlexibleContent\Flexible;
use Yassi\NestedForm\NestedForm;

class Quiz extends Resource
{
    /**
     * The model the resource corresponds to.
     *
     * @var string
     */
    public static $model = \App\Models\Quiz::class;

    /**
     * The single value that should be used to represent the resource when being displayed.
     *
     * @var string
     */
    public static $title = 'title';

    /**
     * The columns that should be searched.
     *
     * @var array
     */
    public static $search = [
        'id',
        'title'
    ];

    /**
     * Get the fields displayed by the resource.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return array
     */
    public function fields(Request $request)
    {
        return [
            ID::make(__('ID'), 'id')->sortable(),
            Text::make('Title')->rules('required','string','max:200'),
            BelongsTo::make('Category'),
            Tags::make('Tags')->withLinkToTagResource(),
            DateTime::make('Published at')->withMeta(['value' => $date->published_at ?? Carbon::now()->toDatetimeString()])->hideFromIndex(),
            DateTime::make('Ended at'),
            Flexible::make('Content')
                ->addLayout(QuestionsLayout::class)
                ->resolver(QuestionResolver::class)
                ->button('Add question')
                ->fullWidth(),

        ];
    }
}

App/Nova/Flexible/Layouts/QuestionsLayout.php

<?php

namespace App\Nova\Flexible\Layouts;

use App\Nova\Flexible\Resolvers\AnswerResolver;
use Laravel\Nova\Fields\Hidden;
use Laravel\Nova\Fields\Text;
use Whitecube\NovaFlexibleContent\Flexible;
use Whitecube\NovaFlexibleContent\Layouts\Layout;
use Whitecube\NovaFlexibleContent\Value\FlexibleCast;

class QuestionsLayout extends Layout
{
    /**
     * The layout's unique identifier
     *
     * @var string
     */
    protected $name = 'questions-layout';

    /**
     * The displayed title
     *
     * @var string
     */
    protected $title = 'Questions Layout';

    protected $casts = [
        'answers_layout' => AnswerLayout::class
    ];

    /**
     * Get the fields displayed by the layout.
     *
     * @return array
     */
    public function fields()
    {
        return [
            Text::make('Text')->rules('required', 'max:200'),
            Hidden::make('Quiz ID', 'quiz_id'),
            Hidden::make('ID', 'id'),
            Flexible::make('Answers Layout')
                ->addLayout(AnswerLayout::class)
                ->resolver(AnswerResolver::class)
                ->button('Add answer')
                ->limit(4)
        ];
    }

    public function getAnswersAttribute()
    {
        return $this->flexible('answers_layout');
    }

}

App/Nova/Flexible/Resolvers/QuestionResolver.php

<?php

namespace App\Nova\Flexible\Resolvers;

use App\Models\Question;
use Debugbar;
use Whitecube\NovaFlexibleContent\Value\ResolverInterface;

class QuestionResolver implements ResolverInterface
{
    /**
     * get the field's value
     *
     * @param  mixed  $resource
     * @param  string $attribute
     * @param  Whitecube\NovaFlexibleContent\Layouts\Collection $layouts
     * @return Illuminate\Support\Collection
     */
    public function get($resource, $attribute, $layouts)
    {
        $questions = $resource->questions()->get();
        return $questions->map(function($question) use ($layouts) {

            $layout = $layouts->find('questions-layout');

            if(!$layout) return;

            return $layout->duplicateAndHydrate($question->id, [
                'id' => $question->id,
                'quiz_id' => $question->quiz_id,
                'text' => $question->text
            ]);
        })->filter();
    }

    /**
     * Set the field's value
     *
     * @param  mixed  $model
     * @param  string $attribute
     * @param  Illuminate\Support\Collection $groups
     * @return string
     */
    public function set($model, $attribute, $groups)
    {

        Debugbar::info("Entering question resolver!");
        Debugbar::info($model);
        $class = get_class($model);

        $class::saved(function ($model) use ($groups) {

            $questions = $groups->map(function($group, $index) use ($model) {
                return [
                    'id' => $group->id,
                    'quiz_id' => $group->quiz_id,
                    'text' => $group->text,
                ];
            });

            $model->questions()->sync($questions->toArray());
        });
    }
}

App/Nova/Flexible/Layouts/AnswerLayout.php

<?php

namespace App\Nova\Flexible\Layouts;

use Laravel\Nova\Fields\Text;
use Whitecube\NovaFlexibleContent\Layouts\Layout;

class AnswerLayout extends Layout
{
    /**
     * The layout's unique identifier
     *
     * @var string
     */
    protected $name = 'answer-layout';

    /**
     * The displayed title
     *
     * @var string
     */
    protected $title = 'Answer Layout';

    /**
     * Get the fields displayed by the layout.
     *
     * @return array
     */
    public function fields()
    {
        return [
            Text::make('Text')->rules('required', 'max:50'),
        ];
    }

}

App/Nova/Flexible/Resolvers/AnswerResolver.php

<?php

namespace App\Nova\Flexible\Resolvers;

use App\Models\Question;
use Debugbar;
use Whitecube\NovaFlexibleContent\Value\ResolverInterface;

class AnswerResolver implements ResolverInterface
{
    /**
     * get the field's value
     *
     * @param mixed $resource
     * @param string $attribute
     * @param Whitecube\NovaFlexibleContent\Layouts\Collection $layouts
     * @return Illuminate\Support\Collection
     */
    public function get($resource, $attribute, $layouts)
    {
        $question = Question::find($resource['id']);

        if (!$question) return collect([]);
        $answers = $question->answers;

        return $answers->map(function ($answer) use ($layouts) {

            $layout = $layouts->find('answer-layout');

            if (!$layout) return;

            return $layout->duplicateAndHydrate($answer->id . '_answer', [
                'text' => $answer->text,
                'question_id' => $answer->question_id,
            ]);

        })->filter();
    }

    /**
     * Set the field's value
     *
     * @param mixed $model
     * @param string $attribute
     * @param Illuminate\Support\Collection $groups
     * @return string
     */
    public function set($model, $attribute, $groups)
    {
        Debugbar::info("Entering answers!", $model,$groups);

        $question = Question::firstOrCreate(
            ["id" => $model->id],
            ['text' => $model->text, 'quiz_id' => '999999999']
        );

        $blocks = $groups->map(function ($group, $index) use ($model, $question) {
            return [
                'question_id' => $question->id,
                'text' => $group->text,
                'is_correct' => $index === 0 ? 1 : 0
            ];
        });

        $question->answers()->sync($blocks->toArray());
    }
}

I also needed to add SYNC method to OneToMany relationshop since it does not come as default in Laravel. I did it by addin g this macro ( that i found on StackOverflow) to

App/Providers/AppServiceProvider.php

<?php

namespace App\Providers;

use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\ServiceProvider;

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

    /**
     * Bootstrap any application services.
     *
     * @return void
     */
    public function boot()
    {
        HasMany::macro( 'sync', function ( $data, $deleting = true ) {
            $changes = [
                'created' => [], 'deleted' => [], 'updated' => [],
            ];

            /**
             * Cast the given keys to integers if they are numeric and string otherwise.
             *
             * @param array $keys
             *
             * @return array
             */
            $castKeys = function ( array $keys ) {
                return (array)array_map( function ( $v ) {
                    return is_numeric( $v ) ? (int)$v : (string)$v;
                }, $keys );
            };

            $relatedKeyName = $this->related->getKeyName();

            $getCompositeKey = function ( $row ) use ( $relatedKeyName ) {
                $keys = [];
                foreach ( (array)$relatedKeyName as $k ) {
                    $keys[] = data_get( $row, $k );
                }
                return join( '|', $keys );
            };

            // First we need to attach any of the associated models that are not currently
            // in the child entity table. We'll spin through the given IDs, checking to see
            // if they exist in the array of current ones, and if not we will insert.
            $current = $this->newQuery()->get( $relatedKeyName )->map( $getCompositeKey )->toArray();

            // Separate the submitted data into "update" and "new"
            $updateRows = [];
            $newRows = [];
            foreach ( $data as $row ) {
                $key = $getCompositeKey( $row );
                // We determine "updateable" rows as those whose $relatedKeyName (usually 'id') is set, not empty, and
                // match a related row in the database.
                if ( ! empty( $key ) && in_array( $key, $current ) ) {
                    $updateRows[$key] = $row;
                } else {
                    $newRows[] = $row;
                }
            }

            // Next, we'll determine the rows in the database that aren't in the "update" list.
            // These rows will be scheduled for deletion.  Again, we determine based on the relatedKeyName (typically 'id').
            $updateIds = array_keys( $updateRows );

            if ( $deleting ) {
                $deleteIds = [];
                foreach ( $current as $currentId ) {
                    if ( ! in_array( $currentId, $updateIds ) ) {
                        $deleteIds[$currentId] = array_combine( (array)$relatedKeyName, explode( '|', $currentId ) );
                    }
                }

                // Delete any non-matching rows
                if ( count( $deleteIds ) > 0 ) {
                    /**
                     * @var \Illuminate\Database\Query\Builder $q
                     */
                    $q = $this->newQuery();
                    $q->where(function ($q) use ( $relatedKeyName, $deleteIds) {
                        foreach ( $deleteIds as $row ) {
                            $q->where( function ( $q ) use ( $relatedKeyName, $row ) {
                                foreach ( (array)$relatedKeyName as $key ) {
                                    $q->where( $key, $row[$key] );
                                }
                            }, null, null, 'or' );
                        }
                    });
                    $q->delete();

                    $changes['deleted'] = $castKeys( array_keys( $deleteIds ) );
                }
            }

            // Update the updatable rows
            foreach ( $updateRows as $id => $row ) {
                $q = $this->getRelated();

                foreach ( (array)$relatedKeyName as $key ) {
                    $q->where( $key, $row[$key] )->update( $row );
                }
            }

            $changes['updated'] = $castKeys( $updateIds );

            // Insert the new rows
            $newIds = [];
            foreach ( $newRows as $row ) {
                $newModel = $this->create( $row );
                $newIds[] = $getCompositeKey( $newModel );
            }

            $changes['created'] = $castKeys( $newIds );

            return $changes;
        } );
    }
}

Ok now that we have all the files ( hopefully i didnt forget something ) - its time for the tricky part.

  1. Where im stuck ?!?!?!?!?

So with the current state of code - I have the following issue.

Questions are saved to the table "questions" correctly, Answers also - but the issue is that in the AnswersResolver.php i dont have access to the original model of the Quiz, i just have access to the QuestionLayout. And that causes issues only when i create a new question ( In AnswerResolver ) because i cannot access Quizz ID which i can assign. This is also partially issue because the AnswerResolver is called before the QuestionResolver - so creating a Question in QuestionResolver is not an option. And in the QuestionResolver - if i wanted to populate the Answers - the issue is that i cannot use "find" method on the Collection of Layouts to find AnswerLayout since its located in the fields - and i was not able to access it and get the Layout for Answers to hidrate.

Does anyone have any idea how this could be solved, or at least push me in the right direction?

Im here for any further questions that you may have!

johnpuddephatt commented 2 months ago

Spent a few hours yesterday trying to get to the bottom of this. Sharing in case it helps anyone.

My explanation below refers to @SanjinOsmanbasic's example.

There are two issues:

There is a simple answer though, which is to do all the necessary work in the QuestionResolver.

At first this doesn't seem possible as in the QuestionResolver you cannot access your 'answers'.

But it is possible, you just need the following in the set() method of the AnswerResolver:

public function set($resource, $attribute, $groups) {
    $resource->answers = $groups
}

This means that in the QuestionResolver your answers will now be present in $groups:

$groups->each(function($group) {
   $group->answers // here are your answers
   });

This means you can create each question and then create the answers.