Laravel-Backpack / CRUD

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

[Feature] Ability to define date_range (nested) fields inside "table" field #1228

Closed Gorbas closed 6 years ago

Gorbas commented 6 years ago

Bug report

What I did:

I followed instructions of @fabriciolangermt in issue #463 and created a child field in order to create a Master-Details form. Everything works great for text, number, select and hidden, but when I tried to create a child_date_range field type, I do not get the data back the the server.

What I expected to happen:

I expected that the values that I select would be passed back to the server in order to store them

What happened:

The previous values of the model were passed for the dates while the rest of the fields have the values that I changed in the form.

What I've already tried to fix it:

Backpack, Laravel, PHP, DB version:

Resources:

fabriciolangermt commented 6 years ago

you should create your implementation for this, I did for date_picker and some others, use as an example:

CrudController ['label' => trans('app.dt_tour'), 'type' => 'child_date_picker', 'name' => 'dt_tour', 'date_picker_options' => [ 'todayBtn' => 'linked', 'format' => 'dd/mm/yyyy', 'language' => 'pt-BR', 'todayHighlight' => 'true', ], 'attributes' => ['convert-to-date' => ''], ],

table field implementation (my child.blade.php)

<?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')

@include('crud::inc.field_translatable_icon')

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

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

    <table
        id="{{ $field['name'] }}"
        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;"
                        class="@if ($column['type'] == 'child_hidden') hidden @endif ">
                        {{ $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
                            @if ($column['type'] == 'child_hidden')
                                hidden
                            @endif
                            "
                        >

                    @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">
                    <button ng-hide="min > -1 && $index < min" class="btn btn-sm btn-danger" 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>
                <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>
            </tr>

        </tbody>

    </table>

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

</div>

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

{{-- ########################################## --}} {{-- 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 = {};

                        if (window['onAddChildItem'] != null) {
                            item = window['onAddChildItem'];
                        }

                        $scope.items.push(item);
                    } else {
                        new PNotify({
                            title: $scope.maxErrorTitle,
                            text: $scope.maxErrorMessage,
                            type: 'error'
                        });
                    }
                }
                else {
                    var item = {};

                    if (window['onAddChildItem'] != null) {
                        item = window['onAddChildItem'];
                    }

                    $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() {
                        if (window['initIntegerNumber'] != null) {
                            initIntegerNumber();
                        }
                        if (window['initDatePicker'] != null) {
                            initDatePicker();
                        }
                        if (window['initTime'] != null) {
                            initTime();
                        }

                        $('.select2_field').each(function (i, obj) {
                            if (!$(obj).hasClass("select2-hidden-accessible"))
                            {
                                $(obj).select2({
                                    theme: "bootstrap"
                                });
                            }
                        });

                    });
                }
            }
        });
        window.angularApp.directive('convertToNumber', function() {
            return {
              require: 'ngModel',
              link: function(scope, element, attrs, ngModel) {
                ngModel.$parsers.push(function(val) {
                    return parseInt(val, 10);
                });
                ngModel.$formatters.push(function(val) {
                    return '' + val +  '';
                });
              }
            };
        });
        window.angularApp.directive('convertToInteger', function() {
            return {
              require: 'ngModel',
              link: function(scope, element, attrs, ngModel) {
                ngModel.$parsers.push(function(val) {
                    return val.replace('.', '');
                });
                ngModel.$formatters.push(function(val) {
                    var aux = '' + val + '';
                    return aux.replace('.', '');
                });
              }
            };
        });
        window.angularApp.directive('convertToDate', function() {
            return {
              require: 'ngModel',
              link: function(scope, element, attrs, ngModel) {
                ngModel.$parsers.push(function(val) {
                   return val; //val.substr(0, 4) + '-' + val.substr(5, 2) + '-' + val.substr(8, 2);
                });
                ngModel.$formatters.push(function(val) {
                    if (val != null && val != "") {
                        return val.substr(8, 2) + '/' + val.substr(5, 2) + '/' + val.substr(0, 4);
                    } else {
                        return "";
                    }

                });
              }
            };
        });

        window.angularApp.directive('convertToFloat', function() {
              return {
                require: 'ngModel',
                link: function(scope, element, attrs, ngModel) {
                  ngModel.$parsers.push(function(val) {
                    return val.replace('.', '').replace(',', '.');
                  });
                  ngModel.$formatters.push(function(val) {
                    return val.replace('.', ',');
                  });
                }
              };
        });

        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>

    @stack('child_after_scripts')

