Магазин на Laravel 7, часть 19. Панель управления, добавляем редактор для страниц сайта

29.10.2020

Теги: AJAXFormDataJavaScriptjQueryJSONLaravelMySQLPHPWeb-разработкаИнтернетМагазинКаталогТоваровПрактикаРедакторФреймворк

Возможность добавлять и редактировать страницы сайта у нас теперь есть, но не хватает wysiwyg-редактора. Будем использовать summernote — простой, легкий и есть возможность вставлять видео и изображения. Но самое главное — можно навесить свои обработчики события добавления и удаления изображений. А это означает, что можно организовать систему автозагрузки и автоудаления изображений на сервере.

Установка wysiwyg-редактора

Тут все просто — в layout-шаблоне добавляем два файла js-скриптов и один css-файл. Даже не будем их скачивать, а загрузим из внешнего источника.

<head>
    <meta charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>{{ $title ?? 'Панель управления' }}</title>
    <link rel="stylesheet" href="{{ asset('css/app.css') }}">
    <link rel="stylesheet" href="https://pro.fontawesome.com/releases/v5.10.0/css/all.css"/>
    <!-- один css-файл -->
    <link href="https://cdn.jsdelivr.net/npm/summernote@0.8.18/dist/summernote-bs4.min.css" rel="stylesheet">
    <link rel="stylesheet" href="{{ asset('css/admin.css') }}">
    <script src="{{ asset('js/app.js') }}"></script>
    <!-- два js-скрипта -->
    <script src="https://cdn.jsdelivr.net/npm/summernote@0.8.18/dist/summernote-bs4.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/summernote@0.8.18/dist/lang/summernote-ru-RU.min.js"></script>
    <script src="{{ asset('js/admin.js') }}"></script>
</head>

В шаблоне формы для добавления-редактирования страницы сайта для <textarea> зададим идентификатор id="editor".

<div class="form-group">
    <textarea class="form-control" name="content" placeholder="Контент (html)" required
              id="editor" rows="10">{{ old('content') ?? $page->content ?? '' }}</textarea>
</div>

В файле admin.js подключим wysiwyg-редактор для <textarea> с уникальным идентификатором id="editor".

jQuery(document).ready(function($) {
    /*
     * Автоматическое создание slug при вводе name (замена кириллицы на латиницу)
     */
    $('input[name="name"]').on('input', function() {
        /* ... */
    });
    /*
     * Подключение wysiwyg-редактора для редактирования контента страницы
     */
    $('textarea[id="editor"]').summernote({
        lang: 'ru-RU',
        height: 300,
    });
});

Загрузка изображений

По умолчанию редактор summernote сохраняет изображение прямо в атрибуте src тега <img>:

<p>
Lorem ipsum dolor sit amet, consectetur adipisicing elit. Animi assumenda blanditiis consequatur cum cupiditate ea
facere, facilis fuga fugit ipsum itaque laboriosam, laudantium nemo, nulla odio placeat quas recusandae repellat
repudiandae sint unde ut vitae voluptas voluptate voluptatem. Amet assumenda dolorum enim iusto odit quis similique.
</p>
<p>
<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAIAAAD/gAIDAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5
ccllPAAAAyJpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4
..........
ciI/Pv3k9zcAAACiSURBVHja7NAxAQAACAMgtX/nWcHPByLQKa5GgSxZsmTJkqVAlixZsmTJUiBLlixZsmQpkCVLlixZshTIkiVLlixZCmTJkiVLliwFsmTJ
kiVLlgJZsmTJkiVLgSxZsmTJkqVAlixZsmTJUiBLlixZsmQpkCVLlixZshTIkiVLlixZCmTJkiVLliwFsmTJkiVLlgJZsmTJkiVLgSxZ31YAAQYAil4Bx7aJ
z7QAAAAASUVORK5CYII=" data-filename="test.png" style="width: 100px;">
</p>
<p>
Lorem ipsum dolor sit amet, consectetur adipisicing elit. Animi assumenda blanditiis consequatur cum cupiditate ea
facere, facilis fuga fugit ipsum itaque laboriosam, laudantium nemo, nulla odio placeat quas recusandae repellat
repudiandae sint unde ut vitae voluptas voluptate voluptatem. Amet assumenda dolorum enim iusto odit quis similique.
</p>

