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 <julien@nahum.net>
This commit is contained in:
parent
fb64a948a3
commit
cc2b0e989d
|
|
@ -121,6 +121,11 @@ class FormController extends Controller
|
||||||
'creator_id' => $request->user()->id,
|
'creator_id' => $request->user()->id,
|
||||||
]));
|
]));
|
||||||
|
|
||||||
|
if (config('app.self_hosted') && !empty($formData['slug'])) {
|
||||||
|
$form->slug = $formData['slug'];
|
||||||
|
$form->save();
|
||||||
|
}
|
||||||
|
|
||||||
if ($this->formCleaner->hasCleaned()) {
|
if ($this->formCleaner->hasCleaned()) {
|
||||||
$formStatus = $form->workspace->is_trialing ? 'Non-trial' : 'Pro';
|
$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:';
|
$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());
|
return !Str::of($field['type'])->startsWith('nf-') && !in_array($field['id'], collect($formData['properties'])->pluck('id')->toArray());
|
||||||
})->toArray());
|
})->toArray());
|
||||||
|
|
||||||
|
$form->slug = (config('app.self_hosted') && !empty($formData['slug'])) ? $formData['slug'] : $form->slug;
|
||||||
|
|
||||||
$form->update($formData);
|
$form->update($formData);
|
||||||
|
|
||||||
if ($this->formCleaner->hasCleaned()) {
|
if ($this->formCleaner->hasCleaned()) {
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,14 @@ namespace App\Http\Requests;
|
||||||
|
|
||||||
use App\Http\Requests\Workspace\CustomDomainRequest;
|
use App\Http\Requests\Workspace\CustomDomainRequest;
|
||||||
use App\Models\Forms\Form;
|
use App\Models\Forms\Form;
|
||||||
|
use App\Rules\CustomSlugRule;
|
||||||
use App\Rules\FormPropertyLogicRule;
|
use App\Rules\FormPropertyLogicRule;
|
||||||
use App\Rules\PaymentBlockConfigurationRule;
|
use App\Rules\PaymentBlockConfigurationRule;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
use Illuminate\Contracts\Validation\Validator;
|
use Illuminate\Contracts\Validation\Validator;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Abstract class to validate create/update forms
|
* Abstract class to validate create/update forms
|
||||||
|
|
@ -18,6 +20,13 @@ use Illuminate\Validation\Rule;
|
||||||
*/
|
*/
|
||||||
abstract class UserFormRequest extends \Illuminate\Foundation\Http\FormRequest
|
abstract class UserFormRequest extends \Illuminate\Foundation\Http\FormRequest
|
||||||
{
|
{
|
||||||
|
public ?Form $form;
|
||||||
|
|
||||||
|
public function __construct(Request $request)
|
||||||
|
{
|
||||||
|
$this->form = $request?->form ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
protected function prepareForValidation()
|
protected function prepareForValidation()
|
||||||
{
|
{
|
||||||
$data = $this->all();
|
$data = $this->all();
|
||||||
|
|
@ -76,8 +85,8 @@ abstract class UserFormRequest extends \Illuminate\Foundation\Http\FormRequest
|
||||||
$workspace = null;
|
$workspace = null;
|
||||||
|
|
||||||
// For update requests, try to get the workspace from the form
|
// For update requests, try to get the workspace from the form
|
||||||
if ($this->route('form')) {
|
if ($this->form) {
|
||||||
$workspace = $this->route('form')->workspace;
|
$workspace = $this->form->workspace;
|
||||||
}
|
}
|
||||||
// For create requests, get the workspace from the workspace parameter
|
// For create requests, get the workspace from the workspace parameter
|
||||||
elseif ($this->route('workspace')) {
|
elseif ($this->route('workspace')) {
|
||||||
|
|
@ -186,6 +195,7 @@ abstract class UserFormRequest extends \Illuminate\Foundation\Http\FormRequest
|
||||||
'password' => 'sometimes|nullable',
|
'password' => 'sometimes|nullable',
|
||||||
'use_captcha' => 'boolean',
|
'use_captcha' => 'boolean',
|
||||||
'captcha_provider' => ['sometimes', Rule::in(['recaptcha', 'hcaptcha'])],
|
'captcha_provider' => ['sometimes', Rule::in(['recaptcha', 'hcaptcha'])],
|
||||||
|
'slug' => [new CustomSlugRule($this->form)],
|
||||||
|
|
||||||
// Custom SEO
|
// Custom SEO
|
||||||
'seo_meta' => 'nullable|array',
|
'seo_meta' => 'nullable|array',
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Rules;
|
||||||
|
|
||||||
|
use App\Models\Forms\Form;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
|
||||||
|
class CustomSlugRule implements ValidationRule
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Create a new rule instance.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function __construct(protected ?Form $form = null)
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||||
|
{
|
||||||
|
if ($value === null || empty(trim($value)) || !config('app.self_hosted')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -158,7 +158,7 @@ Route::group(['middleware' => 'auth:api'], function () {
|
||||||
Route::prefix('forms')->name('forms.')->group(function () {
|
Route::prefix('forms')->name('forms.')->group(function () {
|
||||||
Route::post('/', [FormController::class, 'store'])->name('store');
|
Route::post('/', [FormController::class, 'store'])->name('store');
|
||||||
Route::post('/{id}/workspace/{workspace_id}', [FormController::class, 'updateWorkspace'])->name('workspace.update');
|
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::delete('/{id}', [FormController::class, 'destroy'])->name('destroy');
|
||||||
Route::get('/{id}/mobile-editor-email', [FormController::class, 'mobileEditorEmail'])->name('mobile-editor-email');
|
Route::get('/{id}/mobile-editor-email', [FormController::class, 'mobileEditorEmail'])->name('mobile-editor-email');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -189,3 +189,112 @@ it('can create form with custom scripts', function () {
|
||||||
'custom_code' => null
|
'custom_code' => null
|
||||||
]);
|
]);
|
||||||
})->skip(true, 'Trialing custom script form cleaning disabled for now.');
|
})->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'
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -261,7 +261,7 @@ if (props.form) {
|
||||||
darkMode: darkModeRef
|
darkMode: darkModeRef
|
||||||
})
|
})
|
||||||
formManager.initialize({
|
formManager.initialize({
|
||||||
submissionId: submissionId,
|
submissionId: submissionId.value,
|
||||||
urlParams: import.meta.client ? new URLSearchParams(window.location.search) : null,
|
urlParams: import.meta.client ? new URLSearchParams(window.location.search) : null,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,22 @@
|
||||||
label="Indexable by Google"
|
label="Indexable by Google"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="useFeatureFlag('self_hosted')" class="w-full border-t pt-4 mt-4">
|
||||||
|
<h4 class="font-semibold">
|
||||||
|
Custom Form URL
|
||||||
|
</h4>
|
||||||
|
<p class="text-gray-500 text-sm mb-4">
|
||||||
|
Create a custom URL for your form. This will be the unique identifier in your form's URL.
|
||||||
|
</p>
|
||||||
|
<text-input
|
||||||
|
:form="form"
|
||||||
|
name="slug"
|
||||||
|
class="mt-4 max-w-xs"
|
||||||
|
label="Custom Form URL"
|
||||||
|
help="Use only lowercase letters, numbers, and hyphens. Example: my-custom-form"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -216,7 +216,10 @@ export function useFormInitialization(formConfig, form, pendingSubmission) {
|
||||||
return opnFetch(`/forms/${slug}/submissions/${submissionIdValue}`)
|
return opnFetch(`/forms/${slug}/submissions/${submissionIdValue}`)
|
||||||
.then(submissionData => {
|
.then(submissionData => {
|
||||||
if (submissionData.data) {
|
if (submissionData.data) {
|
||||||
resetAndFill(submissionData.data)
|
resetAndFill({
|
||||||
|
...submissionData.data,
|
||||||
|
submission_id: submissionIdValue
|
||||||
|
})
|
||||||
return true
|
return true
|
||||||
} else {
|
} else {
|
||||||
console.warn(`Submission ${submissionIdValue} for form ${slug} loaded but returned no data.`)
|
console.warn(`Submission ${submissionIdValue} for form ${slug} loaded but returned no data.`)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue