pbQuiz — гибкий компонент квизов на контроллерах PageBlocks

pbQuiz — это наглядный пример того, как с помощью PageBlocks можно построить гибкую многошаговую форму-квиз с пошаговой валидацией и хранением прогресса в сессии.

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



Что умеет pbQuiz


  • Создавать квиз из любого количества шагов.
  • Гибко задавать поля: radio, checkbox, текстовые и email.
  • Проверять введённые данные на каждом шаге.
  • Хранить введённые данные между шагами.
  • Отправлять результаты менеджеру и пользователю.
  • Полностью управляться через таблицы PageBlocks
ДЕМО

Как настроить


Вы можете быстро запустить квиз двумя способами:

1. Использовать готовый бесплатный компонент


pbQuiz — это готовый бесплатный компонент, который можно установить через PageBlocks.
Что для этого нужно:
  • Установите компонент PageBlocks, если он ещё не установлен.
  • Установите pbQuiz — он автоматически создаст:
    • все необходимые контроллеры,
    • меню с таблицей,
    • базовую структуру квиза.
После установки всё, что нужно — это перейти в меню pbQuiz и заполнить шаги квиза своими данными: обложку, вопросы, финальный экран и поля для ввода.

2. Сделать квиз вручную


Если вы хотите глубже понять, как работает квиз внутри PageBlocks, или адаптировать его под свои задачи — ниже приведён детальный пример, который показывает:
  • как шаг за шагом собрать таблицу,
  • какие маршруты подключить,
  • как написать свой QuizController,
  • как настроить шаблоны.
Эти шаги помогут вам полностью разобраться, как всё устроено «под капотом» и при необходимости доработать логику под свои сценарии.

Шаг 1. Создайте таблицу для квиза
В этой таблице вы будете создавать шаги квиза.
У каждого шага свой заголовок, описание и набор полей.

Режим менеджера
1.1 Создайте необходимые таблицы


1.2 Добавьте меню


Режим разработчика
Menu::make('pbquiz')
    ->title('pbQuiz')
    ->description('Quiz for PageBlocks')
    ->position(5)
    ->parent('pageblocks')
    ->fields([
        Field::make('pbquiz')
            ->label('Quiz')
            ->type('table')
            ->fields([
                Field::make('title')
                    ->label('Title')
                    ->required(),
                
                Field::make('description')
                    ->label('Description')
                    ->type('textarea'),
                
                Field::make('Image')
                    ->label('Image')
                    ->type('image')
                    ->source(1)
                    ->sourcePath('/assets/components/pbquiz/images/'),
                
                Field::make('fields')
                    ->label('Fields')
                    ->type('table')
                    ->fields([
                        Field::make('name')
                            ->label('Name')
                            ->width(50)
                            ->required(),
                        
                        Field::make('label')
                            ->label('Label')
                            ->width(50),
                        
                        Field::make('type')
                            ->label('Field type')
                            ->type('select')
                            ->options([
                                'radio' => 'Radio',
                                'checkbox' => 'Checkbox',
                                'text' => 'Text',
                                'email' => 'Email',
                            ])
                            ->width(50)
                            ->required(),
                        
                        Field::make('placeholder')
                            ->label('Placeholder')
                            ->width(50),
                        
                        Field::make('options')
                            ->label('Values')
                            ->type('keyvalue')
                            ->keyLabel('Value')
                            ->valueLabel('Label'),
                        
                        Field::make('validation')
                            ->label('Validation')
                            ->width(50)
                            ->help('Docs: https://pageblocks.boshnik.com/docs/validation#available-validation-rules'),
                        
                        Field::make('validation_error')
                            ->label('Field Validation Error')
                            ->width(50),
                    ])
                    ->columns([
                        Column::make('Name'),
                        Column::make('Label'),
                        Column::make('Validation'),
                    ])
            ])
        ->columns([
            Column::make('Title'),
        ])
    ]);

Шаг 2. Заполните данные квиза
После создания таблицы в админке откройте созданное меню pbQuiz.

