thedevdojo / voyager

Voyager - The Missing Laravel Admin
https://voyager.devdojo.com
MIT License
11.78k stars 2.67k forks source link

BelongsToMany w/Pivot data or order #3327

Open kylemilloy opened 6 years ago

kylemilloy commented 6 years ago

I have some many-to-many relationships that I'd like to include pivot data with. I don't suppose there's a way to get/set pivot table data within the Voyager UI short of making BREAD for the pivot table?

The use case is: A project belongs to many services. But I don't want to show them in the order they were made. Instead, want to have an "order" column that a user can set to display them as required.

I am assuming I'll need to build something bespoke here...how can I create a custom data type for including pivot data? What part of Voyager would I need to augment?

kylemilloy commented 6 years ago

For anyone else looking to solve this issue I hacked it by doing the following...this guide specifically deals with setting an "order" attribute on the pivot table but you could likely turn it into anything.

Create a custom controller

You'll need a custom controller to handle the pivot attributes. Create a controller and extend the VoyagerBaseController. From here we're going to overwrite the insertUpdateData method. Most of this remains the same but near the very end where we run a foreach call on the $multi_select variable we're going to modify some stuff here. If you're looking to order items, do the following:

// ... insertUpdateData
foreach ($multi_select as $sync_data) {
  $attrs = [];

  for ($i = 0; $i < $count; $i++) {
    $pivot[$i] = ['order' => $i];
  }

  //
  $data->belongsToMany($sync_data['model'], $sync_data['table'])->sync(
     array_combine($sync_data['content'],  $attrs)
  );
}
// ... 

See Laravel documentation here for the Laravel sync command and what it expects

Include your pivot data in the relationship

On the model you need to include the relationship data for later so add a ->withPivot($column_name) call to the end of your belongsToMany call.

Create custom views

You'll need to overwrite your edit-add view for the slug of the model you're updating. Ex: Updating the "Artist" model? Then create a view at resources/views/vendor/voyager/artists/edit-add.blade.php and copy everything from the base edit-add view. See the doc here. If you're trying to add order you'll also need to make a custom relationship partial at resources/views/vendor/voyager/formfields/relationship-w-pivot.blade.php. Copy the contents of the base voyager relationship.blade.php view. In the custom edit-add view you made change the include relationship call to point at your custom one.

Update some javascript

This is very specific to setting an order and likely won't need to be done. In the edit-add partial you made attach jQuery UI in the javascripts section and add the following at the bottom...feel free to clean this up but I hacked it up with the following:

$('.select2-selection__rendered').sortable({
  stop: function(evt, ui) {
    $parent = $(evt.target).parents('.form-group');
    var select = $parent.find('select');
    var option_array = $(evt.target).parents('.form-group').find('.select2-selection__choice').get();
    $.each(option_array, function(i, el) {
      var id = $(el).attr('id');
      var option = select.find('option[value="' + id + '"]')[0];
      select.prepend(option);
    });
  }
});

This will make it so the belongsToMany field will be sortable and when it sorts it it will update the order of the select options. Doing this allows us to avoid passing more data to the backend and instead just rely on the order of the options in the select input to tell Laravel what order to assign.

Modify your custom relationship view

For ordering we need to persist the order that they appear. Skip down to the <select multiple> portion (around line 150) and here you will see a call to @php where we assign the $selected_values variable. Everything should remain the same but I modified it a bit to fetch additional pivot info...and then sort by this pivot data. I changed the @php section to be the following:

$selected_values = isset($dataTypeContent)
  ? $dataTypeContent->belongsToMany($options->model, $options->pivot_table)->withPivot('order')->get()
  : array();
$relationshipOptions = app($options->model)->all()->merge($selected_values)->sortByDesc('pivot.order');
$selected_values = $selected_values->pluck($options->key)->toArray();
lablushah commented 5 years ago
$count

where the $count comes from and $pivot array where we used it. Thanks.

kylemilloy commented 5 years ago

From the rest of the insertUpdateData method on the parent class.

