bayareawebpro/laravel-multistep-forms

针对Laravel的多步骤表单构建器。

v1.3.0 2024-06-07 00:00 UTC

This package is auto-updated.

Last update: 2024-09-07 00:27:35 UTC


README

https://packagist.org.cn/packages/bayareawebpro/laravel-multistep-forms

多步骤表单构建器是一个可以由控制器返回的“责任”类。

  • 指定要使用的视图,或者使用JSON无头方式与JavaScript框架一起使用。
  • 使用简单的数组配置每个步骤的规则、消息和辅助数据。
  • 多次提交到同一路由,将每个验证过的请求合并到命名空间会话键中。
  • 在验证前后挂钩到每个步骤,以与表单交互或返回响应。

安装

composer require bayareawebpro/laravel-multistep-forms

示例用法

<?php

use BayAreaWebPro\MultiStepForms\MultiStepForm;

// Render a view with data.
return Form::make('my-form', [
        'title' => 'MultiStep Form'
    ])

    // Namespace the session data.
    ->namespaced('my-session-key')

    // Allow backwards navigation via get request. ?form_step=x
    ->canNavigateBack(true)

    // Tap invokable Class __invoke(Form $form)
    ->tap(new InvokableClass)

    // Before x step validation...
    ->beforeStep(1, function (MultiStepForm $form) {
        // Maybe return early or redirect?
    })
    // Before all step validation...
    ->beforeStep('*', function (MultiStepForm $form) {
        // Maybe return early or redirect?
    })

    // Validate Step 1
    ->addStep(1, [
        'rules' => ['name' => 'required'],
        'messages' => ['name.required' => 'Your name is required.'],
    ])

    // Validate Step 2
    ->addStep(2, [
        'rules' => ['role'  => 'required|string'],
        'data'  => ['roles' => fn()=>Role::forSelection()] // Lazy Loaded Closure
    ])

    // Add non-validated step...
    ->addStep(3,[
       'data' => ['message' => "Great Job, Your Done!"]
    ])

    // After step validation...
    ->onStep(3, function (MultiStepForm $form) {
        // Specific step, logic if needed.
    })
    ->onStep('*', function (MultiStepForm $form) {
        // All steps, logic if needed.
    })
   
    // Modify data before saved to session after each step.
    ->beforeSave(function(array $data) {
    
        // Transform non-serializable objects to paths, array data etc...
        return $data;
    })
   
    // Modify data before saved to session after each step.
    ->onComplete(function(MultiStepForm $form) {
    
        // Final submission logic.
    })
;

创建新实例

使用可选的视图和数据数组创建构建器类的实例。您应该始终为表单会话设置namespace,以避免与其他使用会话存储的应用程序部分发生冲突。

  • GET请求将加载表单状态和数据,对于已保存的当前步骤或回退到步骤1。
  • POSTPUTPATCH等...将验证和处理任何步骤的请求,并继续到下一个配置的步骤。
  • DELETE将重置会话状态并返回(blade),或返回JsonResponse
  • 通过canNavigateBack方法启用向后导航(通过get参数)。
<?php

use BayAreaWebPro\MultiStepForms\MultiStepForm;

$form = MultiStepForm::make('onboarding.start', [
    'title' => 'Setup your account'
]);

$form->namespaced('onboarding');
$form->canNavigateBack(true);

配置步骤

定义步骤的规则、消息和数据。数据将与在make方法中定义的任何视图数据合并,并包含在JsonResponse中。

** 使用Closure按键懒加载数据。

使用数组:

$form->addStep(2, [
    'rules' => [
        'role' => 'required|string'
    ],
    'messages' => [
        'role.required' => 'Your name is required.'
    ],
    'data' => [
        'roles' => fn() => Role::query()...,
    ],
])

或使用可调用的类(推荐)

use BayAreaWebPro\MultiStepForms\MultiStepForm;

class ProfileStep
{
    public function __construct(private int $step)
    {
        //
    }
    
    public function __invoke(MultiStepForm $form) 
    {
        $form->addStep($this->step, [
            'rules' => [
                'name' => 'required|string'
            ],
            'messages' => [
                'name.required' => 'Your name is required.'
            ],
            'data' => [
                'placeholders' => [
                    'name' => 'Enter your name.'
                ]
            ],
        ]);
    }
}
$form->tap(new ProfileStep(1));

BeforeStep / OnStep钩子

定义一个回调,在验证步骤之前触发。步骤编号为*表示所有步骤。

  • 使用步骤整数或星号(*)表示所有步骤。
  • 您可以从这些钩子返回响应。
$form->beforeStep('*', function(MultiStepForm $form){
    //
});
$form->onStep('*', function(MultiStepForm $form){
    //
});
$form->onComplete(function(MultiStepForm $form){
    //
});