Для каждого шага квиза добавьте новую строку с полями:
  • Title — заголовок шага.
  • Description — описание или пояснение.
  • Image — иллюстрация (если нужно).
  • Fields — список вопросов или полей на шаге.
Для каждого поля в блоке Fields задайте:
  • системное имя (name),
  • подпись для пользователя (label),
  • тип поля (Field type) — radio, checkbox, text или email,
  • опции значений (для radio/checkbox),
  • плейсхолдер (для текстовых полей),
  • правила валидации,
  • сообщение об ошибке валидации.
Для правильной работы квиза не забудьте:
  • первым шагом указать обложку (приветственный экран),
  • последним — финальный экран (сообщение о том, что данные успешно отправлены).


Шаг 3. Добавьте маршруты
Для работы квиза нужны три маршрута:
Route::get('/', 'Quiz\QuizController@index')->name('pageQuiz');

Route::prefix('/pbquiz')->group(function () {
    Route::post('/prev', 'Quiz\QuizController@prev')->name('quizPrev');
    Route::post('/next', 'Quiz\QuizController@next')->name('quizNext');
});

Что они делают?
  • /
  • Основной маршрут. Вызывает метод index в QuizController. Именно он выводит первый шаг квиза (или обложку).
  • /pbquiz/prev
  • POST-запрос на предыдущий шаг. Если пользователь решил вернуться назад — этот маршрут откатит шаг и покажет предыдущий экран.
  • /pbquiz/next
  • POST-запрос на следующий шаг. Пользователь заполнил поля, нажал «Далее» — данные валидируются и записываются в сессию. Если всё ок — грузится следующий шаг.

Шаг 4. Напишите контроллер
Метод index() — старт
Этот метод:
  • Очищает старые данные из сессии.
  • Получает первый шаг из таблицы.
  • Передаёт данные в шаблон квиза.
public function index()
{
    $_SESSION['pbquiz'] = []; // сбрасываем всё
    $data = $this->getData(); // берём первый шаг
    return view('file:quiz/templates/quiz', $data);
}

Метод getData() — получить данные шага
Достаёт из таблицы все поля для текущего шага.
public string $className = \pbTableValue::class;

public function getData(int $offset = 0, int $limit = 1)
{
    $query = $this->modx->newQuery($this->className);
    $query->select($this->modx->getSelectColumns($this->className, $this->className));
    $query->where([
        'field_name' => 'pbquiz',
        'published' => 1,
        'deleted' => 0,
    ]);
    $query->limit($limit, $offset);
    $query->sortby('menuindex', 'ASC');
    $query->prepare();
    $query->stmt->execute();

    if ($limit === 1) {
        $result = $query->stmt->fetch(\PDO::FETCH_ASSOC);
        $params = json_decode($result['values'], true) ?? [];
        $params['step'] = $offset;
        $params['total'] = $this->getTotal() - 2; // -2, потому что не считаем обложку и финал
    } else {
        $results = $query->stmt->fetchAll(\PDO::FETCH_ASSOC);
        $params = [];
        foreach ($results as $result) {
            $params[] = json_decode($result['values'], true) ?? [];
        }
    }

    return $params;
}

public function getTotal()
{
    return $this->modx->getCount($this->className, [
        'field_name' => 'pbquiz',
        'published' => 1,
        'deleted' => 0,
    ]);
}

Метод prev() — шаг назад
Когда пользователь жмёт «Назад»:
  • Шаг уменьшается на 1.
  • Из сессии удаляются поля, введённые для этого шага.
  • Сбрасывается валидация.
public function prev(Request $request)
{
    $step = $_SESSION['pbquiz']['step'] ?? 0;
    if ($step) {
        $step -= 1;
    }

    if ($_SESSION['pbquiz']['validation']['rules'] ?? false) {
        $_SESSION['pbquiz']['fields'] = array_diff_key(
            $_SESSION['pbquiz']['fields'],
            array_flip(array_keys($_SESSION['pbquiz']['validation']['rules']))
        );
    }

    $_SESSION['pbquiz']['validation'] = [];

    return $this->getStep($step, $request);
}