Daniyal-Javani commented 4 years ago

Is there any new/built-in way to do that?

alexisrazok commented 4 years ago

@lablushah I update $count as count($sync_data['content']) and then worked for me.

devig commented 4 years ago

@kylemilloy Do you have full example in repository?

g-trema commented 4 years ago

Here's an exemple with a custom text pivot field : We have a Model Sale: we can sale some elements to a user and we want to save the price of the sale in the pivot table.

Configuring the relationship

In the relationship details we add the name of the pivot field and the displayed name wanted (here the name is displayed in french):

{
    "pivot_datas": [
        [
            "price",
            "Prix (CHF)"
        ]
    ]
}

Custom controller

You'll need a custom controller to handle the pivot attributes. Create a controller and extend the VoyagerBaseController. From here we're going to overwrite the insertUpdateData method.

//...  insertUpdateData

//

             //add pivotDatas in $multi_select[]
             if ($row->type == 'relationship' && $row->details->type == 'belongsToMany') {
                // Only if select_multiple is working with a relationship
                $multi_select[] = [
                    'model'           => $row->details->model,
                    'content'         => $content,
                    'table'           => $row->details->pivot_table,
                    'foreignPivotKey' => $row->details->foreign_pivot_key ?? null,
                    'relatedPivotKey' => $row->details->related_pivot_key ?? null,
                    'parentKey'       => $row->details->parent_key ?? null,
                    'relatedKey'      => $row->details->key,
                    'pivotDatas'      => $row->details->pivot_datas ?? null,
                    'FieldName'       => $row->field,
                ];
            } else {
                $data->{$row->field} = $content;
            }

// ...

          foreach ($multi_select as $sync_data) {
            //if there is pivot data parameters
            if(isset($sync_data['pivotDatas']) && isset($sync_data['content'])){
              $pivot_tab = [];
              //we are looking for pivot data value

              foreach($sync_data['content'] as $key => $content_to_sync_with){
                $pivot_rows = [];
                foreach($sync_data['pivotDatas'] as $pivot_data){
                  //name of the column in the pivot table
                  $name_of_pivot = $pivot_data[0];
                  //form field name
                  $name_of_formField = $sync_data['FieldName']."-".$name_of_pivot;

                  //value of this form field name
                  $value = $request->$name_of_formField[$key];
                  $pivot_rows[$name_of_pivot] = $value;
                }
                array_push($pivot_tab, $pivot_rows);
              }
              //combination of the datas to sync and datas to put in the columns of the pivot table
              $sync = array_combine($sync_data['content'], $pivot_tab);
            }else{
              $sync = $sync_data['content'];
            }

            $data->belongsToMany(
                $sync_data['model'],
                $sync_data['table'],
                $sync_data['foreignPivotKey'],
                $sync_data['relatedPivotKey'],
                $sync_data['parentKey'],
                $sync_data['relatedKey']
            )->sync($sync);
        }

Create relationshipwithpivot.blade.php

You have to create a new file in ressources/views/vendor/voyager/formfields/relationshipwithpivot.blade.php:

@php
    $relationshipField = $row->field;
    $options->pivot_extra_fields = [];
    foreach($options->pivot_datas as $pivot_datas){
      array_push($options->pivot_extra_fields, $pivot_datas[0]);
    }
    $selected_values = isset($dataTypeContent) ? $dataTypeContent->belongsToMany($options->model, $options->pivot_table, $options->foreign_pivot_key ?? null, $options->related_pivot_key ?? null, $options->parent_key ?? null, $options->key)->withPivot($options->pivot_extra_fields)->get()->map(function ($item, $key) use ($options) {
        $array_pivot = [];
        foreach($options->pivot_extra_fields as $pivot_col_name){
          array_push($array_pivot, $item->pivot->$pivot_col_name);
        }
        return array('id' => $item->{$options->key}, 'extra_pivot' => $array_pivot);

    })->all() : array();

    $relationshipOptions = app($options->model)->all();
    $selected_values = old($relationshipField, $selected_values);
    if(count($selected_values) == null){
      $selected_values[0] = 0;
    }