@endpush

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

child_date_picker.blade.php

<?php // if the column has been cast to Carbon or Date (using attribute casting) // get the value as a date string if (isset($field['value']) && ( $field['value'] instanceof \Carbon\Carbon || $field['value'] instanceof \Jenssegers\Date\Date )) { $field['value'] = $field['value']->format('Y-m-d'); }

$field_language = isset($field['date_picker_options']['language'])?$field['date_picker_options']['language']:\App::getLocale();

?>

<div @include('crud::inc.field_wrapper_attributes') >

@include('crud::inc.field_translatable_icon')
{{-- HINT --}}
@if (isset($field['hint']))
    <p class="help-block">{!! $field['hint'] !!}</p>
@endif

<?php $include_resource = true; if (isset($crud->child_resource_included)) { if (is_array($crud->child_resource_included)) { if (in_array($crud->child_resource_included, 'child_date_picker')) { if ($crud->child_resource_included['child_date_picker']) { $include_resource = false; } } } } ?> @if ($include_resource)

{{-- FIELD CSS - will be loaded in the after_styles section --}}
@push('crud_fields_styles')
<link rel="stylesheet" href="{{ asset('vendor/adminlte/plugins/datepicker/datepicker3.css') }}">
@endpush

{{-- FIELD JS - will be loaded in the after_scripts section --}}
@push('crud_fields_scripts')
<script src="{{ asset('vendor/adminlte/plugins/datepicker/bootstrap-datepicker.js') }}"></script>
@if ($field_language !== 'en')
    <script charset="UTF-8" src="{{ asset('vendor/adminlte/plugins/datepicker/locales/bootstrap-datepicker.'.$field_language.'.js') }}"></script>