Но во-первых, в этом случае неудобно работать в редакторе, если переключиться в режим редактирования кода. А во-вторых, не хотелось бы хранить изображения в базе данных, раздувая ее без необходимости.

Первый способ

При сохранении страницы будем вызывать метод saveImages(), который проанализирует html-код, найдет в нем изображения и сохранит их на диск. И заменит значения атрибутов src всех изображений c base64-строк на ссылки.

class PageController extends Controller {
    /**
     * Сохраняет новую страницу в базу данных
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Http\Response
     */
    public function store(Request $request) {
        $this->validate($request, [
            'name' => 'required|max:100',
            'parent_id' => 'required|regex:~^[0-9]+$~',
            'slug' => 'required|max:100|unique:pages|regex:~^[-_a-z0-9]+$~i',
            'content' => 'required',
        ]);
        $content = $this->saveImages($request->input('content'));
        $data = $request->all();
        $data['content'] = $content;
        $page = Page::create($data);
        return redirect()
            ->route('admin.page.show', ['page' => $page->id])
            ->with('success', 'Новая страница успешно создана');
    }

    /**
     * Обновляет страницу (запись в таблице БД)
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \App\Models\Page  $page
     * @return \Illuminate\Http\Response
     */
    public function update(Request $request, Page $page) {
        $this->validate($request, [
            'name' => 'required|max:100',
            'parent_id' => 'required|regex:~^[0-9]+$~|not_in:'.$page->id,
            'slug' => 'required|max:100|unique:pages,slug,'.$page->id.',id|regex:~^[-_a-z0-9]+$~i',
            'content' => 'required',
        ]);
        $content = $this->saveImages($request->input('content'));
        $data = $request->all();
        $data['content'] = $content;
        $page->update($data);
        return redirect()
            ->route('admin.page.show', ['page' => $page->id])
            ->with('success', 'Страница была успешно отредактирована');
    }

    /**
     * Сохраняет на диск изображения и заменяет атрибут src тегов img
     * <img src="data:image/png;base64,R0lGODlhEAAOALDD..." alt="" />
     * <img src="http://server.com/storage/page/123456.png" alt="" />
     *
     * @param $content
     * @return string
     */
    private function saveImages($content) {
        $dom = new \DomDocument('1.0', 'UTF-8');
        // loadHTML() считает, что строка в кодировке ISO-8859-1,
        // поэтому указываем явно, что строка в кодировке UTF-8
        $html = '<!DOCTYPE html><html><head><meta charset="UTF-8"/></head>';
        $html = $html . '<body>'.$content.'</body></html>';
        $dom->loadHtml($html);
        $images = $dom->getElementsByTagName('img');
        foreach ($images as $img) {
            $data = $img->getAttribute('src');
            if (strpos($data, 'data') === false) {
                continue;
            }
            // <img src="data:image/jpeg;base64,R0lGODlhEAAOAL..." alt="" />
            // data:image/jpeg;base64, data:image/png;base64, data:image/gif
            list($type, $data) = explode(';', $data);
            list(, $ext) = explode('/', $type);
            list(, $data) = explode(',', $data);
            $data = base64_decode($data);
            $name = md5(uniqid(rand(), true)) . '.' . $ext;
            Storage::disk('public')->put('page/' . $name, $data);
            $url = Storage::disk('public')->url('page/' . $name);
            $img->removeAttribute('data-filename');
            $img->removeAttribute('src');
            $img->setAttribute('src', $url);
        }
        $content = html_entity_decode($dom->saveXML($dom->documentElement));
        $content = str_replace(
            [
                '<html><head><meta charset="UTF-8"/></head><body>',
                '</body></html>',
            ],
            '',
            $content
        );
        $content = trim($content);
        return $content;
    }
}

Кроме того, при удалении страницы нам опять нужно проанализировать html-код, чтобы найти в нем изображения и удалить их с диска.

