SolvedCRUD [Feature] Ability to define any (nested) fields inside "table" field

I have a Model where I want to store discounts they have for specific courses (another Model), so I tried to get it working with the "table" field in order to store any number of them as JSON fields:

image

Of course, the table view doesn't have anything but clear text fields, so I can't use any other field type in the definition. It would be perfect to have select2/select2_from_ajax/any other field usable inside those definitions, I really think it would solve master/detail needs for a lot of use cases, as discussed in #451 (comment)

Now my options are:

Any clue on this, which option should I choose ?

21 Answers

✔️Accepted Answer

I did something very similar to this, implemented a field following the table field default, and created sub fields for the table columns, one for each type. If you can help:

$this->crud->child_resource_included = ['select' => false, 'number' => false];

$this->crud->addField([
            'name' => 'items',
            'label' => 'Exercícios',
            'type' => 'child',
            'entity_singular' => 'exercício', // used on the "Add X" button
            'columns' => [
                ['label' => 'Exercício',
                    'type' => 'child_select',
                    'name' => 'id_exercicio',
                    'entity' => 'tb_exercicio',
                    'attribute' => 'descricao',
                    'size' => '3',
                    'model' => "App\Models\Exercicio"],
                ['label' => 'Intensidade',
                    'type' => 'child_select',
                    'name' => 'id_intensidade_exercicio',
                    'entity' => 'tb_intensidade_exercicio',
                    'attribute' => 'descricao',
                    'size' => '2',
                    'model' => "App\Models\IntensidadeExercicio"],
                ['name' => 'serie',
                    'label' => 'Séries padrão',
                    'type' => 'child_number'],
                ['name' => 'repeticao',
                    'label' => 'Repetições padrão',
                    'type' => 'child_number'],
                ['name' => 'carga',
                    'label' => 'Carga padrão',
                    'type' => 'child_number']
            ],
            'max' => 12, // maximum rows allowed in the table
            'min' => 0 // minimum rows allowed in the table
        ]);

child.blade.php

<!-- array input -->
<?php
    $max = isset($field['max']) && (int) $field['max'] > 0 ? $field['max'] : -1;
    $min = isset($field['min']) && (int) $field['min'] > 0 ? $field['min'] : -1;
    $item_name = strtolower(isset($field['entity_singular']) && !empty($field['entity_singular']) ? $field['entity_singular'] : $field['label']);

    $items = old($field['name']) ? (old($field['name'])) : (isset($field['value']) ? ($field['value']) : (isset($field['default']) ? ($field['default']) : '' ));

    // make sure not matter the attribute casting
    // the $items variable contains a properly defined JSON
    if (is_array($items)) {
        if (count($items)) {
            $items = json_encode($items);
        } else {
            $items = '[]';
        }
    } elseif (is_string($items) && !is_array(json_decode($items))) {
        $items = '[]';
    }

