Создание CRUD интерфейса вне админки

Приветствую, в этой заметке хочу поделится кейсом по созданию простого интерфейса за пределами админки для управления различными объектами как нативными (пользователи, ресурсы, заказы), так и кастомными, т.е. созданными под конкретный проект. Видео с результатом

Задача.
Создать интерфейс для менеджеров, через который они смогу добавлять, изменять и удалять пользователей, а так же отправлять им приглашения на прохождение тестирования.

Компоненты.
  1. SendIt для отправки запросов и обработки ответов
  2. pdoTools для парсинга чанков
Подготовка.
Основной код будет располагаться в папке core/services/custom. Подключать наш сервис работы с пользователями будем с помощью composer. Для этого создадим в папке core/services файл composer.json со следующим содержимым
{
  "autoload": {
    "psr-4": {
      "CustomServices\\": "custom/"
    }
  }
}
Затем нужно запустить в терминале команду
composer dump-autoload
После выполнения этой команды должна появиться папка services/vendor. На этом подготовительная часть завершена.

Использовать composer необязательно, можно расположить код где угодно и подключать классы через reqiure, но использование composer видится мне более удобным и актуальным способом загрузки классов.

Основная идея.
SendIt умеет отправлять данные на сервер и обрабатывать ответы от него. Значит наша задача сводится к тому, чтобы сообщить серверу какую операцию мы хотим выполнить: добавить пользователя, найти, изменить или удалить. Для простоты, операции создания и изменения объединим. Далее создадим 4 пресета ( четвёртый нужен чтобы не дублировать общие параметры)
<?php

return [
    'user' => [
        'hooks' => '',
        'snippet' => '@FILE snippets/manage_users.php',
    ],
    'find_user' => [
        'extends' => 'user',
        'method' => 'findUser',
    ],
    'remove_user' => [
        'extends' => 'user',
        'method' => 'removeUser',
    ],
    'manage_user' => [
        'clearFieldsOnSuccess' => 1,
        'extends' => 'manage_users',
        'method' => 'manageUser',
        'successMessage' => 'Пользователь добавлен.',
        'validate' => 'fullname:required,email:required,phone:required',
    ],
];
Как видите все пресеты используют один сниппет, но разные методы. Код сниппета выглядит так
<?php
use CustomServices\Users;

require_once MODX_CORE_PATH . 'services/vendor/autoload.php';
/**
 * @var modX $modx
 * @var array $scriptProperties
 * @var SendIt $SendIt
 * @var string $method
 */
$service = new Users($modx);
$method = $scriptProperties['method'];
if(!method_exists($service, $method)){
    return $SendIt->error('Метод '.$method.' не найден', []);
}
$result = $service->$method($_POST, $scriptProperties);

if($SendIt){
    if($result['success']){
        return $SendIt->success($result['message'], $result['data']);
    }else{
        return $SendIt->error($result['message'], $result['data']);
    }
}
return $result;
В нём мы подключаем наш сервис работы с пользователем и выполняем переданный метод, в который передаём параметры запроса (массив $_POST) и параметры вызова ($scriptProperties). В зависимости от того как вызывается данный сниппет: в шаблоне или из JS, возвращаем разный результат. Таким образом мы получаем один универсальный сниппет, который можно вызывать как угодно.
В видео, которое выложено в начале заметки, не видно, но результаты всех вызовов возвращаются с разбивкой на страницы, т.е. работает постраничная навигация. Для этого в файле core/elements/templates/candidates.tpl есть вот такой вызов
<ul class="list-unstyled border-end border-start border-1 border-secondary" data-pn-result>
    {'!Pagination' | snippet: [
    'snippet' => '!Pagination',
    'render' => '@FILE snippets/manage_users.php',
    'presetName' => 'pagination-search',
    'tpl' => '@FILE chunks/get_candidates/item.tpl',
    'tplEmpty' => '@INLINE <li class="row align-items-center border-bottom border-1 border-secondary mx-0 py-3">Кандидатов не найдено.</li>',
    'limit' => 10,
    'pagination' => 'candidates',
    'resultBlockSelector' => '[data-pn-result]',
    'resultShowMethod' => 'insert',
    'method' => 'getListCandidates',
    'hashParams' => 'query',
    ]}
</ul>