Метод next() — шаг вперёд
Когда пользователь жмёт «Далее»:
  • Берутся правила валидации из текущего шага.
  • Сохраняются в сессию.
  • Передаётся управление в getStep — он проверит данные, сохранит их и покажет следующий шаг.
public function next(Request $request)
{
    $step = $_SESSION['pbquiz']['step'] ?? 0;
    $data = $this->getData($step);

    $_SESSION['pbquiz']['validation'] = [];
    foreach ($data['fields'] ?? [] as $field) {
        $name = $field['name'];
        $_SESSION['pbquiz']['validation']['rules'][$name] = $field['validation'];
        $_SESSION['pbquiz']['validation']['errors'][$name] = $field['validation_error'];
    }

    return $this->getStep($step + 1, $request);
}

Метод getStep() — сердце квиза
Здесь всё происходит:
  • Валидация данных по правилам.
  • Сохранение введённого в сессию.
  • Получение следующего шага.
  • Если шаг последний — отправка писем менеджеру и пользователю.
public function getStep(int $step, Request $request)
{
    if ($_SESSION['pbquiz']['validation']['rules'] ?? false) {
        $rules = ['honeypot' => 'empty|exclude'];
        foreach ($_SESSION['pbquiz']['validation']['rules'] as $name => $rule) {
            $rules[$name] = $rule;
        }

        $errors = $_SESSION['pbquiz']['validation']['errors'] ?? [];
        $field_errors = [];
        foreach ($errors as $name => $error) {
            $field_errors[$name . '.required'] = $error;
        }

        $validator = validate($request->all(), $rules, $field_errors);

        if ($validator->fails()) {
            return response()->error('', $validator->errors());
        }

        $_SESSION['pbquiz']['fields'] = array_merge(
            $_SESSION['pbquiz']['fields'] ?? [],
            $validator->validated()
        );
    }

    $prevStep = $_SESSION['pbquiz']['step'] ?? 0;
    $_SESSION['pbquiz']['step'] = $step;

    $data = $this->getData($step);
    $html = view('file:quiz/chunks/step', $data);

    if ($step > $data['total']) {
        $emails = config('quiz.email.manager');
        if (!empty($emails)) {
            foreach (explode(',', $emails) as $email) {
                Mail::to(trim($email))
                    ->subject(lang('quiz.email_manager_subject'))
                    ->view('file:quiz/chunks/email_manager', $_SESSION['pbquiz']['fields'])
                    ->send();
            }
        }

        if (!empty($_SESSION['pbquiz']['fields']['email'])) {
            Mail::to($_SESSION['pbquiz']['fields']['email'])
                ->subject(lang('quiz.email_user_subject'))
                ->view('file:quiz/chunks/email_user', $_SESSION['pbquiz']['fields'])
                ->send();
        }
    }

    return response()->append([
        'prevStep' => $prevStep,
        'step' => $step,
        'total' => $data['total'],
    ])->success($html);
}

Шаг 5. Настройте шаблоны формы и шагов
После того как таблицы созданы, маршруты подключены, а контроллер написан — нужно сделать так, чтобы данные красиво отображались пользователю. Для этого используются шаблоны и чанки.

1. Основной шаблон формы
Вот базовый пример формы, которая уже готова к работе через AJAX с PageBlocks:
html...
<form action="{route 'quizNext'}" method="post" class="quiz-form w-100 h-100" pb-form>
    <input type="hidden" name="_token" value="{csrf_token}">
    <input type="hidden" name="honeypot" value="">
    <div id="quiz-step" pb-success-message>
        {insert 'file:quiz/chunks/step'}
    </div>
</form>
...

Как это работает:
pb-form
Этот атрибут подключает автоматическую обработку формы через AJAX
_token
Защищает от CSRF — каждый запрос проходит проверку безопасности.
honeypot
Простая защита от ботов. Если поле заполнено — запрос отклоняется.
pb-success-message
Этот атрибут указывает PageBlocks, куда вставить HTML ответа, который вернёт контроллер. В вашем случае контроллер возвращает сгенерированный чанк step для следующего шага квиза. Поэтому старый шаг автоматически заменяется на новый — без полной перезагрузки страницы.