?>
<div 
    ng-app="backPackTableApp" 
    ng-controller="tableController" 
    @include('crud::inc.field_wrapper_attributes') 
    >

    <label>{!! $field['label'] !!}</label>
    @include('crud::inc.field_translatable_icon')

    <input class="array-json" type="hidden" id="{{ $field['name'] }}" name="{{ $field['name'] }}">

    <div class="array-container form-group">

        <table 
            class="table table-bordered table-striped m-b-0" 
            ng-init="field = '#{{ $field['name'] }}'; items = {{ $items }}; max = {{$max}}; min = {{$min}}; maxErrorTitle = '{{trans('backpack::crud.table_cant_add', ['entity' => $item_name])}}'; maxErrorMessage = '{{trans('backpack::crud.table_max_reached', ['max' => $max])}}'"
            >

            <thead>
                <tr>
                    @foreach( $field['columns'] as $column )
                    <th style="font-weight: 600!important;">
                        {{ $column['label'] }}
                    </th>
                    @endforeach
                    <th class="text-center" ng-if="max == -1 || max > 1"> {{-- <i class="fa fa-sort"></i> --}} </th>
                    <th class="text-center" ng-if="max == -1 || max > 1"> {{-- <i class="fa fa-trash"></i> --}} </th>
                </tr>
            </thead>

            <tbody ui-sortable="sortableOptions" ng-model="items" class="table-striped">

                <tr post-render ng-repeat="item in items" class="array-row" >
                    
                    
                    @foreach ($field['columns'] as $column)
                        <td 
                             class="
                                @if(isset($column['size']))  
                                    col-md-{{ $column['size'] }}
                                @endif
                                "
                            >
                        <!-- load the view from the application if it exists, otherwise load the one in the package -->
                        @if(view()->exists('vendor.backpack.crud.fields.'.$column['type']))
                            @include('vendor.backpack.crud.fields.'.$column['type'], array('field' => $column))
                        @else
                            @include('crud::fields.'.$column['type'], array('field' => $column))
                        @endif
                        </td>
                    @endforeach

                    <td ng-if="max == -1 || max > 1">
                        <span class="btn btn-sm btn-default sort-handle"><span class="sr-only">sort item</span><i class="fa fa-sort" role="presentation" aria-hidden="true"></i></span>
                    </td>
                    <td ng-if="max == -1 || max > 1">
                        <button ng-hide="min > -1 && $index < min" class="btn btn-sm btn-default" type="button" ng-click="removeItem(item);"><span class="sr-only">delete item</span><i class="fa fa-trash" role="presentation" aria-hidden="true"></i></button>
                    </td>
                </tr>

            </tbody>

        </table>

        <div class="array-controls btn-group m-t-10">
            <button ng-if="max == -1 || items.length < max" class="btn btn-sm btn-default" type="button" ng-click="addItem()"><i class="fa fa-plus"></i> Adiciionar item ({{ $item_name }})</button>
        </div>

    </div>

    {{-- HINT --}}
    @if (isset($field['hint']))
        <p class="help-block">{!! $field['hint'] !!}</p>
    @endif
</div>

