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 @@