diff --git a/api/app/Http/Controllers/Forms/FormController.php b/api/app/Http/Controllers/Forms/FormController.php index efdd09d4..c98174ac 100644 --- a/api/app/Http/Controllers/Forms/FormController.php +++ b/api/app/Http/Controllers/Forms/FormController.php @@ -121,6 +121,11 @@ class FormController extends Controller 'creator_id' => $request->user()->id, ])); + if (config('app.self_hosted') && !empty($formData['slug'])) { + $form->slug = $formData['slug']; + $form->save(); + } + if ($this->formCleaner->hasCleaned()) { $formStatus = $form->workspace->is_trialing ? 'Non-trial' : 'Pro'; $message = 'Form successfully created, but the ' . $formStatus . ' features you used will be disabled when sharing your form:'; @@ -150,6 +155,8 @@ class FormController extends Controller return !Str::of($field['type'])->startsWith('nf-') && !in_array($field['id'], collect($formData['properties'])->pluck('id')->toArray()); })->toArray()); + $form->slug = (config('app.self_hosted') && !empty($formData['slug'])) ? $formData['slug'] : $form->slug; + $form->update($formData); if ($this->formCleaner->hasCleaned()) { diff --git a/api/app/Http/Requests/UserFormRequest.php b/api/app/Http/Requests/UserFormRequest.php index 8a36e661..10da80a8 100644 --- a/api/app/Http/Requests/UserFormRequest.php +++ b/api/app/Http/Requests/UserFormRequest.php @@ -4,12 +4,14 @@ namespace App\Http\Requests; use App\Http\Requests\Workspace\CustomDomainRequest; use App\Models\Forms\Form; +use App\Rules\CustomSlugRule; use App\Rules\FormPropertyLogicRule; use App\Rules\PaymentBlockConfigurationRule; use Illuminate\Support\Facades\Log; use Illuminate\Validation\ValidationException; use Illuminate\Contracts\Validation\Validator; use Illuminate\Validation\Rule; +use Illuminate\Http\Request; /** * Abstract class to validate create/update forms @@ -18,6 +20,13 @@ use Illuminate\Validation\Rule; */ abstract class UserFormRequest extends \Illuminate\Foundation\Http\FormRequest { + public ?Form $form; + + public function __construct(Request $request) + { + $this->form = $request?->form ?? null; + } + protected function prepareForValidation() { $data = $this->all(); @@ -76,8 +85,8 @@ abstract class UserFormRequest extends \Illuminate\Foundation\Http\FormRequest $workspace = null; // For update requests, try to get the workspace from the form - if ($this->route('form')) { - $workspace = $this->route('form')->workspace; + if ($this->form) { + $workspace = $this->form->workspace; } // For create requests, get the workspace from the workspace parameter elseif ($this->route('workspace')) { @@ -186,6 +195,7 @@ abstract class UserFormRequest extends \Illuminate\Foundation\Http\FormRequest 'password' => 'sometimes|nullable', 'use_captcha' => 'boolean', 'captcha_provider' => ['sometimes', Rule::in(['recaptcha', 'hcaptcha'])], + 'slug' => [new CustomSlugRule($this->form)], // Custom SEO 'seo_meta' => 'nullable|array', diff --git a/api/app/Rules/CustomSlugRule.php b/api/app/Rules/CustomSlugRule.php new file mode 100644 index 00000000..128a33c7 --- /dev/null +++ b/api/app/Rules/CustomSlugRule.php @@ -0,0 +1,47 @@ +form && $this->form->slug === $value) { + return; + } + + if (!preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $value)) { + $fail('The custom slug can only contain lowercase letters, numbers, and hyphens.'); + return; + } + + // Check if the slug is unique, excluding current form + $query = Form::where('slug', $value); + if ($this->form) { + $query->where('id', '!=', $this->form->id); + } + + if ($query->exists()) { + $fail('This slug is already in use. Please choose another one.'); + return; + } + } +} diff --git a/api/routes/api.php b/api/routes/api.php index aead655f..e2efaecb 100644 --- a/api/routes/api.php +++ b/api/routes/api.php @@ -158,7 +158,7 @@ Route::group(['middleware' => 'auth:api'], function () { Route::prefix('forms')->name('forms.')->group(function () { Route::post('/', [FormController::class, 'store'])->name('store'); Route::post('/{id}/workspace/{workspace_id}', [FormController::class, 'updateWorkspace'])->name('workspace.update'); - Route::put('/{id}', [FormController::class, 'update'])->name('update'); + Route::put('/{id}', [FormController::class, 'update'])->name('update')->middleware([ResolveFormMiddleware::class]); Route::delete('/{id}', [FormController::class, 'destroy'])->name('destroy'); Route::get('/{id}/mobile-editor-email', [FormController::class, 'mobileEditorEmail'])->name('mobile-editor-email'); diff --git a/api/tests/Feature/Forms/FormTest.php b/api/tests/Feature/Forms/FormTest.php index 8da10af2..6a109295 100644 --- a/api/tests/Feature/Forms/FormTest.php +++ b/api/tests/Feature/Forms/FormTest.php @@ -189,3 +189,112 @@ it('can create form with custom scripts', function () { 'custom_code' => null ]); })->skip(true, 'Trialing custom script form cleaning disabled for now.'); + +it('can not set custom slug when not self hosted', function () { + config(['app.self_hosted' => false]); + + $user = $this->actingAsUser(); + $workspace = $this->createUserWorkspace($user); + $form = $this->makeForm($user, $workspace); + $form->slug = 'my-custom-slug-123'; + $formData = (new \App\Http\Resources\FormResource($form))->toArray(request()); + + $response = $this->postJson(route('open.forms.store', $formData)) + ->assertSuccessful() + ->assertJson([ + 'type' => 'success', + 'message' => 'Form created.' + ]); + $this->assertNotEquals($response->json('form.slug'), 'my-custom-slug-123'); +}); + +it('can set custom slug when self hosted', function () { + config(['app.self_hosted' => true]); + + $user = $this->actingAsUser(); + $workspace = $this->createUserWorkspace($user); + $form = $this->makeForm($user, $workspace); + $form->slug = 'my-custom-slug-123'; + $formData = (new \App\Http\Resources\FormResource($form))->toArray(request()); + + $response = $this->postJson(route('open.forms.store', $formData)) + ->assertSuccessful() + ->assertJson([ + 'type' => 'success', + 'message' => 'Form created.' + ]); + $this->assertEquals($response->json('form.slug'), 'my-custom-slug-123'); +}); + +it('rejects invalid custom slug format when self hosted', function () { + config(['app.self_hosted' => true]); + + $user = $this->actingAsUser(); + $workspace = $this->createUserWorkspace($user); + $form = $this->makeForm($user, $workspace); + $form->slug = 'Invalid Slug!@#'; + $formData = (new \App\Http\Resources\FormResource($form))->toArray(request()); + + $this->postJson(route('open.forms.store', $formData)) + ->assertUnprocessable() + ->assertJsonValidationErrors(['slug']); +}); + +it('rejects duplicate custom slug when self hosted', function () { + config(['app.self_hosted' => true]); + + $user = $this->actingAsUser(); + $workspace = $this->createUserWorkspace($user); + + // Create first form with custom slug + $form1 = $this->createForm($user, $workspace); + $form1->update(['slug' => 'duplicate-slug']); + + // Try to create second form with same slug + $form2 = $this->makeForm($user, $workspace); + $form2->slug = $form1->slug; + $formData = (new \App\Http\Resources\FormResource($form2))->toArray(request()); + + $this->postJson(route('open.forms.store', $formData)) + ->assertUnprocessable() + ->assertJsonValidationErrors(['slug']); +}); + +it('allows empty custom slug when self hosted', function () { + config(['app.self_hosted' => true]); + + $user = $this->actingAsUser(); + $workspace = $this->createUserWorkspace($user); + $form = $this->makeForm($user, $workspace); + $form->slug = null; + $formData = (new \App\Http\Resources\FormResource($form))->toArray(request()); + + $this->postJson(route('open.forms.store', $formData)) + ->assertSuccessful() + ->assertJson([ + 'type' => 'success', + 'message' => 'Form created.', + ]); +}); + +it('can update form with custom slug when self hosted', function () { + config(['app.self_hosted' => true]); + + $user = $this->actingAsUser(); + $workspace = $this->createUserWorkspace($user); + $form = $this->createForm($user, $workspace); + $form->slug = 'updated-custom-slug'; + $formData = (new \App\Http\Resources\FormResource($form))->toArray(request()); + + $this->putJson(route('open.forms.update', $form->id), $formData) + ->assertSuccessful() + ->assertJson([ + 'type' => 'success', + 'message' => 'Form updated.', + ]); + + $this->assertDatabaseHas('forms', [ + 'id' => $form->id, + 'slug' => 'updated-custom-slug' + ]); +}); diff --git a/client/components/open/forms/OpenCompleteForm.vue b/client/components/open/forms/OpenCompleteForm.vue index 981d4fe0..7f0a2790 100644 --- a/client/components/open/forms/OpenCompleteForm.vue +++ b/client/components/open/forms/OpenCompleteForm.vue @@ -261,7 +261,7 @@ if (props.form) { darkMode: darkModeRef }) formManager.initialize({ - submissionId: submissionId, + submissionId: submissionId.value, urlParams: import.meta.client ? new URLSearchParams(window.location.search) : null, }) } diff --git a/client/components/open/forms/components/form-components/FormCustomSeo.vue b/client/components/open/forms/components/form-components/FormCustomSeo.vue index f55eee42..d4ee180b 100644 --- a/client/components/open/forms/components/form-components/FormCustomSeo.vue +++ b/client/components/open/forms/components/form-components/FormCustomSeo.vue @@ -71,6 +71,22 @@ label="Indexable by Google" /> + +
+

+ Custom Form URL +

+

+ Create a custom URL for your form. This will be the unique identifier in your form's URL. +

+ +
diff --git a/client/lib/forms/composables/useFormInitialization.js b/client/lib/forms/composables/useFormInitialization.js index 9e224007..c3129c21 100644 --- a/client/lib/forms/composables/useFormInitialization.js +++ b/client/lib/forms/composables/useFormInitialization.js @@ -216,7 +216,10 @@ export function useFormInitialization(formConfig, form, pendingSubmission) { return opnFetch(`/forms/${slug}/submissions/${submissionIdValue}`) .then(submissionData => { if (submissionData.data) { - resetAndFill(submissionData.data) + resetAndFill({ + ...submissionData.data, + submission_id: submissionIdValue + }) return true } else { console.warn(`Submission ${submissionIdValue} for form ${slug} loaded but returned no data.`)