@endif
<script>
    if (jQuery.ui) {
        var datepicker = $.fn.datepicker.noConflict();
        $.fn.bootstrapDP = datepicker;
    } else {
        $.fn.bootstrapDP = $.fn.datepicker;
    }

    var dateFormat=function(){var a=/d{1,4}|m{1,4}|yy(?:yy)?|([HhMsTt])\1?|[LloSZ]|"[^"]*"|'[^']*'/g,b=/\b(?:[PMCEA][SDP]T|(?:Pacific|Mountain|Central|Eastern|Atlantic) (?:Standard|Daylight|Prevailing) Time|(?:GMT|UTC)(?:[-+]\d{4})?)\b/g,c=/[^-+\dA-Z]/g,d=function(a,b){for(a=String(a),b=b||2;a.length<b;)a="0"+a;return a};return function(e,f,g){var h=dateFormat;if(1!=arguments.length||"[object String]"!=Object.prototype.toString.call(e)||/\d/.test(e)||(f=e,e=void 0),e=e?new Date(e):new Date,isNaN(e))throw SyntaxError("invalid date");f=String(h.masks[f]||f||h.masks.default),"UTC:"==f.slice(0,4)&&(f=f.slice(4),g=!0);var i=g?"getUTC":"get",j=e[i+"Date"](),k=e[i+"Day"](),l=e[i+"Month"](),m=e[i+"FullYear"](),n=e[i+"Hours"](),o=e[i+"Minutes"](),p=e[i+"Seconds"](),q=e[i+"Milliseconds"](),r=g?0:e.getTimezoneOffset(),s={d:j,dd:d(j),ddd:h.i18n.dayNames[k],dddd:h.i18n.dayNames[k+7],m:l+1,mm:d(l+1),mmm:h.i18n.monthNames[l],mmmm:h.i18n.monthNames[l+12],yy:String(m).slice(2),yyyy:m,h:n%12||12,hh:d(n%12||12),H:n,HH:d(n),M:o,MM:d(o),s:p,ss:d(p),l:d(q,3),L:d(q>99?Math.round(q/10):q),t:n<12?"a":"p",tt:n<12?"am":"pm",T:n<12?"A":"P",TT:n<12?"AM":"PM",Z:g?"UTC":(String(e).match(b)||[""]).pop().replace(c,""),o:(r>0?"-":"+")+d(100*Math.floor(Math.abs(r)/60)+Math.abs(r)%60,4),S:["th","st","nd","rd"][j%10>3?0:(j%100-j%10!=10)*j%10]};return f.replace(a,function(a){return a in s?s[a]:a.slice(1,a.length-1)})}}();dateFormat.masks={default:"ddd mmm dd yyyy HH:MM:ss",shortDate:"m/d/yy",mediumDate:"mmm d, yyyy",longDate:"mmmm d, yyyy",fullDate:"dddd, mmmm d, yyyy",shortTime:"h:MM TT",mediumTime:"h:MM:ss TT",longTime:"h:MM:ss TT Z",isoDate:"yyyy-mm-dd",isoTime:"HH:MM:ss",isoDateTime:"yyyy-mm-dd'T'HH:MM:ss",isoUtcDateTime:"UTC:yyyy-mm-dd'T'HH:MM:ss'Z'"},dateFormat.i18n={dayNames:["Sun","Mon","Tue","Wed","Thu","Fri","Sat","Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],monthNames:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec","January","February","March","April","May","June","July","August","September","October","November","December"]},Date.prototype.format=function(a,b){return dateFormat(this,a,b)};

    jQuery(document).ready(function($){
        initDatePicker();
    });

    function initDatePicker() {
        $('[data-bs-datepicker]').each(function(){
            var $fake = $(this),
            $field = $fake.parents('.datepicker-box').find('input[type="hidden"]'),
            $customConfig = $.extend({
                format: 'dd/mm/yyyy'
            }, $fake.data('bs-datepicker'));

            var $existingVal = $field.val();

            $picker = $fake.bootstrapDP($customConfig);

            if( $existingVal != null && $existingVal.length ){
                // Passing an ISO-8601 date string (YYYY-MM-DD) to the Date constructor results in
                // varying behavior across browsers. Splitting and passing in parts of the date
                // manually gives us more defined behavior.
                // See https://stackoverflow.com/questions/2587345/why-does-date-parse-give-incorrect-results
                var parts = $existingVal.split('-')
                var year = parts[0]
                var month = parts[1] - 1 // Date constructor expects a zero-indexed month
                var day = parts[2]
                preparedDate = new Date(year, month, day).format($customConfig.format);
                $fake.val(preparedDate);
                $picker.bootstrapDP('update', preparedDate);
            }

            $fake.on('keydown', function(e){
                e.preventDefault();
                return false;
            });

            $picker.on('show hide change', function(e){
                if( e.date ){
                    var sqlDate = e.format('yyyy-mm-dd');
                } else {
                    try {
                        var sqlDate = $fake.val();

                        if( $customConfig.format === 'dd/mm/yyyy' ){
                            sqlDate = new Date(sqlDate.split('/')[2], sqlDate.split('/')[1] - 1, sqlDate.split('/')[0]).format('yyyy-mm-dd');
                        }
                    } catch(e){
                        if( $fake.val() ){
                            PNotify.removeAll();
                            new PNotify({
                                title: 'Whoops!',
                                text: 'Sorry we did not recognise that date format, please make sure it uses a yyyy mm dd combination',
                                type: 'error',
                                icon: false
                            });
                        }
                    }
                }

                $field.val(sqlDate);
            });
        });

    }
</script>
@endpush

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

get the

tabacitu commented 6 years ago

Hi @Gorbas ,

As @fabriciolangermt mentioned, there's currently no quick way to do this, other than creating a custom field type. @fabriciolangermt 's code should help you get started with that.

We do plan on making this A LOT easier in the future, we call it the matrix field, and it should allow you to use ANY type of field inside a table. But we're quite far away from achieving this, some core changes need to be made in order for that to be possible. There are plenty of issues that have not been resolved (ex: how to retrigger field JS after validation, how to show fields without labels in the table, etc). I'll keep you guys updated on this in the newsletter - it will definitely be time to celebrate when we implement this :-) It's my favorite big feature.

Thanks, cheers!