class PageController extends Controller {
    /**
     * Удаляет изображения, которые связаны со страницей
     *
     * @param  string $content
     * @return void
     */
    private function removeImages($content) {
        $dom = new \DomDocument();
        $dom->loadHtml($content);
        $images = $dom->getElementsByTagName('img');
        foreach ($images as $img) {
            $src = $img->getAttribute('src');
            $pattern = '~/storage/page/([0-9a-z]+\.(jpeg|png|gif))~i';
            if (preg_match($pattern, $src, $match)) {
                $name = $match[1];
                if (Storage::disk('public')->exists('page/' . $name)) {
                    Storage::disk('public')->delete('page/' . $name);
                }
            }
        }
    }

    /**
     * Удаляет страницу (запись в таблице БД)
     *
     * @param  \App\Models\Page  $page
     * @return \Illuminate\Http\Response
     */
    public function destroy(Page $page) {
        if ($page->children->count()) {
            return back()->withErrors('Нельзя удалить страницу, у которой есть дочерние');
        }
        $this->removeImages($page->content);
        $page->delete();
        return redirect()
            ->route('admin.page.index')
            ->with('success', 'Страница сайта успешно удалена');
    }
}

Второй способ

Первый способ мне не очень нравится, потому что с DomDocument (неожиданно) возникли трудности. Там чехарда с кодировкой строки html-кода при использовании метода loadHTML() и проблемы при использовании метода saveHTML(). Проблема, как оказалось, застарелая — в интернете полным-полно рецептов, как это побороть с помощью тех или иных хаков.

К счастью, редактор summernote предоставляет возможность навесить свои обработчики на события вставки и удаления изображений.

jQuery(document).ready(function($) {
    /*
     * Общие настройки ajax-запросов, отправка на сервер csrf-токена
     */
    $.ajaxSetup({
        headers: {
            'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content')
        }
    });
    /*
     * Автоматическое создание slug при вводе name (замена кириллицы на латиницу)
     */
    $('input[name="name"]').on('input', function() {
        /* ... */
    });
    /*
     * Подключение wysiwyg-редактора для редактирования контента страницы
     */
    $('textarea[id="editor"]').summernote({
        lang: 'ru-RU',
        height: 300,
        callbacks: {
            /*
             * При вставке изображения загружаем его на сервер
             */
            onImageUpload: function(images) {
                for (var i = 0; i < images.length; i++) {
                    uploadImage(images[i], this);
                }
            },
            /*
             * При удалении изображения удаляем его на сервере
             */
            onMediaDelete: function(target) {
                removeImage(target[0].src);
            }
        }
    });
    /*
     * Загружает на сервер вставленное в редакторе изображение
     */
    function uploadImage(image, textarea) {
        var data = new FormData();
        data.append('image', image);
        $.ajax({
            data: data,
            type: 'POST',
            url: '/admin/page/upload/image',
            cache: false,
            contentType: false,
            processData: false,
            success: function(url) {
                // $(textarea).summernote('insertImage', url);
                $(textarea).summernote('insertImage', url, function ($img) {
                    $img.css('max-width', '100%');
                });
            }
        });
    }
    /*
     * Удаляет на сервере удаленное в редакторе изображение
     */
    function removeImage(src) {
        $.ajax({
            data: {'image': src, '_method': 'DELETE'},
            type: 'POST',
            url: '/admin/page/remove/image',
            cache: false,
            success: function(msg) {
                // console.log(msg);
            }
        });
    }
});
Не совсем очевидно, как удалить изображение, чтобы вызвать событие. Если кликнуть по изображению — появится всплывающая менюшка. И уже в этой менюшке надо кликнуть по иконке корзины.

Обратите внимание, что мы делаем общие настройки для всех ajax-запросов, отправляя с каждым запросом csrf-токен. Без этого нам бы пришлось при каждой отправке ajax-запроса добавлять этот токен самостоятельно, примерно так:

var data = new FormData();
data.append('image', image);
data.append('_token', $('meta[name="csrf-token"]').attr('content'));

Токен наш код получает из мета-тега внутри <head> страницы, только надо этот мета-тег добавить в layout-шаблон:

<head>
    <meta charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <meta name="csrf-token" content="{{ csrf_token() }}">
    <title>{{ $title ?? 'Панель управления' }}</title>
    <!-- .......... -->
</head>

Теперь добавляем два новых маршрута в файл web.php:

Route::group([
    'as' => 'admin.', // имя маршрута, например admin.index
    'prefix' => 'admin', // префикс маршрута, например admin/index
    'namespace' => 'Admin', // пространство имен контроллера
    'middleware' => ['auth', 'admin'] // один или несколько посредников
], function () {
    // главная страница панели управления
    Route::get('index', 'IndexController')->name('index');
    // CRUD-операции над категориями каталога
    Route::resource('category', 'CategoryController');
    // CRUD-операции над брендами каталога
    Route::resource('brand', 'BrandController');
    // CRUD-операции над товарами каталога
    Route::resource('product', 'ProductController');
    // доп.маршрут для показа товаров категории
    Route::get('product/category/{category}', 'ProductController@category')
        ->name('product.category');
    // просмотр и редактирование заказов
    Route::resource('order', 'OrderController', ['except' => [
        'create', 'store', 'destroy'
    ]]);
    // просмотр и редактирование пользователей
    Route::resource('user', 'UserController', ['except' => [
        'create', 'store', 'show', 'destroy'
    ]]);
    // CRUD-операции над страницами сайта
    Route::resource('page', 'PageController');
    // загрузка изображения из редактора
    Route::post('page/upload/image', 'PageController@uploadImage')
        ->name('page.upload.image');
    // удаление изображения в редакторе
    Route::delete('page/remove/image', 'PageController@removeImage')
        ->name('page.remove.image');
});

Осталось только добавить методы uploadImage() и removeImage() в контроллер:

class PageController extends Controller {
    /**
     * Загружает изображение, которое было добавлено в wysiwyg-редакторе и
     * возвращает ссылку на него, чтобы в редакторе вставить <img src="…"/>
     *
     * @param  \Illuminate\Http\Request  $request
     * @return string
     */
    public function uploadImage(Request $request) {
        $path = $request->file('image')->store('page', 'public');
        return Storage::disk('public')->url($path);
    }

    /**
     * Удаляет изображение, которое было удалено в wysiwyg-редакторе
     *
     * @param  \Illuminate\Http\Request  $request
     * @return string
     */
    public function removeImage(Request $request) {
        // $path = /storage/page/CW2xtBYIcXDx7d3oJRCLZoZtIhaSFWAS8Qa7WFL3.png
        $path = parse_url($request->image, PHP_URL_PATH);
        $path = str_replace('/storage/', '', $path);
        if (Storage::disk('public')->exists($path)) {
            Storage::disk('public')->delete($path);
            return 'Изображение было удалено';
        }
        return 'Не удалось удалить изображение';
    }
}

Теперь метод saveImages() больше не нужен и его можно удалить. Также удаляем вызов этого метода из store() и update(). В итоге получился такой контроллер:

namespace App\Http\Controllers\Admin;

use App\Http\Controllers\Controller;
use App\Models\Page;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;

class PageController extends Controller {
    /**
     * Показывает список всех страниц
     *
     * @return \Illuminate\Http\Response
     */
    public function index() {
        $pages = Page::all();
        return view('admin.page.index', compact('pages'));
    }

    /**
     * Показывает форму для создания страницы
     *
     * @return \Illuminate\Http\Response
     */
    public function create() {
        $parents = Page::where('parent_id', 0)->get();
        return view('admin.page.create', compact('parents'));
    }

    /**
     * Сохраняет новую страницу в базу данных
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Http\Response
     */
    public function store(Request $request) {
        $this->validate($request, [
            'name' => 'required|max:100',
            'parent_id' => 'required|regex:~^[0-9]+$~',
            'slug' => 'required|max:100|unique:pages|regex:~^[-_a-z0-9]+$~i',
            'content' => 'required',
        ]);
        $page = Page::create($request->all());
        return redirect()
            ->route('admin.page.show', ['page' => $page->id])
            ->with('success', 'Новая страница успешно создана');
    }

