From d6181cd2497c70542e7955300a5219e268fffde7 Mon Sep 17 00:00:00 2001 From: Chirag Chhatrala <60499540+chiragchhatrala@users.noreply.github.com> Date: Mon, 23 Sep 2024 23:32:38 +0530 Subject: [PATCH] Form editor v2 (#579) * Form editor v2 * fix template test * setFormDefaults when save * fix form cleaning dark mode * improvements on open sidebar * UI polish * Fix change type button --------- Co-authored-by: Julien Nahum --- api/app/Models/Forms/Form.php | 7 - api/app/Service/SeoMetaResolver.php | 217 --------- ...851_add_description_to_form_properties.php | 47 ++ api/tests/Feature/Forms/FormTest.php | 6 +- api/tests/Unit/TestHelpersTest.php | 1 - client/app.config.ts | 12 +- client/components/forms/ColorInput.vue | 6 +- client/components/forms/DateInput.vue | 2 +- client/components/forms/FileInput.vue | 34 +- client/components/forms/FlatSelectInput.vue | 16 +- client/components/forms/SignatureInput.vue | 1 + .../components/forms/components/InputHelp.vue | 7 +- .../components/forms/components/VSelect.vue | 27 +- .../{EditableDiv.vue => EditableTag.vue} | 12 +- client/components/global/Modal.vue | 2 +- .../global/Settings/SettingsSection.vue | 40 ++ .../global/Settings/SettingsSubSection.vue | 30 ++ .../global/Settings/SettingsWrapper.vue | 62 +++ .../open/editors/EditorRightSidebar.vue | 11 +- client/components/open/editors/UndoRedo.vue | 5 +- .../open/forms/OpenCompleteForm.vue | 32 +- client/components/open/forms/OpenForm.vue | 266 ++++++----- .../components/open/forms/OpenFormField.vue | 46 +- .../open/forms/components/BlockTypeIcon.vue | 26 + .../components/CustomFieldValidation.vue | 24 +- .../open/forms/components/FormEditor.vue | 264 ++++------- .../forms/components/FormEditorNavbar.vue | 150 ++++++ .../forms/components/FormFieldsEditor.vue | 444 ++++++------------ .../form-components/AddFormBlock.vue | 324 ++++--------- .../form-components/EditorSectionHeader.vue | 39 ++ .../form-components/FormAboutSubmission.vue | 284 ----------- .../components/form-components/FormAccess.vue | 101 ---- .../form-components/FormCustomCode.vue | 68 ++- .../form-components/FormCustomSeo.vue | 147 +++--- .../form-components/FormCustomization.vue | 161 +++---- .../form-components/FormEditorPreview.vue | 272 ++++++----- .../form-components/FormEditorSidebar.vue | 6 +- .../form-components/FormInformation.vue | 214 ++++----- .../form-components/FormSecurityAccess.vue | 100 ++++ .../form-components/FormSecurityPrivacy.vue | 67 --- .../form-components/FormSettings.vue | 40 ++ .../FormSubmissionSettings.vue | 231 +++++++++ .../ConditionEditor.client.vue | 11 +- .../FormBlockLogicEditor.vue | 22 +- .../open/forms/fields/FormFieldEdit.vue | 379 +++++++-------- .../forms/fields/components/BlockOptions.vue | 218 ++++----- .../fields/components/ChangeFieldType.vue | 74 ++- .../forms/fields/components/FieldOptions.vue | 196 +++----- .../components/HiddenRequiredDisabled.vue | 116 +++++ .../fields/components/MatrixFieldOptions.vue | 13 +- .../forms/create/CreateFormBaseModal.vue | 2 +- .../pages/forms/show/FormCleanings.vue | 2 +- client/composables/forms/initForm.js | 52 +- client/data/blocks_types.json | 162 +++++++ client/middleware/self-hosted.js | 5 +- client/nuxt.config.ts | 7 +- client/pages/forms/[slug]/index.vue | 6 +- client/pages/forms/create/guest.vue | 41 +- client/pages/forms/create/index.vue | 3 +- client/stores/working_form.js | 76 +-- client/tailwind.config.js | 1 + 61 files changed, 2576 insertions(+), 2661 deletions(-) delete mode 100644 api/app/Service/SeoMetaResolver.php create mode 100644 api/database/migrations/2024_09_23_054851_add_description_to_form_properties.php rename client/components/global/{EditableDiv.vue => EditableTag.vue} (85%) create mode 100644 client/components/global/Settings/SettingsSection.vue create mode 100644 client/components/global/Settings/SettingsSubSection.vue create mode 100644 client/components/global/Settings/SettingsWrapper.vue create mode 100644 client/components/open/forms/components/BlockTypeIcon.vue create mode 100644 client/components/open/forms/components/FormEditorNavbar.vue create mode 100644 client/components/open/forms/components/form-components/EditorSectionHeader.vue delete mode 100644 client/components/open/forms/components/form-components/FormAboutSubmission.vue delete mode 100644 client/components/open/forms/components/form-components/FormAccess.vue create mode 100644 client/components/open/forms/components/form-components/FormSecurityAccess.vue delete mode 100644 client/components/open/forms/components/form-components/FormSecurityPrivacy.vue create mode 100644 client/components/open/forms/components/form-components/FormSettings.vue create mode 100644 client/components/open/forms/components/form-components/FormSubmissionSettings.vue create mode 100644 client/components/open/forms/fields/components/HiddenRequiredDisabled.vue create mode 100644 client/data/blocks_types.json diff --git a/api/app/Models/Forms/Form.php b/api/app/Models/Forms/Form.php index 49d3c3c1..7ff7e8d0 100644 --- a/api/app/Models/Forms/Form.php +++ b/api/app/Models/Forms/Form.php @@ -48,7 +48,6 @@ class Form extends Model implements CachableAttributes 'removed_properties', 'title', - 'description', 'tags', 'visibility', @@ -177,12 +176,6 @@ class Form extends Model implements CachableAttributes }); } - public function setDescriptionAttribute($value) - { - // Strip out unwanted html - $this->attributes['description'] = Purify::clean($value); - } - public function setSubmittedTextAttribute($value) { // Strip out unwanted html diff --git a/api/app/Service/SeoMetaResolver.php b/api/app/Service/SeoMetaResolver.php deleted file mode 100644 index 3f05d86c..00000000 --- a/api/app/Service/SeoMetaResolver.php +++ /dev/null @@ -1,217 +0,0 @@ - '/', - 'form_show' => '/forms/{slug}', - 'login' => '/login', - 'register' => '/register', - 'reset_password' => '/password/reset', - 'privacy_policy' => '/privacy-policy', - 'terms_conditions' => '/terms-conditions', - 'integrations' => '/integrations', - 'templates' => '/form-templates', - 'templates_show' => '/form-templates/{slug}', - 'templates_types_show' => '/form-templates/types/{slug}', - 'templates_industries_show' => '/form-templates/industries/{slug}', - ]; - - /** - * Metas for simple route (without needing to access DB) - */ - public const PATTERN_STATIC_META = [ - 'login' => [ - 'title' => 'Login', - ], - 'register' => [ - 'title' => 'Create your account', - ], - 'reset_password' => [ - 'title' => 'Reset your password', - ], - 'privacy_policy' => [ - 'title' => 'Our Privacy Policy', - ], - 'terms_conditions' => [ - 'title' => 'Our Terms & Conditions', - ], - 'integrations' => [ - 'title' => 'Our Integrations', - ], - 'templates' => [ - 'title' => 'Templates', - 'description' => 'Our collection of beautiful templates to create your own forms!', - ], - ]; - - public const META_CACHE_DURATION = 60 * 60 * 12; // 12 hours - - public const META_CACHE_KEY_PREFIX = 'seo_meta_'; - - public function __construct(private Request $request) - { - } - - /** - * Returns the right metas for a given route, caches meta for 1 hour. - */ - public function getMetas(): array - { - $cacheKey = self::META_CACHE_KEY_PREFIX.urlencode($this->request->path()); - - return Cache::remember($cacheKey, now()->addSeconds(self::META_CACHE_DURATION), function () { - $pattern = $this->resolvePattern(); - - if ($this->hasPatternMetaGetter($pattern)) { - // Custom function for pattern - try { - return array_merge($this->getDefaultMeta(), $this->{'get'.Str::studly($pattern).'Meta'}()); - } catch (\Exception $e) { - return $this->getDefaultMeta(); - } - } elseif (in_array($pattern, array_keys(self::PATTERN_STATIC_META))) { - // Simple meta for pattern - $meta = self::PATTERN_STATIC_META[$pattern]; - if (isset($meta['title'])) { - $meta['title'] .= $this->titleSuffix(); - } - if (isset($meta['image'])) { - $meta['image'] = asset($meta['image']); - } - - return array_merge($this->getDefaultMeta(), $meta); - } - - return $this->getDefaultMeta(); - }); - } - - /** - * Simulates the Laravel router to match route with Metas - * - * @return string - */ - private function resolvePattern() - { - foreach (self::URL_PATTERNS as $patternName => $patternData) { - $path = rtrim($this->request->getPathInfo(), '/') ?: '/'; - - $route = (new Route('GET', $patternData, fn () => ''))->bind($this->request); - if (preg_match($route->getCompiled()->getRegex(), rawurldecode($path))) { - $this->patternData = $route->parameters(); - - return $patternName; - } - } - - return 'default'; - } - - /** - * Determine if a get mutator exists for a pattern. - * - * @param string $key - * @return bool - */ - private function hasPatternMetaGetter($key) - { - return method_exists($this, 'get'.Str::studly($key).'Meta'); - } - - private function titleSuffix() - { - return ' ยท '.config('app.name'); - } - - private function getDefaultMeta(): array - { - return [ - 'title' => 'Create beautiful forms for free'.$this->titleSuffix(), - 'description' => "Create beautiful forms for free. Unlimited fields, unlimited submissions. It's free and it takes less than 1 minute to create your first form.", - 'image' => asset('/img/social-preview.jpg'), - ]; - } - - private function getFormShowMeta(): array - { - $form = Form::whereSlug($this->patternData['slug'])->firstOrFail(); - - $meta = []; - if ($form->is_pro && $form->seo_meta->page_title) { - $meta['title'] = $form->seo_meta->page_title; - } else { - $meta['title'] = $form->title.$this->titleSuffix(); - } - - if ($form->is_pro && $form->seo_meta->page_description) { - $meta['description'] = $form->seo_meta->page_description; - } elseif ($form->description) { - $meta['description'] = Str::of($form->description)->limit(160); - } - - if ($form->is_pro && $form->seo_meta->page_thumbnail) { - $meta['image'] = $form->seo_meta->page_thumbnail; - } elseif ($form->cover_picture) { - $meta['image'] = $form->cover_picture; - } - - return $meta; - } - - private function getTemplatesShowMeta(): array - { - $template = Template::whereSlug($this->patternData['slug'])->firstOrFail(); - - return [ - 'title' => $template->name.$this->titleSuffix(), - 'description' => Str::of($template->short_description)->limit(140).' | Customize any template and create your own form in minutes.', - 'image' => $template->image_url, - ]; - } - - private function getTemplatesTypesShowMeta(): array - { - $types = json_decode(file_get_contents(resource_path('data/forms/templates/types.json')), true); - $type = $types[array_search($this->patternData['slug'], array_column($types, 'slug'))]; - - return [ - 'title' => $type['meta_title'], - 'description' => Str::of($type['meta_description'])->limit(140), - ]; - } - - private function getTemplatesIndustriesShowMeta(): array - { - $industries = json_decode(file_get_contents(resource_path('data/forms/templates/industries.json')), true); - $industry = $industries[array_search($this->patternData['slug'], array_column($industries, 'slug'))]; - - return [ - 'title' => $industry['meta_title'], - 'description' => Str::of($industry['meta_description'])->limit(140), - ]; - } -} diff --git a/api/database/migrations/2024_09_23_054851_add_description_to_form_properties.php b/api/database/migrations/2024_09_23_054851_add_description_to_form_properties.php new file mode 100644 index 00000000..c534d8c0 --- /dev/null +++ b/api/database/migrations/2024_09_23_054851_add_description_to_form_properties.php @@ -0,0 +1,47 @@ +properties; + if (!empty($form->description)) { + array_unshift($properties, [ + 'type' => 'nf-text', + 'content' => $form->description, + 'name' => 'Description', + 'id' => Str::uuid() + ]); + $form->properties = $properties; + $form->timestamps = false; + $form->save(); + } + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Form::chunk(100, function ($forms) { + foreach ($forms as $form) { + $properties = $form->properties; + if (!empty($properties) && $properties[0]['type'] === 'nf-text' && $properties[0]['name'] === 'Description') { + array_shift($properties); + $form->properties = $properties; + $form->save(); + } + } + }); + } +}; diff --git a/api/tests/Feature/Forms/FormTest.php b/api/tests/Feature/Forms/FormTest.php index 864f4b5b..8da10af2 100644 --- a/api/tests/Feature/Forms/FormTest.php +++ b/api/tests/Feature/Forms/FormTest.php @@ -65,8 +65,7 @@ it('can update a form', function () { $this->assertDatabaseHas('forms', [ 'id' => $form->id, - 'title' => $form->title, - 'description' => $form->description, + 'title' => $form->title ]); }); @@ -125,8 +124,7 @@ it('can duplicate a form', function () { expect($workspace->forms()->count())->toBe(2); $this->assertDatabaseHas('forms', [ 'id' => $response->json('new_form.id'), - 'title' => 'Copy of ' . $form->title, - 'description' => $form->description, + 'title' => 'Copy of ' . $form->title ]); }); diff --git a/api/tests/Unit/TestHelpersTest.php b/api/tests/Unit/TestHelpersTest.php index d8b71549..ccfa7c05 100644 --- a/api/tests/Unit/TestHelpersTest.php +++ b/api/tests/Unit/TestHelpersTest.php @@ -18,6 +18,5 @@ it('can make a form for a database', function () { $workspace = $this->createUserWorkspace($user); $form = $this->makeForm($user, $workspace); expect($form->title)->not()->toBeNull(); - expect($form->description)->not()->toBeNull(); expect(count($form->properties))->not()->toBe(0); }); diff --git a/client/app.config.ts b/client/app.config.ts index f1e2039d..a7181a0b 100644 --- a/client/app.config.ts +++ b/client/app.config.ts @@ -1,6 +1,16 @@ export default defineAppConfig({ ui: { primary: 'blue', - gray: 'slate' + gray: 'slate', + + tabs: { + wrapper:'space-y-0', + list: { + height: 'h-auto', + tab: { + height: 'h-[30px]' + } + } + } } }) diff --git a/client/components/forms/ColorInput.vue b/client/components/forms/ColorInput.vue index 491afd28..f7a7dcd4 100644 --- a/client/components/forms/ColorInput.vue +++ b/client/components/forms/ColorInput.vue @@ -14,7 +14,11 @@ :name="name" > - {{ label }} + {{ label }} { try { return format(new Date(value), props.dateFormat + (props.timeFormat == 12 ? ' p':' HH:mm')) } catch (e) { - console.log('Error formatting date', e) + console.error('Error formatting date', e) return '' } } diff --git a/client/components/forms/FileInput.vue b/client/components/forms/FileInput.vue index 5a0e8adb..894e5963 100644 --- a/client/components/forms/FileInput.vue +++ b/client/components/forms/FileInput.vue @@ -98,35 +98,14 @@ @@ -143,13 +122,12 @@ import {inputProps, useFormInput} from './useFormInput.js' import InputWrapper from './components/InputWrapper.vue' import UploadedFile from './components/UploadedFile.vue' -import OpenFormButton from '../open/forms/OpenFormButton.vue' import CameraUpload from './components/CameraUpload.vue' import {storeFile} from "~/lib/file-uploads.js" export default { name: 'FileInput', - components: {InputWrapper, UploadedFile, OpenFormButton}, + components: {InputWrapper, UploadedFile, CameraUpload}, mixins: [], props: { ...inputProps, diff --git a/client/components/forms/FlatSelectInput.vue b/client/components/forms/FlatSelectInput.vue index a0aa6090..7baabf5f 100644 --- a/client/components/forms/FlatSelectInput.vue +++ b/client/components/forms/FlatSelectInput.vue @@ -109,7 +109,8 @@ export default { loading: {type: Boolean, default: false}, multiple: { type: Boolean, default: false }, disableOptions: { type: Array, default: () => [] }, - disableOptionsTooltip: {type: String, default: "Not allowed"}, + disableOptionsTooltip: { type: String, default: "Not allowed" }, + clearable: { type: Boolean, default: false }, }, setup(props, context) { return { @@ -129,11 +130,11 @@ export default { if (this.multiple) { const emitValue = Array.isArray(this.compVal) ? [...this.compVal] : [] - // Already in value, remove it + // Already in value, remove it only if clearable or not the last item if (this.isSelected(value)) { - this.compVal = emitValue.filter((item) => { - return item !== value - }) + if (this.clearable || emitValue.length > 1) { + this.compVal = emitValue.filter((item) => item !== value) + } return } @@ -141,7 +142,10 @@ export default { emitValue.push(value) this.compVal = emitValue } else { - this.compVal = this.compVal === value ? null : value + // For single select, only change value if it's different or clearable + if (this.compVal !== value || this.clearable) { + this.compVal = this.compVal === value && this.clearable ? null : value + } } }, isSelected(value) { diff --git a/client/components/forms/SignatureInput.vue b/client/components/forms/SignatureInput.vue index 4b04aaa1..22b3911c 100644 --- a/client/components/forms/SignatureInput.vue +++ b/client/components/forms/SignatureInput.vue @@ -41,6 +41,7 @@ - + diff --git a/client/components/forms/components/VSelect.vue b/client/components/forms/components/VSelect.vue index 66206e09..f4114d66 100644 --- a/client/components/forms/components/VSelect.vue +++ b/client/components/forms/components/VSelect.vue @@ -78,7 +78,7 @@