处理上传的文件

指定一个回调,用于将上传的文件转换为路径。

use Illuminate\Http\UploadedFile;

$form->beforeSave(function(array $data){
    if($data['avatar'] instanceof UploadedFile){
        $data['avatar'] = $data['avatar']->store('avatars');
    }
    return $data;
});

重置/清除表单

  • Ajax:向表单路由提交DELETE请求。
  • Blade:使用额外的提交按钮,传递布尔值(truthy)。
<button type="submit" name="reset" value="1">Reset</button>

JSON响应模式

返回的响应将有两个属性

{
  "form": {
    "form_step": 1
  },
  "data": {}
}

公共辅助方法

stepConfig

获取当前步骤配置(默认),或传递整数以获取特定步骤

$form->stepConfig(2): Collection

getValue

获取字段值(会话/旧输入)或回退

$form->getValue('name', 'John Doe'): mixed

setValue

设置字段值并将其存储在会话中

$form->setValue('name', 'Jane Doe'): MultiStepForm

save

直接合并和保存键/值数组到会话(不触发beforeSaveCallback

$form->save(['name' => 'Jane Doe']): MultiStepForm

reset

将表单状态重置为默认值,可选地传递一个数据数组以初始化。

$form->reset(['name' => 'Jane Doe']): MultiStepForm

withData

将非表单数据添加到所有视图和响应中

$form->withData(['date' => now()->toDateString()]);

currentStep

获取当前保存的步骤编号

$form->currentStep(): int

requestedStep

获取传入客户端请求的步骤编号

$form->requestedStep(): int

isStep

当前步骤是提供的步骤吗

$form->isStep(3): bool

prevStepUrl

获取上一个步骤URL。

$form->prevStepUrl(): string|null

lastStep

获取最后一步编号

$form->lastStep(): int

isLastStep

当前步骤是否为最后一步

$form->isLastStep(): bool

isPast,isActive,isFuture

// Boolean Usage
$form->isPast(2): bool
$form->isActive(2): bool
$form->isFuture(2): bool

// Usage as HTML Class Helpers
$form->isPast(2, 'truthy-class', 'falsy-class'): string
$form->isActive(2, 'truthy-class', 'falsy-class'): string
$form->isFuture(2, 'truthy-class', 'falsy-class'): string

Blade 示例

数据将被注入到视图以及表单本身,允许您访问表单值和其他辅助方法。

<?php
use BayAreaWebPro\MultiStepForms\MultiStepForm as Form;

$form = Form::make('my-view', $data);
$form->namespaced('onboarding');
$form->canNavigateBack(true);
<form method="post" action="{{ route('submit') }}">
    <input type="hidden" name="form_step" value="{{ $form->currentStep() }}">
    @csrf
    <a
        href="{{ route('submit', ['form_step' => 1]) }}"
        class="{{ $form->isPast(1, 'text-blue-500', $form->isActive(1, 'font-bold', 'disabled')) }}">
        Step 1
    </a>
    <a
        href="{{ route('submit', ['form_step' => 2]) }}"
        class="{{ $form->isPast(2, 'text-blue-500', $form->isActive(2, 'font-bold', 'disabled')) }}">
        Step 2
    </a>
    <a
        href="{{ route('submit', ['form_step' => 3]) }}"
        class="{{ $form->isPast(3, 'text-blue-500', $form->isActive(3, 'font-bold', 'disabled')) }}">
        Step 3
    </a>

    
    @switch($form->currentStep())
    
        @case(1)
            <label>Name</label>
            <input type="text" name="name" value="{{ $form->getValue('name') }}">
            @error('name') 
                <p>{{ $errors->first('name') }}</p>
            @enderror
        @break
    
        @case(2)
            <label>Role</label>
            <input type="text" name="role" value="{{ $form->getValue('role') }}">
             @error('role') 
                <p>{{ $errors->first('role') }}</p>
            @enderror
        @break
    
        @case(3)
            <p>Review your submission:</p>
            <p>
             Name: {{ $form->getValue('name') }}<br>
             Role: {{ $form->getValue('role') }}<br>
            </p>
        @break
    
    @endswitch
    
    @if($form->isLastStep())
        <button type="submit" name="submit">Save</button>
        <button type="submit" name="reset" value="1">Reset</button>
    @else
        <button type="submit" name="submit">Continue</button>
    @endif

</form>

Vue 示例

如果没有指定视图或请求偏好 JSON,表单状态和数据将以 JSON 格式返回。您可以将这两种技术结合起来,在 blade 中使用 Vue。

<v-form action="{{ route('submit') }}">
    <template v-slot:default="{form, options, errors, reset, back}">

        <h1 class="font-black my-3">
            @{{ options.title }}
        </h1>

        <p v-if="options.message" role="alert" class="bg-gray-200 p-4 my-5 font-bold text-blue-500">
            @{{ options.message }}
        </p>

        <template v-if="form.form_step < 4">
            <a
                @click="back(1)"
                :class="{'text-blue-500': form.form_step > 1, 'font-bold': form.form_step === 1}">
                Step 1
            </a>
            <a
                @click="back(2)"
                :class="{'text-blue-500': form.form_step > 2, 'font-bold': form.form_step === 2}">
                Step 2
            </a>
            <a
                @click="back(3)"
                :class="{'text-blue-500': form.form_step > 3, 'font-bold': form.form_step === 3}">
                Step 3
            </a>
        </template>

        <template v-if="form.form_step === 1">

            <v-input
                name="name"
                label="Name"
                :errors="errors"
                v-model="form.name">
            </v-input>

            <v-select
                name="name"
                label="Name"
                :errors="errors"
                :options="options.roles"
                v-model="form.role">
            </v-select>

            <x-action>Continue</x-action>
        </template>

        <template v-if="form.form_step === 2">
            <v-input
                name="email"
                label="Email"
                :errors="errors"
                v-model="form.email">
            </v-input>
            <v-input
                name="phone"
                label="Phone"
                :errors="errors"
                v-model="form.phone">
            </v-input>
            <x-action>Continue</x-action>
        </template>

        <template v-if="form.form_step === 3">
            <v-input
                name="bio"
                label="Bio"
                :errors="errors"
                v-model="form.bio">
            </v-input>
            <v-input
                name="notify"
                label="Notify"
                :errors="errors"
                v-model="form.notify">
            </v-input>
            <x-action>Continue</x-action>
        </template>

        <template v-if="form.form_step === 4">
            <h3>Review Submission</h3>
            <p>
                Name: @{{ form.name }}<br>
                Role: @{{ form.role }}<br>
                Email: @{{ form.email }}<br>
                Phone: @{{ form.phone }}<br>
            </p>
            <x-action>Save</x-action>
            <x-action @click="reset">Reset</x-action>
        </template>

        <template v-if="form.form_step === 5">
            <x-action>Done</x-action>
        </template>

    </template>
</v-form>

示例表单组件

<script>
  export default {
    name: 'Form',
    props: ['action'],
    data: () => ({
      errors: {},
      options: {},
      form: {form_step: 1},
    }),
    methods: {
      reset() {
        this.form.reset = 1
        this.submit()
      },
      back(step) {
        if (step < this.form.form_step) {
          this.fetch({form_step: step})
        }
      },
      fetch(params = {}) {
        axios
            .get(this.action, {params})
            .then(this.onResponse)
            .catch(this.onError)
      },
      submit() {
        axios
            .post(this.action, this.form)
            .then(this.onResponse)
            .catch(this.onError)
      },
      onError({response}) {
        this.errors = (response.data.errors || response.data.exception)
      },
      onResponse({data}) {
        this.errors = {}
        this.options = (data.data || {})
        this.form = (data.form || {})
      },
    },
    created() {
      this.fetch()
    }
  }
</script>
<template>
  <form @submit.prevent="submit">
    <slot :reset="reset" :back="back" :form="form" :options="options" :errors="errors"/>
  </form>
</template>

示例输入组件

<script>
  export default {
    name: "Input",
    props: ['name', 'label', 'value', 'errors'],
    computed: {
      field: {
        get() {
          return this.value
        },
        set(val) {
          return this.$emit('input', val)
        }
      }
    }
  }
</script>
<template>
  <label class="block my-4">
        <span class="text-gray-700 font-bold">
            {{ label || name }}
        </span>
    <input
        type="text"
        v-model="field"
        class="form-input block w-full mt-2">
    <div v-if="errors[name]" class="text-red-500 text-xs my-2">
      {{ errors[name][0] }}
    </div>
  </label>
</template>

示例选择组件

<script>
  export default {
    name: "Select",
    props: ['name', 'label', 'value', 'errors', 'options'],
    computed: {
      field: {
        get() {
          return this.value
        },
        set(val) {
          return this.$emit('input', val)
        }
      }
    }
  }
</script>
<template>
  <label class="block">
    <span class="text-gray-700">{{ label || name }}</span>
    <select v-model="field" class="form-select mt-1 block w-full">
      <option disabled value="">Please select one</option>
      <option v-for="option in options" :value="option">
        {{ option }}
      </option>
    </select>
    <div v-if="errors[name]" class="text-red-500 text-xs my-2">
      {{ errors[name][0] }}
    </div>
  </label>
</template>