<!-- PAGINATION -->
{set $totalPages = 'candidates.totalPages' | placeholder}
{set $currentPage = 'candidates.currentPage' | placeholder}
{set $limit = 'candidates.limit' | placeholder}

<div data-pn-pagination="candidates" data-pn-type="" class="{$totalPages < 2 ? 'v_hidden' : ''} pagination">
    <button type="button" data-pn-first="1">‹‹</button>
    <button type="button" data-pn-prev="">‹</button>
    <input type="number" name="candidatespage" data-pn-current data-si-preset="pagination-search" form="searchForm" min="1" max="{$totalPages}"
           value="{$currentPage?:1}">
    <p>из
        <span data-pn-total="">{$totalPages?:1}</span>
    </p>
    <button type="button" data-pn-next="">›</button>
    <button type="button" data-pn-last="{$totalPages}">››</button>
</div>
Как видите в этом вызове так же происходит обращение к нашему универсальному сниппету manage_users.php.
А чтобы можно было искать пользователей, я добавил форму поиска
<form class="row" id="searchForm" data-si-form data-si-nosave data-si-preset="pagination-search">
    <div class="col-6">
        <input type="text" class="form-control" name="query" placeholder="ФИО/Email">
    </div>
    <div class="col-3">
        <button type="submit" class="btn btn-primary w-100 d-block">Найти</button>
    </div>
    <div class="col-3">
        <button type="reset" class="btn btn-outline-primary w-100 d-block" data-si-event="click">Сбросить</button>
    </div>
</form>
ВАЖНО!!! Она использует тот же пресет, что и вызов сниппета Pagination, и связана с поле ввода номера текущей страницы с помощью атрибута form и собственного id.

Таким образом основная идея состоит в том, чтобы писать код в одном классе-сервисе и обращаться к его методам через один универсальный сниппет. Логика разделения сервисов у каждого своя, главное не терять здравый смысл.

Дополнительная информация.
Весь код выложен в открытом репозитории. Кроме серверной части, там есть и немного стилей и JavaSrcipt. В части JavaScript следут обратить внимание на метод project.updatePagination() -обновляющий состояние пагинации после поиска поьзователя, и на метод project.insertData(e.target.closest('[data-insert]')), который обеспечивает вставку данных пользователя в скрытые поля для передачи на сервер.

Заключение.
На мой взгляд данный кейс легко экстрапалируется на любые объект, поэтому пользуйтесь, не стесняйтесь. Если есть вопросы или замечания, милости прошу в комментарии.

Всем щедрых заказчиков и качественного кода. Спасибо за внимание.
Артур Шевченко
26 октября 2024, 21:25
modx.pro
2
467
+7
Поблагодарить автора Отправить деньги

Комментарии: 7

Дима Касаткин
28 октября 2024, 01:38
0
Пользуюсь похожей конструкцией, чтобы редактировать TV-шки с фронта. Отличный пример допиливания полезного функционала под проект, спасибо!