{{-- ########################################## --}}
{{-- Extra CSS and JS for this particular field --}}
{{-- If a field type is shown multiple times on a form, the CSS and JS will only be loaded once --}}
@if ($crud->checkIfFieldIsFirstOfItsType($field, $fields))

    {{-- FIELD CSS - will be loaded in the after_styles section --}}
    @push('crud_fields_styles')
    {{-- @push('crud_fields_styles')
        {{-- YOUR CSS HERE --}}
    @endpush

    {{-- FIELD JS - will be loaded in the after_scripts section --}}
    @push('crud_fields_scripts')
        {{-- YOUR JS HERE --}}
        <script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.5.8/angular.min.js"></script>
        <script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js"></script>
        <script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/angular-ui-sortable/0.14.3/sortable.min.js"></script>
        <script>

            window.angularApp = window.angularApp || angular.module('backPackTableApp', ['ui.sortable'], function($interpolateProvider){
                $interpolateProvider.startSymbol('<%');
                $interpolateProvider.endSymbol('%>');
            });


            window.angularApp.controller('tableController', function($scope){

                $scope.sortableOptions = {
                    handle: '.sort-handle'
                };

                $scope.addItem = function(){

                    if( $scope.max > -1 ){
                        if( $scope.items.length < $scope.max ){
                            var item = {};
                            $scope.items.push(item);
                        } else {
                            new PNotify({
                                title: $scope.maxErrorTitle,
                                text: $scope.maxErrorMessage,
                                type: 'error'
                            });
                        }
                    }
                    else {
                        var item = {};
                        $scope.items.push(item);
                    }
                    
                    
                }

                $scope.removeItem = function(item){
                    var index = $scope.items.indexOf(item);
                    $scope.items.splice(index, 1);
                }
                
                $scope.$watch('items', function(a, b){

                    if( $scope.min > -1 ){
                        while($scope.items.length < $scope.min){
                            $scope.addItem();
                        }
                    }

                    if( typeof $scope.items != 'undefined' && $scope.items.length ){

                        if( typeof $scope.field != 'undefined'){
                            if( typeof $scope.field == 'string' ){
                                $scope.field = $($scope.field);
                            }
                            $scope.field.val( angular.toJson($scope.items) );
                        }
                    }
                    
                }, true);

                if( $scope.min > -1 ){
                    for(var i = 0; i < $scope.min; i++){
                        $scope.addItem();
                    }
                }
            });
            window.angularApp.directive('postRender', function($timeout) {
                return {
                   link: function(scope, element, attr) {
                      $timeout(function() {
                         $('.select2').each(function (i, obj) {
                                if (!$(obj).data("select2"))
                                {
                                    $(obj).select2();
                                }
                            });
                      });
                   }
                }
            });
        
            angular.element(document).ready(function(){
                angular.forEach(angular.element('[ng-app]'), function(ctrl){
                    var ctrlDom = angular.element(ctrl);
                    if( !ctrlDom.hasClass('ng-scope') ){
                        angular.bootstrap(ctrl, [ctrlDom.attr('ng-app')]);
                    }
                });
            });

        </script>

    @endpush
@endif
{{-- End of Extra CSS and JS --}}
{{-- ########################################## --}}

child_select.blade.php

<!-- select2 -->
<div clas="col-md-12">

    <?php $entity_model = $crud->model; ?>
    <select 
        ng-model="item.{{ $field['name'] }}"
        @include('crud::inc.field_attributes', ['default_class' =>  'form-control select2'])
        >
            <option value="">-</option>

            @if (isset($field['model']))
                @foreach ($field['model']::all() as $connected_entity_entry)
                    <option value="{{ $connected_entity_entry->getKey() }}"
                    >{{ $connected_entity_entry->{$field['attribute']} }}</option>
                @endforeach
            @endif
    </select>

    {{-- HINT --}}
    @if (isset($field['hint']))
        <p class="help-block">{!! $field['hint'] !!}</p>
    @endif
</div>

{{-- ########################################## --}}
{{-- Extra CSS and JS for this particular field --}}
{{-- If a field type is shown multiple times on a form, the CSS and JS will only be loaded once --}}
@if (!$crud->child_resource_included['select'])

    {{-- FIELD CSS - will be loaded in the after_styles section --}}
    @push('crud_fields_styles')
        <!-- include select2 css-->
        <link href="{{ asset('vendor/backpack/select2/select2.css') }}" rel="stylesheet" type="text/css" />
        <link href="{{ asset('vendor/backpack/select2/select2-bootstrap-dick.css') }}" rel="stylesheet" type="text/css" />
    @endpush

    {{-- FIELD JS - will be loaded in the after_scripts section --}}
    @push('crud_fields_scripts')
        <!-- include select2 js-->
        <script src="{{ asset('vendor/backpack/select2/select2.js') }}"></script>
    @endpush

    
    <?php $crud->child_resource_included['select'] = true; ?>
@endif
{{-- End of Extra CSS and JS --}}
{{-- ########################################## --}}

child_number.blade.php

<!-- number input -->
<div>
    
    @if(isset($field['prefix']) || isset($field['suffix'])) <div class="input-group"> @endif
        @if(isset($field['prefix'])) <div class="input-group-addon">{!! $field['prefix'] !!}</div> @endif
        <input
            type="number"
            ng-model="item.{{ $field['name'] }}"
            @include('crud::inc.field_attributes')
        	>
        @if(isset($field['suffix'])) <div class="input-group-addon">{!! $field['suffix'] !!}</div> @endif

    @if(isset($field['prefix']) || isset($field['suffix'])) </div> @endif

    {{-- HINT --}}
    @if (isset($field['hint']))
        <p class="help-block">{!! $field['hint'] !!}</p>
    @endif
</div>


@if (!$crud->child_resource_included['number'])

    @push('crud_fields_styles')
        <style>
            .table input[type='number'] { text-align: right; padding-right: 5px; }
        </style>
    @endpush

    <?php $crud->child_resource_included['number'] = true; ?>
@endif

image

Other Answers:

@fabriciolangermt HUGE THANKS !

Was doing EXACTLY the same, with table_rich.blade, table_rich_select2, table_rich_text, etc... :D

I took advantage of your method of including those files, plus the "only load assets once" method and "postRender" directive.

I also added a couple of fields in the Model declaration, to be able to order the select2:

...
'sortField' => 'nombre',
'sortDirection' => 'asc',
...

And in the select:

...
<?php $fields = $field['sortField'] ? $field['model']::orderBy($field['sortField'], $field['sortDirection'])->get() : $field['model']::all(); ?>
@foreach ($fields as $connected_entity_entry)
...

Also managed to get the "-" option automatically only when the Model field attribute is nullable:

...
@if ($field['model']::isColumnNullable($field['attribute']))
    <option value="">-</option>
@endif
...

I also found a problem when the form already contains a normal select2, adding them with your example gave me "query function not defined for Select2 s2id_autogen5" in console. Fixed it by setting a different class specific to this table .rich_select2 both in angular function and select class declaration.

And finally sorted a couple of anoyances with the sort drag here #466

Just my 2 cents, thanks again !

More Issues: