Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Lines marked **Upgrade step** need you to act after updating.
- Search your forms by name, also without accents ("muller" finds "Müller"), and press "/" to jump to the search. The form list has status tabs with counts and sorts by latest submission, last edit, newest or name. Your choice stays in the address, so Back undoes it and a link shows the same list. Dashboard links with the old `?filter=` show all forms. (#248)
- The form list shows when each form last got a submission, with a blue dot for the last 24 hours, and now opens sorted by latest submission. Its date column shows the date the list is sorted by. (#250)
- The API can copy a question (`POST /api/blocks/{block}/duplicate`) with its text, settings, answers and Required, but not its rules or its Identifier, so links that fill in answers and webhooks can't mix the two up. The copy goes right after the original. (#262)
- The public form API has a preview mode for the form's team: start a session with `preview: true`, also for a draft. Answers in preview mode are never stored or counted and send no webhooks or emails. (#266)

### Changed

Expand Down
14 changes: 13 additions & 1 deletion app/Http/Controllers/Api/CreateFormSessionController.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,22 +16,34 @@ class CreateFormSessionController extends Controller
* Create a new form session
*
* This endpoint creates a new form session and returns the session token. This token is required to submit the form. Using the same token on multiple submissions will result in the old submission being overwritten.
*
* With `preview`, a member of the form's team gets the token `preview` instead. Submitting or uploading with it stores nothing, counts nothing and sends no webhooks or emails.
*/
#[Group('Public Form Endpoints')]
public function __invoke(Request $request, Form $form)
{
$request->validate([
// params come from the form link, and nginx stops URLs at 8 KB
'params' => ['array', 'nullable', new MaxSize(8192, json: true)],
// only for the form's team: try the form without storing anything
'preview' => 'boolean',
]);

$session = FormSession::create([
$session = new FormSession([
'form_id' => $form->id,
'token' => Str::random(32),
'params' => $request->input('params', []),
'has_data_privacy' => $form->has_data_privacy,
]);

if ($request->boolean('preview')) {
$this->authorize('view', $form);

return response()->json($session->fill(['token' => FormSession::PREVIEW_TOKEN]), 201);
}

$session->save();

return response()->json($session, 201);
}
}
10 changes: 9 additions & 1 deletion app/Http/Controllers/Api/FormSubmitController.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use App\Events\FormSessionCompletedEvent;
use App\Http\Controllers\Controller;
use App\Models\Form;
use App\Models\FormSession;
use App\Rules\MaxSize;
use Illuminate\Http\Request;
use Knuckles\Scribe\Attributes\BodyParam;
Expand All @@ -17,7 +18,7 @@ class FormSubmitController extends Controller
*
* This endpoint submits a form. It requires a valid session token, which can be obtained by creating a new session. Using the same token will result in the old submitted data being overwritten.
*/
#[BodyParam('token', 'string', 'The session token', example: 'rIt8id8JgiyExtmb2hkcbuWjI9izUyRp')]
#[BodyParam('token', 'string', 'The session token, or `preview` for a preview session', example: 'rIt8id8JgiyExtmb2hkcbuWjI9izUyRp')]
#[BodyParam('payload', 'object', <<<'DESC'
The user input that you want to submit for the form. The payload is a key-value object, where the key is the form block ID and the value is the user input for this form block.

Expand All @@ -44,6 +45,13 @@ public function __invoke(Request $request, Form $form)
'payload.*' => [new MaxSize(30000)],
]);

// a preview stores and sends nothing, see CreateFormSessionController
if ($request->input('token') === FormSession::PREVIEW_TOKEN) {
$this->authorize('view', $form);

return response()->noContent();
}

$session = $form->formSessions()
->where('token', $request->input('token'))
->firstOrFail();
Expand Down
8 changes: 8 additions & 0 deletions app/Http/Controllers/Api/FormUploadController.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use App\Http\Controllers\Controller;
use App\Models\Form;
use App\Models\FormBlockInteraction;
use App\Models\FormSession;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;

Expand All @@ -31,6 +32,13 @@ public function __invoke(Request $request, Form $form)
'file' => 'required|file',
]);

// a preview stores no files, see CreateFormSessionController
if ($request->input('token') === FormSession::PREVIEW_TOKEN) {
$this->authorize('view', $form);

return response()->noContent();
}