    /**
     * Показывает информацию о странице сайта
     *
     * @param  \App\Models\Page  $page
     * @return \Illuminate\Http\Response
     */
    public function show(Page $page) {
        return view('admin.page.show', compact('page'));
    }

    /**
     * Показывает форму для редактирования страницы
     *
     * @param  \App\Models\Page  $page
     * @return \Illuminate\Http\Response
     */
    public function edit(Page $page) {
        $parents = Page::where('parent_id', 0)->get();
        return view('admin.page.edit', compact('page', 'parents'));
    }

    /**
     * Обновляет страницу (запись в таблице БД)
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \App\Models\Page  $page
     * @return \Illuminate\Http\Response
     */
    public function update(Request $request, Page $page) {
        $this->validate($request, [
            'name' => 'required|max:100',
            'parent_id' => 'required|regex:~^[0-9]+$~|not_in:'.$page->id,
            'slug' => 'required|max:100|unique:pages,slug,'.$page->id.',id|regex:~^[-_a-z0-9]+$~i',
            'content' => 'required',
        ]);
        $page->update($request->all());
        return redirect()
            ->route('admin.page.show', ['page' => $page->id])
            ->with('success', 'Страница была успешно отредактирована');
    }

    /**
     * Загружает изображение, которое было добавлено в wysiwyg-редакторе и
     * возвращает ссылку на него, чтобы в редакторе вставить <img src="…"/>
     *
     * @param  \Illuminate\Http\Request  $request
     * @return string
     */
    public function uploadImage(Request $request) {
        $path = $request->file('image')->store('page', 'public');
        return Storage::disk('public')->url($path);
    }

    /**
     * Удаляет изображение, которое было удалено в wysiwyg-редакторе
     *
     * @param  \Illuminate\Http\Request  $request
     * @return string
     */
    public function removeImage(Request $request) {
        // $path = /storage/page/CW2xtBYIcXDx7d3oJRCLZoZtIhaSFWAS8Qa7WFL3.png
        $path = parse_url($request->image, PHP_URL_PATH);
        $path = str_replace('/storage/', '', $path);
        if (Storage::disk('public')->exists($path)) {
            Storage::disk('public')->delete($path);
            return 'Изображение было удалено';
        }
        return 'Не удалось удалить изображение';
    }

    /**
     * Удаляет изображения, которые связаны со страницей
     *
     * @param  string $content
     * @return void
     */
    private function removeImages($content) {
        $dom = new \DomDocument();
        $dom->loadHtml($content);
        $images = $dom->getElementsByTagName('img');
        foreach ($images as $img) {
            $src = $img->getAttribute('src');
            $pattern = '~/storage/page/([0-9a-f]{32}\.(jpeg|png|gif))~';
            if (preg_match($pattern, $src, $match)) {
                $name = $match[1];
                if (Storage::disk('public')->exists('page/' . $name)) {
                    Storage::disk('public')->delete('page/' . $name);
                }
            }
        }
    }

    /**
     * Удаляет страницу (запись в таблице БД)
     *
     * @param  \App\Models\Page  $page
     * @return \Illuminate\Http\Response
     */
    public function destroy(Page $page) {
        if ($page->children->count()) {
            return back()->withErrors('Нельзя удалить страницу, у которой есть дочерние');
        }
        $this->removeImages($page->content);
        $page->delete();
        return redirect()
            ->route('admin.page.index')
            ->with('success', 'Страница сайта успешно удалена');
    }
}

Проверка изображения

Метод uploadImage() просто сохраняет файл изображения без всяких проверок, что не очень хорошо. Хотя изображения будет загружать администратор, все-таки добавим валидацию, что это именно изображение и что размер не очень большой.

