Laravel-Backpack / CRUD

Build custom admin panels. Fast!
https://backpackforlaravel.com
MIT License
3k stars 880 forks source link

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

Closed MarcosBL closed 6 years ago

MarcosBL commented 7 years ago

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 https://github.com/Laravel-Backpack/CRUD/issues/451#issuecomment-279974394

Now my options are:

Any clue on this, which option should I choose ?

OwenMelbz commented 7 years ago

Well there is an outstanding issue for version v4 which has a "matrix" style repeatable

https://github.com/Laravel-Backpack/CRUD/issues/131

The idea is to be able to have a repeatable field for most field types, however this is pretty massive job to do :D

So this might be some bespoke functionality you'll need to create.

Option 2 is the easiest for you to complete - however making customisable re-usuable component and releasing it I'm sure will make many people happy :)

MarcosBL commented 7 years ago

Sure, I also found https://github.com/Laravel-Backpack/CRUD/issues/247 after writing this.

https://github.com/Laravel-Backpack/CRUD/issues/131#issuecomment-249187471 is the exact need I had, with the ability to reference another Model in a nested field.

I would like to create something reusable, but I'm far from being able to, just started learning Backpack basics... only thing that I can think of is sharing my new view so somebody can use it as a (bad) example in the future, will kep you updated!

fabriciolangermt commented 7 years ago

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

MarcosBL commented 7 years ago

@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 https://github.com/Laravel-Backpack/CRUD/pull/466

Just my 2 cents, thanks again !

automat64 commented 7 years ago

Thanks guys, you saved me a lot of time.

Just in case someone wants to do something similar, I am saving the values directly to the related model in crud store and update. I am using an accessor for reading.

select2 expects strings for option keys so you need to cast your IDs to string.

Fiftysven commented 7 years ago

First of all thanks to @fabriciolangermt, you saved me a lot of time =)

I am using your child.blade.php and creating new blades like child_date.blade.php works without problems. The point I am struggling with is, as soon as i use a child_blade like child_date_picker.blade.php that has its own Javascript it kinda gets broken.

I am really not that advanced in Javascript so i dont know what breaks the code, but i can give out console.logs, so i know the code gets executed. In my example of a datepicker when I click into the datefield normally a calendar to select a date should open but unfortunately it doesnt.

I hope I could explain my problem precisely enough and someone of you can help me. If someone has a child.blade where he uses Javascript a Code snippet would probably already help =)

fabriciolangermt commented 7 years ago

to use date i create this

<!-- html5 date input -->

<div @include('crud::inc.field_wrapper_attributes') >
    @include('crud::inc.field_translatable_icon')
    <input
        type="date"
        ng-model="item.{{ $field['name'] }}"
        @include('crud::inc.field_attributes')
        >

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

I also used this to make child rows, and created some more fields (child_hidden, child_text). Maybe we could put everything together and make a pull request with all this new fields? Are you willing to add this, @tabacitu ?

tabacitu commented 7 years ago

@mariavilaro - I am willing to add it, sure, but I think the concept needs a lot more work before actually getting into coding. In this point, we can't add features that aren't rock-solid into Backpack, because it's difficult to support them afterwards, and very VERY difficult to change them (breaking changes suck for everybody).

How do you see this going? How would it look, how would the user edit the child entry - from inside the same form, or have an edit button to go to a different page? Open a modal? How many fields would then fit for a single child entry?

mariavilaro commented 7 years ago

I'm developing this for a customer project. When doing it I already noticed lots of problems and caveats. You can only use a child editable entry if the table is very simple - that is, no "edit" button. If the table has lots of fields or complex fields (i.e. file upload), then you should use only a a list with a create button that opens the model creation form (and edit button to edit). So I created 2 new fields: child.blade.php that is like table but with select field, and child-list.blade.php that is a mix between list and table - no editable but you can reorder and delete.

I also worked with the return urls so they go back to the parent after creation and edition, and in the needed relationship parameters. So I modified SaveActions and I created 2 new CrudTraits. I will try to polish everything and I will do a PR with all.

MarcosBL commented 7 years ago

@mariavilaro just curious how are you implementing this, I made something similar, if you want to take some ideas, but looking for further improvements at https://github.com/Laravel-Backpack/CRUD/issues/624

victorlap commented 7 years ago

@fabriciolangermt I tried implementing your solution stated in the comments above, however when I submit the form, the values are not serialized and are not submitted in the form. Can you explain where this is done in your code?

amipax commented 6 years ago

I'm fighting to make child_datetime_picker to work, i just copied contents from datetime_picker to the resources folder, what modifications must i do to the file to display the calendar, if someone has already implemented it , please share.

this is what i have done .the items array only contains parameters values .

form image code returned

lloy0076 commented 6 years ago

@mariavilaro @tabacitu @MarcosBL - closing due to inactivity (@amipax - feel free to open a fresh issue with what you currently have impelemented).

Please re-open if a PR becomes available.

mcsystemen commented 5 years ago

Hi all,

I was wondering if there are any updates for this since the latest Backpack update? I'm searching the internet for a while now, but very hard to find if there is a good workaround.

Thanks

tabacitu commented 5 years ago

@mcsystemen - no updates. It will definitely be a feature in Backpack v4 - due for Q1 2019. We might be able to insert this feature before, as a non-breaking change, but no promises. In both cases, register on http://backpackforlaravel.com and you'll find out as soon as it's out, thought the monthly newsletter.

rudolphp commented 5 years ago

@fabriciolangermt thank you for this solution. Can you also post how you managed the edit UI? The table values do not get pre filled in the edit form.

pxpm commented 5 years ago

I'm not sure table field will not be replaced by the matrix one. But anyways, if there are people struggling to get some fields like datepicker or ckeditor to work with table field i will post here the solution.

You have to create angular directives, and attach that directives. I'm better explaining with examples.

Datapicker:

window.angularApp.directive('pickerIcon', function () {
    return {
        restrict: 'A',
        require: '^ngModel',
        link: function (scope, element, attrs,ngModel) {
            element.iconpicker({
                iconset : 'fontawesome',
                icon: scope.item.icon
            }).on('change', function (e) {
                element.siblings('input[type=hidden]').val(e.icon);
             ngModel.$setViewValue(e.icon);
            });

            }
        }
    });

and html:

<button 
class="btn btn-default" 
role="iconpicker" 
data-icon="" 
data-iconset="fontawesome" 
picker-icon
>
</button>
shealavington commented 4 years ago

@mcsystemen - no updates. It will definitely be a feature in Backpack v4 - due for Q1 2019. We might be able to insert this feature before, as a non-breaking change, but no promises. In both cases, register on http://backpackforlaravel.com and you'll find out as soon as it's out, thought the monthly newsletter.

V4 Is now about, I'm wondering if there's any further update on this, I can't seem to find anything.

tabacitu commented 4 years ago

@shealavington We're finally actively working on this - the changes in v4 javascript allow us to do this. See PR https://github.com/Laravel-Backpack/CRUD/pull/2266 . Can't wait to have this feature either!