// only look at the interactions of this form
$interaction = FormBlockInteraction::withUuid($request->input('actionId'))
->whereHas('formBlock', fn ($query) => $query->where('form_id', $form->id))
Expand Down
3 changes: 3 additions & 0 deletions app/Models/FormSession.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ class FormSession extends Model
{
use HasFactory;

// a preview has no stored session, and a stored token has 32 characters, so it never matches this one
public const PREVIEW_TOKEN = 'preview';

protected $guarded = [];

protected $casts = [
Expand Down
97 changes: 97 additions & 0 deletions tests/Feature/PreviewModeTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
<?php

use App\Enums\FormBlockInteractionType;
use App\Enums\FormBlockType;
use App\Events\FormSessionCompletedEvent;
use App\Models\Form;
use App\Models\FormBlock;
use App\Models\FormBlockInteraction;
use App\Models\FormSession;
use App\Models\FormSessionResponse;
use App\Models\FormSessionUpload;
use App\Models\FormWebhook;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Storage;

uses(RefreshDatabase::class);

beforeEach(function () {
Event::fake([FormSessionCompletedEvent::class]);
Storage::fake();

$this->form = Form::factory()->create();
FormWebhook::factory()->for($this->form)->create();

$this->text = FormBlock::factory()->for($this->form)->create(['type' => FormBlockType::short, 'sequence' => 0]);
FormBlockInteraction::factory()->for($this->text, 'formBlock')->input()->create();

$file = FormBlock::factory()->for($this->form)->create(['type' => FormBlockType::file, 'sequence' => 1]);
$this->fileAnswer = FormBlockInteraction::factory()->for($file, 'formBlock')->create(['type' => FormBlockInteractionType::file]);

$this->routes = fn () => [
'session' => ['post', route('api.public.forms.session.create', ['form' => $this->form->uuid]), ['preview' => true]],
'submit' => ['post', route('api.public.forms.submit', ['form' => $this->form->uuid]), ['token' => 'preview', 'payload' => $this->text->fresh()->getSubmitPayload('Hello')]],
'upload' => ['post', route('api.public.forms.file-upload', ['form' => $this->form->uuid]), ['token' => 'preview', 'actionId' => $this->fileAnswer->uuid, 'file' => UploadedFile::fake()->create('cv.pdf', 10)]],
];

$this->assertNothingStored = function () {
expect([FormSession::count(), FormSessionResponse::count(), FormSessionUpload::count(), Storage::allFiles()])->toBe([0, 0, 0, []]);

Event::assertNotDispatched(FormSessionCompletedEvent::class);
};
});

test('a team member can try a draft form in preview mode, and nothing is stored, counted or sent', function () {
$this->form->update(['published_at' => null]);
$member = User::factory()->withTeam($this->form->team)->create();

$this->actingAs($member)
->json('post', route('api.public.forms.session.create', ['form' => $this->form->uuid]), ['preview' => true, 'params' => ['ref' => 'editor']])
->assertCreated()
->assertJson(['token' => 'preview', 'params' => ['ref' => 'editor'], 'is_completed' => false]);

// the form page's order: answers, then files, then the final submit
$submit = fn (bool $isUploading, ?array $payload) => $this->json('post', route('api.public.forms.submit', ['form' => $this->form->uuid]), [
'token' => 'preview',
'is_uploading' => $isUploading,
'payload' => $payload,
])->assertNoContent();

$submit(true, [
...$this->text->fresh()->getSubmitPayload('Hello'),
$this->fileAnswer->formBlock->uuid => ['actionId' => $this->fileAnswer->uuid, 'payload' => 'upload'],
]);

$this->json('post', route('api.public.forms.file-upload', ['form' => $this->form->uuid]), [
'token' => 'preview',
'actionId' => $this->fileAnswer->uuid,
'file' => UploadedFile::fake()->create('cv.pdf', 10),
])->assertNoContent();

$submit(false, null);

($this->assertNothingStored)();
});

test('only the form\'s team can use preview mode', function (string $route) {
[$method, $uri, $data] = ($this->routes)()[$route];

$this->json($method, $uri, $data)->assertForbidden();

$this->actingAs(User::factory()->withTeam()->create())
->json($method, $uri, $data)
->assertForbidden();

($this->assertNothingStored)();
})->with(['session', 'submit', 'upload']);

test('a form session without preview is stored as before', function () {
$this->json('post', route('api.public.forms.session.create', ['form' => $this->form->uuid]), ['preview' => false])
->assertCreated();

expect(FormSession::count())->toBe(1)
->and(FormSession::first()->token)->toHaveLength(32);
});
Loading