From cc2b0e989d5ec1d0eebdc28bf391848b2d4be998 Mon Sep 17 00:00:00 2001 From: Chirag Chhatrala <60499540+chiragchhatrala@users.noreply.github.com> Date: Tue, 20 May 2025 00:36:54 +0530 Subject: [PATCH] Slug customisation (#755) * Enhance Form Slug Handling and Validation Logic - Updated `FormController.php` to conditionally set the form slug based on the `self_hosted` configuration, ensuring proper slug assignment during form creation and updates. - Introduced `CustomSlugRule.php` to validate custom slugs, enforcing format and uniqueness constraints, and integrated this rule into `UserFormRequest.php`. - Enhanced the `FormCustomSeo.vue` component to include a field for custom URL slugs, improving user experience by allowing users to define unique identifiers for their forms. - Updated API routes to apply middleware for form updates, ensuring proper form resolution during requests. These changes aim to improve the functionality and user experience related to form slug management and validation. * Test case for Custom slug * Update OpenCompleteForm and FormCustomSeo for Improved Functionality and Clarity - Modified `OpenCompleteForm.vue` to ensure `submissionId` is correctly referenced as `submissionId.value`, enhancing data handling during form initialization. - Updated `FormCustomSeo.vue` to rename "Custom URL Slug" to "Custom Form URL" for better clarity and user understanding, ensuring consistent terminology across the application. - Enhanced `useFormInitialization.js` to include `submission_id` in the data passed to `form.resetAndFill`, improving the accuracy of form data handling. These changes aim to improve the functionality and user experience of the form components by ensuring correct data references and clearer labeling. --------- Co-authored-by: Julien Nahum --- .../Http/Controllers/Forms/FormController.php | 7 ++ api/app/Http/Requests/UserFormRequest.php | 14 ++- api/app/Rules/CustomSlugRule.php | 47 ++++++++ api/routes/api.php | 2 +- api/tests/Feature/Forms/FormTest.php | 109 ++++++++++++++++++ .../open/forms/OpenCompleteForm.vue | 2 +- .../form-components/FormCustomSeo.vue | 16 +++ .../composables/useFormInitialization.js | 5 +- 8 files changed, 197 insertions(+), 5 deletions(-) create mode 100644 api/app/Rules/CustomSlugRule.php 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.`)