@endphp
@foreach($selected_values as $cpt => $selected_value)
<div class="panel-body" id="{{ $relationshipField }}" style="background-color:#f2f2f2">
  <div id="{{ $relationshipField.'-num'.$cpt }}">

    <label class="control-label" for="name">{{ $row->getTranslatedAttribute('display_name') }}</label>
    <select
        class="form-control"
        onchange="hideAlreadySelectedOptions(this)"
        name="{{ $relationshipField }}[]"
        data-get-items-route="{{route('voyager.' . $dataType->slug.'.relation')}}"
        data-get-items-field="{{$row->field}}"
        @if(!is_null($dataTypeContent->getKey())) data-id="{{$dataTypeContent->getKey()}}" @endif
        data-method="{{ !is_null($dataTypeContent->getKey()) ? 'edit' : 'add' }}"
        @if(isset($options->taggable) && $options->taggable === 'on')
            data-route="{{ route('voyager.'.\Illuminate\Support\Str::slug($options->table).'.store') }}"
            data-label="{{$options->label}}"
            data-error-message="{{__('voyager::bread.error_tagging')}}"
        @endif
    >

        @if(!$row->required)
            <option value="">{{__('voyager::generic.none')}}</option>
        @endif

        @foreach($relationshipOptions as $relationshipOption)
            <option value="{{ $relationshipOption->{$options->key} }}" @if($relationshipOption->{$options->key} == $selected_value['id']) selected="selected" @endif>{{ $relationshipOption->{$options->label} }}</option>
        @endforeach

    </select>
    @foreach($row->details->pivot_datas as $key => $pivot_datas)

        <label for="{{ $relationshipField.'-'.$pivot_datas[0] }}">{{ $pivot_datas[1] }}</label>
        <input type="text" class="form-control pivot" id="{{ $relationshipField.'-'.$pivot_datas[0] }}" name="{{ $relationshipField.'-'.$pivot_datas[0].'[]' }}" placeholder="{{ $pivot_datas[1] }}"
               value="{{$selected_value['extra_pivot'][$key]}}">

    @endforeach
    <hr style="height:1px;border-width:0;color:gray;background-color:gray">
  </div>
  @if(count($relationshipOptions) > 1)
  <a class="addEntry" style="cursor: pointer;" data-relationshipField="{{ $relationshipField }}" data-nbOfRelationshipOptions="{{ count($relationshipOptions) }}"><span class="voyager-plus">Ajouter une entrée</span></a>
  <a class="delEntry" style="cursor: pointer; display: none;" data-relationshipField="{{ $relationshipField }}" data-nbOfRelationshipOptions="{{ count($relationshipOptions) }}"><span class="voyager-trash">Supprimer une entrée</span></a>
  @endif
</div>
@endforeach

Overwrite edit-add.blade.php

We change the way to display the relationship type field:

//...

                                    @if (isset($row->details->view))
                                        @include($row->details->view, ['row' => $row, 'dataType' => $dataType, 'dataTypeContent' => $dataTypeContent, 'content' => $dataTypeContent->{$row->field}, 'action' => ($edit ? 'edit' : 'add'), 'view' => ($edit ? 'edit' : 'add'), 'options' => $row->details])
                                    @elseif ($row->type == 'relationship')
                                      @if(isset($row->details->pivot_datas) && $row->details->pivot_datas != null)
                                         @include('voyager::formfields.relationshipwithpivot', ['options' => $row->details])
                                      @else
                                          @include('voyager::formfields.relationship', ['options' => $row->details])
                                      @endif
                                    @else
                                        {!! app('voyager')->formField($row, $dataType, $dataTypeContent) !!}
                                    @endif 
//.....

in the javascript section of the edit-add.blade.php file:

<script>
    function hideAlreadySelectedOptions(selectObject){
        //When an option has been selected in a list, we do not want this option to be displayed in other lists.
        var name_of_select = selectObject.name;
        console.log(name_of_select);
        var options_to_hide_array = [];
        //Looking for every select element
        $("select[name='"+name_of_select+"']").each(function(){
          //The option selected has to be hide in each select
          var option_to_hide = $(this).val();
          if(option_to_hide != ""){
            options_to_hide_array.push(option_to_hide);
          }
          //for now we show all options (for updating)
          $("option", this).each(function(index, element){
            $(element).show();
          });
        });
        console.log(options_to_hide_array);
        //we are looking for every select again
        $("select[name='"+name_of_select+"']").each(function(){
          var option_selected = $(this).val();
          $("option", this).each(function(index, element){
            //if the option is in the array of the options to be hide AND the option is not selected in the current select...
            if(options_to_hide_array.includes(element.value) && element.value != option_selected){
              //...we hide this option
              $(element).hide();
            }
          });
        });
    }
    $('.addEntry').click(function(){
      // get the last DIV which ID starts with ^= id (the $relationshipField)
      var id = $( this ).attr("data-relationshipField");
      var $div = $('div[id^="'+id+'"]:last');

      // Read the Number from that DIV's ID
      // And increment that number by 1
      var num = parseInt( $div.prop("id").split('-num')[1] ) +1;
      var $newdiv = $div.clone().prop('id', id+'-num'+num );
      $div.after($newdiv);
      $newdiv.hide();
      $newdiv.show("fast");
      //retrieve all pivot field within the new div cloned and delete their value
      var div_cloned = '#'+id+'-num'+num;
      var allPivotFields = $(div_cloned).find('.pivot').map(function() {
          this.value = "";
      }).get();

      //we don't want to select the same option twice
      $("select[name='"+id+"[]']").each(function(){
        var option_to_delete = $(this).val();
        $(div_cloned + " > select[name='"+id+"[]'] > option[value='"+option_to_delete+"']").hide();
      });

      var max_of_options = parseInt($( this ).attr("data-nbOfRelationshipOptions"));
      //if the nb of entries is bigger or equal to the maximum options available
      if(num+1 >= max_of_options){
        $(this).hide();
      }
      //show the button "delEntry" 
      $parent_div = $(this).closest("div");
      $parent_div.find(".delEntry").show();
    });
    $('.delEntry').click(function(){
      // get the last DIV which ID starts with ^= id (the $relationshipField)
      var id = $( this ).attr("data-relationshipField");
      var $div = $('div[id^="'+id+'"]:last');
      var num = parseInt( $div.prop("id").split('-num')[1] );
      $div.fadeOut(500, function(){
          $(this).remove();
          //we update the list of option off all select
          var last_select = $parent_div.find("select[name='"+id+"[]']").get(0);
          hideAlreadySelectedOptions(last_select);
      });
      if(num <= 1){
        $(this).hide();
      }
      //show the button "addEntry"
      $parent_div = $(this).closest("div");
      $parent_div.find(".addEntry").show();

    });
    </script>

Add the relation in the model

For exemple:

public function materials()
  {
      return $this->belongsToMany('App\Material')
                      ->withPivot([
                          'price',
                      ]);
  }

I know it's not perfect, but it's a base to be able to manage custom pivot fields!

nagievflash commented 3 years ago

I know it's not perfect, but it's a base to be able to manage custom pivot fields!

Thank you, it works!

phillmorgan28 commented 3 years ago

Hi,

Are there any plans to implement this feature within voyager? The example above is a great start however it doesn't seems to allow me to add additional pivot table rows and has various other bugs such as array access issues.

Thanks in advance

g-trema commented 3 years ago

Hi @phillmorgan28 Yes I think you can add an additional pivot table rows (you have to modify the "relationship options" in your bread panel and to be sure that the name correspond to your rows name in your database). And yes there's some bugs... and honestly, I think that the code must to be cleaned before to implement this future! I'm not a professional! ;-) but here's a new version of the file relationshipwithpivot.blade.php. It should work!

ressources/views/vendor/voyager/formfields/relationshipwithpivot.blade.php:

@php
    $relationshipField = $row->field;
    $options->pivot_extra_fields = [];
    foreach($options->pivot_datas as $pivot_datas){
      array_push($options->pivot_extra_fields, $pivot_datas[0]);
    }
    $selected_values = isset($dataTypeContent) ? $dataTypeContent->belongsToMany($options->model, $options->pivot_table, $options->foreign_pivot_key ?? null, $options->related_pivot_key ?? null, $options->parent_key ?? null, $options->key)->withPivot($options->pivot_extra_fields)->get()->map(function ($item, $key) use ($options) {
        $array_pivot = [];
        foreach($options->pivot_extra_fields as $pivot_col_name){
          array_push($array_pivot, $item->pivot->$pivot_col_name);
        }
        return array('id' => $item->{$options->key}, 'extra_pivot' => $array_pivot);

    })->all() : array();

    $relationshipOptions = app($options->model)->all();
    $selected_values = old($relationshipField, $selected_values);
    if(count($selected_values) == null){
      $selected_values[0] = 0;
    }
@endphp

@foreach($selected_values as $cpt => $selected_value)
<div class="panel-body" id="{{ $relationshipField }}" style="background-color:#f2f2f2">
  <div id="{{ $relationshipField.'-num'.$cpt }}">

    <label class="control-label" for="name">{{ $row->getTranslatedAttribute('display_name') }}</label>
    <select
        class="form-control"
        onchange="hideAlreadySelectedOptions(this)"
        name="{{ $relationshipField }}[]"
        data-get-items-route="{{route('voyager.' . $dataType->slug.'.relation')}}"
        data-get-items-field="{{$row->field}}"
        @if(!is_null($dataTypeContent->getKey())) data-id="{{$dataTypeContent->getKey()}}" @endif
        data-method="{{ !is_null($dataTypeContent->getKey()) ? 'edit' : 'add' }}"
        @if(isset($options->taggable) && $options->taggable === 'on')
            data-route="{{ route('voyager.'.\Illuminate\Support\Str::slug($options->table).'.store') }}"
            data-label="{{$options->label}}"
            data-error-message="{{__('voyager::bread.error_tagging')}}"
        @endif
    >

        @if(!$row->required)
            <option value="">{{__('voyager::generic.none')}}</option>
        @endif

        @foreach($relationshipOptions as $relationshipOption)
            <option value="{{ $relationshipOption->{$options->key} }}" @if(isset($selected_value['id']) && $relationshipOption->{$options->key} == $selected_value['id']) selected="selected" @endif>{{ $relationshipOption->{$options->label} }}</option>
        @endforeach

    </select>
    @foreach($row->details->pivot_datas as $key => $pivot_datas)

        <label for="{{ $relationshipField.'-'.$pivot_datas[0] }}">{{ $pivot_datas[1] }}</label>
        <input type="text" class="form-control pivot" id="{{ $relationshipField.'-'.$pivot_datas[0] }}" name="{{ $relationshipField.'-'.$pivot_datas[0].'[]' }}" placeholder="{{ $pivot_datas[1] }}"
               value="@if(isset($selected_value['extra_pivot'])) {{$selected_value['extra_pivot'][$key]}} @endif">

    @endforeach
    <hr style="height:1px;border-width:0;color:gray;background-color:gray">
  </div>
  @if(count($relationshipOptions) > 1)
  <a class="addEntry" style="cursor: pointer;" data-relationshipField="{{ $relationshipField }}" data-nbOfRelationshipOptions="{{ count($relationshipOptions) }}"><span class="voyager-plus">Ajouter une entrée</span></a>
  <a class="delEntry" style="cursor: pointer; display: none;" data-relationshipField="{{ $relationshipField }}" data-nbOfRelationshipOptions="{{ count($relationshipOptions) }}"><span class="voyager-trash">Supprimer une entrée</span></a>
  @endif
</div>
@endforeach
ahmed-meraneh commented 2 years ago

hi @g-trema

Your code works great and I thank you very much for it but there are some bugs in the display of adding and deleting an entry when editing. Can you review this?