class PageController extends Controller {
    /**
     * Загружает изображение, которое было добавлено в wysiwyg-редакторе и
     * возвращает ссылку на него, чтобы в редакторе вставить <img src="…"/>
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Http\Response
     */
    public function uploadImage(Request $request) {
        $validator = Validator::make($request->all(), ['image' => [
            'mimes:jpeg,jpg,png',
            'max:5000' // 5 Мбайт
        ]]);
        if ($validator->passes()) {
            $path = $request->file('image')->store('page', 'public');
            $url = Storage::disk('public')->url($path);
            return response()->json(['image' => $url]);
        }
        return response()->json(['errors' => $validator->errors()->all()]);
    }
}
/*
 * Загружает на сервер вставленное в редакторе изображение
 */
function uploadImage(image, textarea) {
    var data = new FormData();
    data.append('image', image);
    $.ajax({
        data: data,
        type: 'POST',
        url: '/admin/page/upload/image',
        cache: false,
        contentType: false,
        processData: false,
        dataType: 'json',
        success: function(data) {
            if (data.errors === undefined) {
                $(textarea).summernote('insertImage', data.image, function ($img) {
                    $img.css('max-width', '100%');
                });
            } else {
                $.each(data.errors, function (key, value) {
                    alert(value);
                });
            }
        },
    });
}

В случае успеха или неудачи мы отправляем клиенту JSON-ответ разного содержания:

{
    "errors": [
            "Поле image должно быть файлом одного из следующих типов: jpeg, png.",
            "Размер файла в поле image не может быть больше 1 Килобайт(а)."
        ]
}
{
    "image": "http://www.host21.ru/storage/page/xfDOpOlUe2J0G4yNe1zhWre0jTYnDq6O3x2adETB.png"
}

Сообщение об ошибке при попытке загрузить изображение в gif-формате:

Сообщение об ошибке при попытке загрузить слишком большое изображение:

Мы сделали проверку сами, но документация Laravel говорит по этому поводу:

При использовании метода validate() во время AJAX-запроса Laravel не будет генерировать ответ перенаправления (редирект). Вместо этого Laravel генерирует ответ в формате JSON, содержащий все ошибки валидации. Этот JSON-ответ будет отправлен с кодом состояния 422 HTTP.

Так что наш код метода uploadImage() почти ничем не будет отличаться от кода, который обрабатывает обычный запрос:

class PageController extends Controller {
    /**
     * Загружает изображение, которое было добавлено в wysiwyg-редакторе и
     * возвращает ссылку на него, чтобы в редакторе вставить <img src="…"/>
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Http\Response
     */
    public function uploadImage(Request $request) {
        $this->validate($request, ['image' => [
            'mimes:jpeg,jpg,png',
            'max:5000' // 5 Мбайт
        ]]);
        $path = $request->file('image')->store('page', 'public');
        $url = Storage::disk('public')->url($path);
        return response()->json(['image' => $url]);
    }
}

Вот такой ответ сформирует фреймворк в случае ошибки:

{
    "message": "The given data was invalid.",
    "errors": {
        "image": [
            "Поле image должно быть файлом одного из следующих типов: jpeg, png.",
            "Размер файла в поле image не может быть больше 1 Килобайт(а)."
        ]
    }
}

А вот такой ответ сформируем мы, если ошибки не было:

{
    "image": "http://www.host21.ru/storage/page/xfDOpOlUe2J0G4yNe1zhWre0jTYnDq6O3x2adETB.png"
}

На клиенте будем проверять — успешно выполнился запрос или с ошибками:

/*
 * Загружает на сервер вставленное в редакторе изображение
 */
function uploadImage(image, textarea) {
    var data = new FormData();
    data.append('image', image);
    $.ajax({
        data: data,
        type: 'POST',
        url: '/admin/page/upload/image',
        cache: false,
        contentType: false,
        processData: false,
        dataType: 'json',
        success: function(data) {
            $(textarea).summernote('insertImage', data.image, function ($img) {
                $img.css('max-width', '100%');
            });
        },
        error: function (reject) {
            $.each(reject.responseJSON.errors, function (key, value) {
                alert(value);
            });
        }
    });
}

Поиск: AJAX • FormData • JavaScript • jQuery • JSON • Laravel • MySQL • PHP • Web-разработка • Интернет магазин • Каталог товаров • Практика • Редактор • Фреймворк

Каталог оборудования
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Производители
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Функциональные группы
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.