2. Чанк step для рендеринга шагов
Чанк отвечает за то, как выводятся заголовок, описание и поля для текущего шага. Пример базового чанка:
{if $title}
  <h1>{$title}</h1>
{/if}

{if $description}
  <p>{$description}</p>
{/if}

{if $fields}
  <div class="quiz-fields">
    {foreach $fields as $field}
      {if $field['options']}
        <!-- Radio или Checkbox -->
        {foreach $field['options'] as $option}
          <label>
            <input type="{$field['type']}" 
                   name="{$field['name']}{if $field['type']=='checkbox'}[]{/if}" 
                   value="{$option['key']}">
            {$option['value']}
          </label>
        {/foreach}
      {else}
        <!-- Текстовое или Email -->
        <label>{$field['label']}</label>
        <input type="{$field['type']}" 
               name="{$field['name']}" 
               placeholder="{$field['placeholder']}">
      {/if}
    {/foreach}
  </div>
{/if}

<!-- Навигация -->
{if $step <= $total}
  {include 'file:quiz/chunks/nav'}
{/if}

3. Чанк nav для кнопок «Назад» и «Далее»
<div class="form-nav d-flex gap-3 justify-content-between justify-content-md-start mt-5 mt-md-auto">
    {if $step == 0}
        <div class="col-12">
            <button type="button" class="btn btn-lg" pb-post="{route 'quizNext'}" pb-target="#pb-quiz" pb-expect="json">{lang 'quiz.start'}</button>
        </div>
    {/if}
    {if $step > 1}
        <div class="col-auto">
            <button type='button' class='btn btn-lg btn-nav btn-prev' pb-post="{route 'quizPrev'}" pb-target="#pb-quiz" pb-expect="json">{lang 'quiz.prev'}</button>
        </div>
    {/if}
    {if $step && $step < $total}
        <div class="col-auto">
            <button type='submit' class='btn btn-lg btn-nav btn-next'>{lang 'quiz.next'}</button>
        </div>
    {/if}
    {if $step == $total}
        <div class="col-auto">
            <button type='submit' class='btn btn-lg btn-nav btn-next'>{lang 'quiz.submit'}</button>
        </div>
    {/if}
</div>

Что в итоге
  • Один шаблон формы — для всего квиза.
  • Один чанк — для рендеринга шагов и полей.
  • Один чанк навигации — для управления переходами.
  • Контроллер возвращает HTML шагов, валидацию и сохраняет прогресс.
  • Всё хранится в сессии — пользователь может возвращаться назад.

Заключение


Теперь у вас есть два удобных варианта:
  • Если вам нужно быстро запустить квиз без лишней разработки — используйте готовый бесплатный компонент pbQuiz. Он сразу создаст все необходимые таблицы, контроллеры и структуру квиза. Вам останется только заполнить данные через меню и запустить квиз на вашем сайте.
  • Если вы хотите гибко настроить всё под свои задачи или лучше разобраться в работе PageBlocks — используйте пошаговую инструкцию из этого руководства. Она показывает, как создать таблицу, подключить маршруты, написать контроллер и настроить шаблоны формы.
Оба подхода позволяют вам создавать удобные многошаговые квизы с пошаговой валидацией, хранением данных и отправкой писем — полностью на базе PageBlocks.
Aleksandr Huz
06 июля 2025, 15:14
modx.pro
299
+14
Поблагодарить автора Отправить деньги

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

Марат
08 июля 2025, 07:12
0
Ветвления есть?
    Aleksandr Huz
    08 июля 2025, 11:25
    +2
    Статью читали? В контроллерах вы сами указываете какой шаг вам нужно показывать, да что угодно, вы можете на любом шаге сделать редирект в зависимости от ответа.

    Не хотите писать код, вот готовый компонент — Quiz
    Дмитрий Суворов
    08 июля 2025, 15:21
    +3
    Вся экосистема PageBlocks вызывает огромное впечатление
      Авторизуйтесь или зарегистрируйтесь, чтобы оставлять комментарии.
      3