P.S. Поправь плиз отступы в форматировании кода во 3 и 4 блоках, а то я shift+tab чуть не нажал машинально, когда читал :)
Ivan K.
01 ноября 2024, 12:53
0
Здравствуйте.
Отличное и нужное решение. Обязательно воспользуюсь.
Но хотел уточнить не возникнет ли проблем при использовании MODX3 + php8.1?
И в плане безопасности можно ли использовать в «личном кабинете пользователя» (простые юзеры, которые залогинены в контексте web) данное решение? хотел кастомную таблицу подключить, чтобы юзеры меняли в ней данные.
    Александр Туниеков
    04 ноября 2024, 02:25
    0
    Тоже интересно что с безопасностью. В коде на гитхабе никаких проверок на доступ. Возможно безопасность обеспечивает SendIt, но автор вообще не указал как SendIt подключается и используется. Наверно в SendIt подключаются прессеты??? В общем, для человека не знакомого с SendIt, как мне, статья вообще не понятная.

    Безопасность может быть реализованна таким образом. Сниппет, типа AjaxForm, при обработки страницы, записывает в сессию хеш-код. js в браузере отправляет с формой этот хеш-код. Обработчик ajax запросов на сервере смотрит есть ли в сесии этот хеш-код и если его нет, то возращяет ошибку.
    Таким образом, если сниппет не был вызван при обработке страницы, то все ajax запросы завершаться ошибкой. Будут приняты только те запросы с правильным хеш-кодом.
    А на ресурс MODX можно поставить ограничения и показывать его только разрешенным пользователям. Соответственно на странице на запрещенном ресурсе сниппет не отработал, хеш-код в сессию не записался и ajax запросы не срабатывают.

    В общем не знаю, как безопасность реализованна в SendIt, но наверно что-то подобное. Надеюсь @Артур Шевченко просветит нас :-)
    Александр Туниеков
    04 ноября 2024, 03:47
    +1
    Кстати GRUD в getTables реализован и можно его использовать для подобной задачи :-). У пользователей MODX 2 таблицы и не так просто. Но можно попробовать.

    На странице на которую ограничен доступ пишем сниппет:
    {'!getTable' | snippet : [
        'table'=>[
            'event'=>1,
            'class'=>'modUser',
            'actions'=>[
                'create'=>[],
                'update'=>[],
                'remove'=>[],
                'export_excel'=>[],
            ],
            'pdoTools'=>[
                'class'=>'modUser',
                'leftJoin'=>[
                    'modUserProfile'=>[
                        'class'=>'modUserProfile',
                        'on'=>'modUserProfile.internalKey = modUser.id'
                    ]
                ],
                'select'=>[
                    'modUser'=>'modUser.id',
                    'modUserProfile'=>'modUserProfile.fullname,modUserProfile.email,modUserProfile.phone',
                ],
                'sortby'=>[
                    'modUser.id'=>'DESC'
                ]
            ],
            'row'=>[
                'id'=>['filter'=>1],
                'password'=>[
                    'edit'=>['type'=>'hidden'],
                    'default'=>'Не секретный пароль'
                ],
                'fullname'=>[
                    'class'=>'modUserProfile',
                    'label'=>'ФИО',
                    'edit'=>['type'=>'text','search_fields'=>['internalKey'=>'id'],],
                    'filter'=>1,
                ],
                'email'=>[
                    'class'=>'modUserProfile',
                    'label'=>'email',
                    'edit'=>['type'=>'text','search_fields'=>['internalKey'=>'id'],],
                    'filter'=>1,
                ],
                'phone'=>[
                    'class'=>'modUserProfile',
                    'label'=>'Телефон',
                    'edit'=>['type'=>'text','search_fields'=>['internalKey'=>'id'],],
                    'filter'=>1,
                ],
            ]
        ]
    ]}
    Пишем плагин на событие getTablesAfterUpdateCreate. Для работы плагина в сниппете должно быть указано 'event'=>1, как выше.
    <?php
    switch ($modx->event->name) {
        case 'getTablesAfterUpdateCreate':
            // $modx->log(1,'getTablesAfterUpdateCreate '.print_r($data,1));
            if($data['table_name'] == 'modUser' and $data['gts_action'] == 'getTable/create'){
                if($modUser = $modx->getObject('modUser',(int)$data['id'])){
                    $modUser->set('username',$data['email']);
                    $modUser->set('password',md5(date('d.m.Y H:i:s')));
                    $modUser->joinGroup(2);
                    $modUser->save();
                }
            }
        break;
    }
    И все работает :-).


    Так же можно это же сделать с PVTables, но этот компонент я еще не добрался опубликовать :-(. И писать в 2 таблицы сразу там не сделанно. И события не все прописал :-(.

    Безопасность обеспечивается хеш-кодом. Страница на которой опубликован сниппет должна быть доступна только нужным пользователям. Использовать getTables, для подобных задач, думаю, проше и быстрее чем писать контроллеры для sendIt.
      Александр Туниеков
      04 ноября 2024, 04:36
      0
      Забыл. getTables работает в MODX2. В MODX3 тоже вроде пока работает, но там не гарантируется и приспосабливать getTables для MODX3 не планируется. Работу с произвольной таблицей в MODX3 вроде сильно усложнили и не понятно как сделать правильно.
        Александр Туниеков
        04 ноября 2024, 04:49
        +1
        Еще сейчас заметил у Артура редактируется выбранная группа пользователей. Чтоб редактировать группу 2 в gettables надо добавить в секцию pdoTools where:
        'where'=>[
            'modUser.primary_group' => 2,
            'modUser.active' => 1,
        ]
        Авторизуйтесь или зарегистрируйтесь, чтобы оставлять комментарии.
        7