diff --git a/api/app/Http/Requests/AnswerFormRequest.php b/api/app/Http/Requests/AnswerFormRequest.php index 75eec8217..0edc1989d 100644 --- a/api/app/Http/Requests/AnswerFormRequest.php +++ b/api/app/Http/Requests/AnswerFormRequest.php @@ -14,6 +14,7 @@ use Illuminate\Http\Request; use Illuminate\Support\Str; use Illuminate\Validation\Rule; +use Stevebauman\Purify\Facades\Purify; class AnswerFormRequest extends FormRequest { @@ -67,11 +68,16 @@ public function rules() if (isset($data[$field['id']]) && is_array($data[$field['id']])) { $data[$field['id']] = array_map(function ($val) use ($field) { $tmpop = collect($field[$field['type']]['options'])->first(function ($op) use ($val) { - return $op['id'] ?? $op['value'] === $val; + return isset($op['id'], $op['name']) && ($op['id'] === $val || $op['name'] === $val); }); - - return isset($tmpop['name']) ? $tmpop['name'] : ''; + return isset($tmpop['name']) ? $tmpop['name'] : $val; }, $data[$field['id']]); + } elseif (isset($data[$field['id']])) { + // Handle single select values + $tmpop = collect($field[$field['type']]['options'])->first(function ($op) use ($field, $data) { + return isset($op['id'], $op['name']) && ($op['id'] === $data[$field['id']] || $op['name'] === $data[$field['id']]); + }); + $data[$field['id']] = isset($tmpop['name']) ? $tmpop['name'] : $data[$field['id']]; } } if (FormLogicPropertyResolver::isRequired($property, $data)) { @@ -167,6 +173,7 @@ private function getPropertyRules($property): array { switch ($property['type']) { case 'text': + case 'rich_text': case 'signature': return ['string']; case 'number': @@ -280,6 +287,10 @@ protected function prepareForValidation() if ($property['type'] === 'phone_number' && (!isset($property['use_simple_text_input']) || !$property['use_simple_text_input']) && $receivedValue && in_array($receivedValue, $countryCodeMapper)) { $mergeData[$property['id']] = null; } + + if ($property['type'] === 'rich_text' && $receivedValue) { + $mergeData[$property['id']] = Purify::clean($receivedValue); + } }); $this->merge($mergeData); diff --git a/api/tests/Feature/Forms/FormLogicTest.php b/api/tests/Feature/Forms/FormLogicTest.php index 456824547..81d038c4d 100644 --- a/api/tests/Feature/Forms/FormLogicTest.php +++ b/api/tests/Feature/Forms/FormLogicTest.php @@ -113,3 +113,238 @@ ], ]); }); + +it('preserves multi-select values during validation with logic conditions', function () { + $user = $this->actingAsUser(); + $workspace = $this->createUserWorkspace($user); + + // Create a form with a multi-select field and a text field that has logic based on the multi-select + $form = $this->createForm($user, $workspace, [ + 'properties' => [ + [ + 'id' => 'multi_select_field', + 'name' => 'Multi Select Field', + 'type' => 'multi_select', + 'hidden' => false, + 'required' => true, + 'multi_select' => [ + 'options' => [ + ['id' => 'option1', 'name' => 'Option 1'], + ['id' => 'option2', 'name' => 'Option 2'], + ['id' => 'option3', 'name' => 'Option 3'] + ] + ] + ], + [ + 'id' => 'text_field', + 'name' => 'Text Field', + 'type' => 'text', + 'hidden' => false, + 'required' => false, + 'logic' => [ + 'conditions' => [ + 'operatorIdentifier' => 'and', + 'children' => [ + [ + 'identifier' => 'multi_select', + 'value' => [ + 'operator' => 'contains', + 'property_meta' => [ + 'id' => 'multi_select_field', + 'type' => 'multi_select' + ], + 'value' => 'Option 1' + ] + ] + ] + ], + 'actions' => ['require-answer'] + ] + ] + ] + ]); + + // Submit form data with multi-select values + $formData = [ + 'multi_select_field' => ['Option 1', 'Option 2'] + ]; + + ray($formData)->blue('Original form data'); + + $response = $this->postJson(route('forms.answer', $form->slug), $formData); + + // The validation should fail because text_field is required when Option 1 is selected + $response->assertStatus(422) + ->assertJson([ + 'message' => 'The Text Field field is required.', + 'errors' => [ + 'text_field' => ['The Text Field field is required.'] + ] + ]); + + // Check that the multi-select values were preserved in the validation data + ray($response->json())->purple('Response data'); + expect($response->json('errors.multi_select_field'))->toBeNull(); +}); + +it('correctly handles multi-select values with complex form logic', function () { + $user = $this->actingAsUser(); + $workspace = $this->createUserWorkspace($user); + + // Create form with the specific fields from your example + $form = $this->createForm($user, $workspace, [ + 'properties' => [ + [ + 'id' => '93c8ebe9-b1ba-42ce-841c-bf3b9be1ca4b', + 'name' => 'Which event would you like to join?', + 'type' => 'multi_select', + 'hidden' => false, + 'required' => true, + 'multi_select' => [ + 'options' => [ + ['id' => 'Ashkelon Run (March 21)', 'name' => 'Ashkelon Run (March 21)'], + ['id' => 'Jerusalem Marathon (April 4)', 'name' => 'Jerusalem Marathon (April 4)'], + ['id' => 'Neither', 'name' => 'Neither'] + ] + ] + ], + [ + 'id' => '0ca51469-6bda-40f4-831c-084f066643d7', + 'name' => 'Jerusalem Marathon - Run Options', + 'type' => 'select', + 'hidden' => true, + 'required' => false, + 'select' => [ + 'options' => [ + ['id' => '10km (Most popular)', 'name' => '10km (Most popular)'] + ] + ], + 'logic' => [ + 'conditions' => [ + 'operatorIdentifier' => 'and', + 'children' => [ + [ + 'identifier' => '93c8ebe9-b1ba-42ce-841c-bf3b9be1ca4b', + 'value' => [ + 'operator' => 'contains', + 'property_meta' => [ + 'id' => '93c8ebe9-b1ba-42ce-841c-bf3b9be1ca4b', + 'type' => 'multi_select' + ], + 'value' => 'Jerusalem Marathon (April 4)' + ] + ] + ] + ], + 'actions' => ['require-answer', 'show-block'] + ] + ] + ] + ]); + + // Submit form data matching your payload + $formData = [ + '93c8ebe9-b1ba-42ce-841c-bf3b9be1ca4b' => ['Jerusalem Marathon (April 4)'], + '0ca51469-6bda-40f4-831c-084f066643d7' => '10km (Most popular)' + ]; + + ray($formData)->blue('Original form data'); + + $response = $this->postJson(route('forms.answer', $form->slug), $formData); + + ray($response->json())->purple('Response data'); + + // Should be successful since all required fields are filled + $response->assertSuccessful() + ->assertJson([ + 'type' => 'success', + 'message' => 'Form submission saved.' + ]); + + // Now let's verify the saved submission data + $submission = $form->submissions()->first(); + expect($submission->data['93c8ebe9-b1ba-42ce-841c-bf3b9be1ca4b'])->toBe(['Jerusalem Marathon (April 4)']); +}); + +it('preserves multi-select values when building validation rules', function () { + $user = $this->actingAsUser(); + $workspace = $this->createUserWorkspace($user); + + // Create form with the exact fields from your real form + $form = $this->createForm($user, $workspace, [ + 'properties' => [ + [ + 'id' => '93c8ebe9-b1ba-42ce-841c-bf3b9be1ca4b', + 'name' => 'Which event would you like to join?', + 'type' => 'multi_select', + 'required' => true, + 'multi_select' => [ + 'options' => [ + ['id' => 'Jerusalem Marathon (April 4)', 'name' => 'Jerusalem Marathon (April 4)'], + ['id' => 'Ashkelon Run (March 21)', 'name' => 'Ashkelon Run (March 21)'] + ] + ] + ], + [ + 'id' => '72565901-c345-427a-b988-0ce3de9ad9f4', + 'name' => 'Additional Days', + 'type' => 'multi_select', + 'required' => false, + 'multi_select' => [ + 'options' => [ + ['id' => 'Thursday', 'name' => 'Thursday'], + ['id' => 'Sunday', 'name' => 'Sunday'] + ] + ], + 'logic' => [ + 'conditions' => [ + 'operatorIdentifier' => 'and', + 'children' => [ + [ + 'identifier' => '93c8ebe9-b1ba-42ce-841c-bf3b9be1ca4b', + 'value' => [ + 'operator' => 'contains', + 'property_meta' => [ + 'id' => '93c8ebe9-b1ba-42ce-841c-bf3b9be1ca4b', + 'type' => 'multi_select' + ], + 'value' => 'Ashkelon Run (March 21)' + ] + ] + ] + ], + 'actions' => ['require-answer'] + ] + ] + ] + ]); + + // Submit form data with Jerusalem Marathon + $formData = [ + '93c8ebe9-b1ba-42ce-841c-bf3b9be1ca4b' => ['Jerusalem Marathon (April 4)'] + ]; + + ray($formData)->blue('Original form data'); + + $response = $this->postJson(route('forms.answer', $form->slug), $formData); + + ray($response->json())->purple('Response data'); + + // Should be successful since Jerusalem Marathon doesn't require Additional Days + $response->assertSuccessful(); + + // Now try with Ashkelon Run which requires Additional Days + $formData = [ + '93c8ebe9-b1ba-42ce-841c-bf3b9be1ca4b' => ['Ashkelon Run (March 21)'] + ]; + + $response = $this->postJson(route('forms.answer', $form->slug), $formData); + + // Should fail because Additional Days is required when Ashkelon Run is selected + $response->assertStatus(422) + ->assertJson([ + 'errors' => [ + '72565901-c345-427a-b988-0ce3de9ad9f4' => ['The Additional Days field is required.'] + ] + ]); +}); diff --git a/client/components/open/forms/OpenFormField.vue b/client/components/open/forms/OpenFormField.vue index de3ae22c0..a025cf1d3 100644 --- a/client/components/open/forms/OpenFormField.vue +++ b/client/components/open/forms/OpenFormField.vue @@ -210,6 +210,7 @@ export default { } return { text: 'TextInput', + rich_text: 'RichTextAreaInput', number: 'TextInput', rating: 'RatingInput', scale: 'ScaleInput', diff --git a/client/components/open/forms/components/FormEditorErrorHandler.vue b/client/components/open/forms/components/FormEditorErrorHandler.vue index 42c969f01..ab09bae5f 100644 --- a/client/components/open/forms/components/FormEditorErrorHandler.vue +++ b/client/components/open/forms/components/FormEditorErrorHandler.vue @@ -69,7 +69,7 @@ errorReport += ` And here are technical details about the error: \`\`\`${error.stack}\`\`\`` try { crisp.openAndShowChat(errorReport) - crisp.showMessage(`Hi there, we're very sorry to hear you experienced an issue with NoteForms. + crisp.showMessage(`Hi there, we're very sorry to hear you experienced an issue with OpnForm. We'll be in touch about it very soon! In the meantime, I recommend that you try going back one step, and save your changes.`, 2000) } catch (e) { console.error('Crisp error', e) diff --git a/client/components/open/forms/fields/components/FieldOptions.vue b/client/components/open/forms/fields/components/FieldOptions.vue index cb05c3a00..cbfba69f3 100644 --- a/client/components/open/forms/fields/components/FieldOptions.vue +++ b/client/components/open/forms/fields/components/FieldOptions.vue @@ -448,8 +448,15 @@ :multiple="field.multiple === true" :move-to-form-assets="true" /> + <rich-text-area-input + v-else-if="field.type === 'rich_text'" + name="prefill" + class="mt-3" + :form="field" + label="Pre-filled value" + /> <text-input - v-else-if="!['files', 'signature'].includes(field.type)" + v-else-if="!['files', 'signature', 'rich_text'].includes(field.type)" name="prefill" class="mt-3" :form="field" diff --git a/client/components/open/tables/OpenTable.vue b/client/components/open/tables/OpenTable.vue index 9807d7f2a..d0687c898 100644 --- a/client/components/open/tables/OpenTable.vue +++ b/client/components/open/tables/OpenTable.vue @@ -193,6 +193,7 @@ export default { rafId: null, fieldComponents: { text: shallowRef(OpenText), + rich_text: shallowRef(OpenText), number: shallowRef(OpenText), rating: shallowRef(OpenText), scale: shallowRef(OpenText), diff --git a/client/components/open/tables/components/OpenText.vue b/client/components/open/tables/components/OpenText.vue index 09d00796d..783aa4570 100644 --- a/client/components/open/tables/components/OpenText.vue +++ b/client/components/open/tables/components/OpenText.vue @@ -1,7 +1,5 @@ <template> - <span> - {{ value }} - </span> + <div v-html="value" /> </template> <script> diff --git a/client/components/pages/pricing/CustomPlan.vue b/client/components/pages/pricing/CustomPlan.vue index 53b454122..d0bab23ed 100644 --- a/client/components/pages/pricing/CustomPlan.vue +++ b/client/components/pages/pricing/CustomPlan.vue @@ -159,7 +159,7 @@ <template v-else>$200</template> </span> <span class="text-sm font-medium leading-6 text-gray-600"> - starting from {{ isYearly ? 'per year' : 'per month' }} + starting from per month </span> </p> <div class="flex justify-center"> diff --git a/client/data/blocks_types.json b/client/data/blocks_types.json index 0a1ac7b9b..2e95d1369 100644 --- a/client/data/blocks_types.json +++ b/client/data/blocks_types.json @@ -8,6 +8,15 @@ "text_class": "text-blue-900", "is_input": true }, + "rich_text": { + "name": "rich_text", + "title": "Rich Text Input", + "icon": "i-heroicons-bars-3-20-solid", + "default_block_name": "Description", + "bg_class": "bg-blue-100", + "text_class": "text-blue-900", + "is_input": true + }, "date": { "name": "date", "title": "Date Input", diff --git a/client/pages/home.vue b/client/pages/home.vue index c8425a42e..f0686d169 100644 --- a/client/pages/home.vue +++ b/client/pages/home.vue @@ -199,7 +199,7 @@ <template #description> <div class="flex flex-wrap sm:flex-nowrap gap-4 items-start"> <p class="flex-grow"> - Remove NoteForms branding, customize forms further, use your custom domain, integrate with your + Remove OpnForm branding, customize forms further, use your custom domain, integrate with your favorite tools, invite users, and more! </p> <UButton diff --git a/client/pages/subscriptions/success.vue b/client/pages/subscriptions/success.vue index 2941c5445..ea7dc66a7 100644 --- a/client/pages/subscriptions/success.vue +++ b/client/pages/subscriptions/success.vue @@ -44,7 +44,7 @@ const redirectIfSubscribed = () => { } const checkSubscription = () => { // Fetch the user. - return noteFormsFetch('user').then((data) => { + return opnFetch('user').then((data) => { authStore.setUser(data) redirectIfSubscribed() }).catch((error) => {