Migrate front-end to Nuxt app (#284)

* wip

* Managed to load a page

* Stuck at changing routes

* Fixed the router, and editable div

* WIP

* Fix app loader

* WIP

* Fix check-auth middleware

* Started to refactor input components

* WIP

* Added select input, v-click-outside for vselect

* update vselect & phone input

* Fixed the mixin

* input component updates

* Fix signature input import

* input component updates in vue3

* image input in vue3

* small fixes

* fix useFormInput watcher

* scale input in vue3

* Vue3: migrating from vuex to Pinia (#249)

* Vue3: migrating from vuex to Pinia

* toggle input fixes

* update configureCompat

---------

Co-authored-by: Forms Dev <chirag+new@notionforms.io>

* support vue3 query builder

* Refactor inpus

* fix: Vue3 Query Builder - Logic Editor (#251)

* support vue3 query builder

* upgrade

* remove local from middleware

* Submission table pagination & migrate chart to vue3 (#254)

* Submission table Pagination in background

* migrate chart to vue3

* Form submissions pagination

* Form submissions

* Fix form starts

* Fix openSelect key issue

---------

Co-authored-by: Forms Dev <chirag+new@notionforms.io>
Co-authored-by: Julien Nahum <julien@nahum.net>

* Vue 3 better animation (#257)

* vue-3-better-animation

* Working on migration to vueuse/motion

* Form sidebar animations

* Clean code

* Added animations for modal

* Finished implementing better animations

---------

Co-authored-by: Forms Dev <chirag+new@notionforms.io>

* Work in progress

* Migrating amplitude and crisp plugin/composable

* Started to refactor pages

* WIP

* vue3-scroll-shadow-fixes (#260)

* WIP

* WIP

* WIP

* Figured out auth & middlewares

* WI

* Refactoring stores and templates pages to comp. api

* Finishing the templates pages

* fix collapsible

* Finish reworking most templates pages

* Reworked workspaces store

* Working on home page and modal

* Fix dropdown

* Fix modal

* Fixed form creation

* Fixed most of the form/show pages

* Updated cors dependency

* fix custom domain warning

* NuxtLink migration (#262)

Co-authored-by: Forms Dev <chirag+new@notionforms.io>

* Tiny fixes + start pre-rendering

* migrate-to-nuxt-useappconfig (#263)

* migrate-to-nuxt-useappconfig

* defineAppConfig

---------

Co-authored-by: Forms Dev <chirag+new@notionforms.io>

* Working on form/show and editor

* Globally import form inputs to fix resolve

* Remove vform - working on form public page

* Remove initform mixin

* Work in progress for form create guess user

* Nuxt Migration notifications (#265)

* Nuxt Migration notifications

* @input to @update:model-value

* change field type fixes

* @update:model-value

* Enable form-block-logic-editor

* vue-confetti migration

* PR request changes

* useAlert in setup

* Migrate to nuxt settings page AND remove axios (#266)

* Settings pages migration

* remove axios and use opnFetch

* Make created form reactive (#267)

* Remove verify pages and axios lib

---------

Co-authored-by: Julien Nahum <julien@nahum.net>

* Fix alert styling + bug fixes and cleaning

* Refactor notifications + add shadow

* Fix vselect issue

* Working on page pre-rendering

* Created NotionPages store

* Added sitemap on nuxt side

* Sitemap done, working on aws amplify

* Adding missing module

* Remove axios and commit backend changes to sitemap

* Fix notifications

* fix guestpage editor (#269)

Co-authored-by: Julien Nahum <julien@nahum.net>

* Remove appconfig in favor of runtimeconfig

* Fixed amplitude bugs, and added staging environment

* Added amplify file

* Change basdirectory amplify

* Fix loading bar position

* Fix custom redirect (#273)

* Dirty form handling - nuxt migration (#272)

* SEO meta nuxt migration (#274)

* SEO meta nuxt migration

* Polish seo metas, add defaults for OG and twitter

---------

Co-authored-by: Julien Nahum <julien@nahum.net>

* migrate to nuxt useClipboard (#268)

* Set middleware on pages (#278)

* Se middleware on pages

* Se middleware on account page

* add robots.txt (#276)

* 404 page migration (#277)

* Templates pages migration (#275)

* NuxtImg Migration (#279)

Co-authored-by: Julien Nahum <julien@nahum.net>

* Update package json

* Fix build script

* Add loglevel param

* Disable page pre-rendering

* Attempt to allow svgs

* Fix SVGs with NuxtImage

* Add .env file at AWS build time

* tRGIGGER deploy

* Fix issue

* ANother attrempt

* Fix typo

* Fix env?

* Attempt to simplify build

* Enable swr caching instead of prerenderign

* Better image compression

* Last attempt at nuxt images efficiency

* Improve image optimization again

* Remove NuxtImg for non asset files

* Restore templates pages cache

* Remove useless images + fix templates show page

* image optimization caching + fix hydratation issue form template page

* URL generation (front&back) + fixed authJWT for SSR

* Fix composable issue

* Fix form share page

* Embeddable form as a nuxt middleware

* Fix URL for embeddable middleware

* Debugging embeddable on amplify

* Add custom domain support

* No follow for non-production env

* Fix sentry nuxt and custom domain redirect

* remove api prefix from routes (#280)

* remove api prefix from routes

* PR changes

---------

Co-authored-by: Julien Nahum <julien@nahum.net>

* nuxt migration -file upload - WIP (#271)

Co-authored-by: Julien Nahum <julien@nahum.net>

* Fix local file upload

* Fix file submissions preview

* API redirect to back-end from nuxt

* API redirect to back-end from nuxt

* Remove old JS app, update deploy script

* Fix tests, added gh action nuxt step

* Updated package-lock.json

* Setup node in GH Nuxt action

* Setup client directory for GH workflow

---------

Co-authored-by: Forms Dev <chirag+new@notionforms.io>
Co-authored-by: Chirag Chhatrala <60499540+chiragchhatrala@users.noreply.github.com>
Co-authored-by: Rishi Raj Jain <rishi18304@iiitd.ac.in>
Co-authored-by: formsdev <136701234+formsdev@users.noreply.github.com>
This commit is contained in:
Julien Nahum 2024-01-15 12:14:47 +01:00 committed by GitHub
parent c01f566ba9
commit 0adce5a2ff
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
478 changed files with 27676 additions and 34120 deletions

16
.eslintrc.js vendored
View File

@ -1,16 +0,0 @@
module.exports = {
root: true,
parser: 'vue-eslint-parser',
parserOptions: {
parser: '@babel/eslint-parser',
ecmaVersion: 2018,
sourceType: 'module'
},
extends: [
'plugin:vue/recommended',
'standard'
],
rules: {
'vue/max-attributes-per-line': 'off'
}
}

View File

@ -86,13 +86,6 @@ jobs:
restore-keys: | restore-keys: |
${{ runner.os }}-composer- ${{ runner.os }}-composer-
- uses: actions/cache@v2
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Setup PHP - name: Setup PHP
uses: shivammathur/setup-php@v2 uses: shivammathur/setup-php@v2
with: with:
@ -128,6 +121,31 @@ jobs:
path: storage/logs/laravel.log path: storage/logs/laravel.log
retention-days: 3 retention-days: 3
build-nuxt-app:
runs-on: ubuntu-latest
name: Build the Nuxt app
defaults:
run:
working-directory: ./client
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Set up Node.js
uses: actions/setup-node@v1
with:
node-version: '20'
- uses: actions/cache@v2
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Prepare the environment
run: cp .env.example .env
- name: Install npm dependencies - name: Install npm dependencies
run: npm install --no-audit --no-progress --silent run: npm install --no-audit --no-progress --silent

View File

@ -67,7 +67,7 @@ It takes 1 minute to try out the builder for free. You'll have high availability
### Docker installation 🐳 ### Docker installation 🐳
There's a `Dockerfile` for building a self-contained docker image including databases, webservers etc. > ⚠️ **Warning**: the Docker setup is currently not working as we're migrating the front-end to Nuxt. [Track progress here](https://github.com/JhumanJ/OpnForm/issues/283).
This can be built and run locally but is also hosted publicly on docker hub at `jhumanj/opnform` and is generally best run directly from there. This can be built and run locally but is also hosted publicly on docker hub at `jhumanj/opnform` and is generally best run directly from there.
@ -154,8 +154,11 @@ First, let's work with the codebase and its dependencies.
# Get the code! # Get the code!
git clone git@github.com:JhumanJ/OpnForm.git && cd OpnForm git clone git@github.com:JhumanJ/OpnForm.git && cd OpnForm
# Install PHP and JS dependencies # Install PHP dependencies
composer install && npm install composer install
# Install JS dependencies
cd client && npm install
# Compile assets (see the scripts section in package.json) # Compile assets (see the scripts section in package.json)
npm run dev # or build npm run dev # or build
@ -186,7 +189,8 @@ Now, create an S3 bucket (or equivalent). Create an IAM user with access to this
OpnForm is a standard web application built with: OpnForm is a standard web application built with:
- [Laravel](https://laravel.com/) PHP framework - [Laravel](https://laravel.com/) PHP framework
- [Vue.js](https://vuejs.org/) front-end framework - [NuxtJs](https://nuxt.com/) Front-end SSR framework
- [Vue.js 3](https://vuejs.org/) Front-end framework
- [TailwindCSS](https://tailwindcss.com/) - [TailwindCSS](https://tailwindcss.com/)
## Contribute ## Contribute

17
amplify.yml Normal file
View File

@ -0,0 +1,17 @@
version: 1
frontend:
phases:
preBuild:
commands:
- cd client
- npm ci
build:
commands:
- npm run build
artifacts:
baseDirectory: client/.amplify-hosting
files:
- '**/*'
cache:
paths:
- client/node_modules/**/*

View File

@ -48,7 +48,7 @@ class Handler extends ExceptionHandler
{ {
return $request->expectsJson() return $request->expectsJson()
? response()->json(['message' => $exception->getMessage()], 401) ? response()->json(['message' => $exception->getMessage()], 401)
: redirect()->guest(url('/login')); : redirect(front_url('login'));
} }
public function report(Throwable $exception) public function report(Throwable $exception)

View File

@ -28,10 +28,10 @@ class AppSumoAuthController extends Controller
// otherwise start login flow by passing the encrypted license key id // otherwise start login flow by passing the encrypted license key id
if (is_null($license->user_id)) { if (is_null($license->user_id)) {
return redirect(url('/register?appsumo_license='.encrypt($license->id))); return redirect(front_url('/register?appsumo_license='.encrypt($license->id)));
} }
return redirect(url('/register?appsumo_error=1')); return redirect(front_url('/register?appsumo_error=1'));
} }
private function retrieveAccessToken(string $requestCode): string private function retrieveAccessToken(string $requestCode): string
@ -82,11 +82,11 @@ class AppSumoAuthController extends Controller
if (is_null($license->user_id)) { if (is_null($license->user_id)) {
$license->user_id = Auth::id(); $license->user_id = Auth::id();
$license->save(); $license->save();
return redirect(url('/home?appsumo_connect=1')); return redirect(front_url('/home?appsumo_connect=1'));
} }
// Licensed already attached // Licensed already attached
return redirect(url('/home?appsumo_error=1')); return redirect(front_url('/home?appsumo_error=1'));
} }
/** /**

View File

@ -17,6 +17,7 @@ class FileUploadController extends Controller
*/ */
public function upload(Request $request) public function upload(Request $request)
{ {
$request->validate(['file' => 'required|file']);
$uuid = (string) Str::uuid(); $uuid = (string) Str::uuid();
$path = $request->file('file')->storeAs(PublicFormController::TMP_FILE_UPLOAD_PATH, $uuid); $path = $request->file('file')->storeAs(PublicFormController::TMP_FILE_UPLOAD_PATH, $uuid);

View File

@ -15,7 +15,8 @@ class FormSubmissionController extends Controller
{ {
public function __construct() public function __construct()
{ {
$this->middleware('auth'); $this->middleware('auth', ['except' => ['submissionFile']]);
$this->middleware('signed', ['only' => ['submissionFile']]);
} }
public function submissions(string $id) public function submissions(string $id)
@ -51,9 +52,6 @@ class FormSubmissionController extends Controller
public function submissionFile($id, $fileName) public function submissionFile($id, $fileName)
{ {
$form = Form::findOrFail((int) $id);
$this->authorize('view', $form);
$fileName = Str::of(PublicFormController::FILE_UPLOAD_PATH)->replace('?', $id).'/' $fileName = Str::of(PublicFormController::FILE_UPLOAD_PATH)->replace('?', $id).'/'
.urldecode($fileName); .urldecode($fileName);
@ -63,8 +61,12 @@ class FormSubmissionController extends Controller
], 404); ], 404);
} }
if (config('filesystems.default') !== 's3') {
return response()->file(Storage::path($fileName));
}
return redirect( return redirect(
Storage::temporaryUrl($fileName, now()->addMinute()) Storage::temporaryUrl($fileName, now()->addMinute())
); );
} }
} }

View File

@ -9,62 +9,24 @@ use App\Models\Template;
class SitemapController extends Controller class SitemapController extends Controller
{ {
/**
* Contains route name and the associated priority
*
* @var array
*/
protected $urls = [
['/', 1],
['/pricing', 0.9],
['/privacy-policy', 0.5],
['/terms-conditions', 0.5],
['/login', 0.4],
['/register', 0.4],
['/password/reset', 0.3],
['/form-templates', 0.9],
];
public function getSitemap(Request $request) public function index(Request $request)
{ {
$sitemap = Sitemap::create(); return [
foreach ($this->urls as $url) { ...$this->getTemplatesUrls()
$sitemap->add($this->createUrl($url[0], $url[1])); ];
}
$this->addTemplatesUrls($sitemap);
$this->addTemplatesTypesUrls($sitemap);
$this->addTemplatesIndustriesUrls($sitemap);
return $sitemap->toResponse($request);
} }
private function createUrl($url, $priority, $frequency = 'daily') private function getTemplatesUrls()
{ {
return Url::create($url)->setPriority($priority)->setChangeFrequency($frequency); $urls = [];
} Template::where('publicly_listed', true)->chunk(100, function ($templates) use (&$urls) {
private function addTemplatesUrls(Sitemap $sitemap)
{
Template::where('publicly_listed', true)->chunk(100, function ($templates) use ($sitemap) {
foreach ($templates as $template) { foreach ($templates as $template) {
$sitemap->add($this->createUrl('/form-templates/' . $template->slug, 0.8)); $urls[] = [
'loc' => '/templates/' . $template->slug
];
} }
}); });
} return $urls;
private function addTemplatesTypesUrls(Sitemap $sitemap)
{
$types = json_decode(file_get_contents(resource_path('data/forms/templates/types.json')), true);
foreach ($types as $type) {
$sitemap->add($this->createUrl('/form-templates/types/' . $type['slug'], 0.7));
}
}
private function addTemplatesIndustriesUrls(Sitemap $sitemap)
{
$industries = json_decode(file_get_contents(resource_path('data/forms/templates/industries.json')), true);
foreach ($industries as $industry) {
$sitemap->add($this->createUrl('/form-templates/industries/' . $industry['slug'], 0.7));
}
} }
} }

View File

@ -1,18 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Service\SeoMetaResolver;
use Illuminate\Http\Request;
class SpaController extends Controller
{
/**
* Get the SPA view.
*/
public function __invoke(Request $request)
{
return view('spa',[
'meta' => (new SeoMetaResolver($request))->getMetas(),
]);
}
}

View File

@ -45,8 +45,8 @@ class SubscriptionController extends Controller
$checkout = $checkoutBuilder $checkout = $checkoutBuilder
->collectTaxIds() ->collectTaxIds()
->checkout([ ->checkout([
'success_url' => url('/subscriptions/success'), 'success_url' => front_url('/subscriptions/success'),
'cancel_url' => url('/subscriptions/error'), 'cancel_url' => front_url('/subscriptions/error'),
'billing_address_collection' => 'required', 'billing_address_collection' => 'required',
'customer_update' => [ 'customer_update' => [
'address' => 'auto', 'address' => 'auto',

View File

@ -4,7 +4,6 @@ namespace App\Http;
use App\Http\Middleware\AuthenticateJWT; use App\Http\Middleware\AuthenticateJWT;
use App\Http\Middleware\CustomDomainRestriction; use App\Http\Middleware\CustomDomainRestriction;
use App\Http\Middleware\EmbeddableForms;
use App\Http\Middleware\IsAdmin; use App\Http\Middleware\IsAdmin;
use App\Http\Middleware\IsNotSubscribed; use App\Http\Middleware\IsNotSubscribed;
use App\Http\Middleware\IsSubscribed; use App\Http\Middleware\IsSubscribed;
@ -20,9 +19,9 @@ class Kernel extends HttpKernel
* @var array * @var array
*/ */
protected $middleware = [ protected $middleware = [
// \App\Http\Middleware\TrustHosts::class, // \App\Http\Middleware\TrustHosts::class,
\App\Http\Middleware\TrustProxies::class, \App\Http\Middleware\TrustProxies::class,
\Fruitcake\Cors\HandleCors::class, \Illuminate\Http\Middleware\HandleCors::class,
\App\Http\Middleware\PreventRequestsDuringMaintenance::class, \App\Http\Middleware\PreventRequestsDuringMaintenance::class,
\Illuminate\Foundation\Http\Middleware\ValidatePostSize::class, \Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
\App\Http\Middleware\TrimStrings::class, \App\Http\Middleware\TrimStrings::class,
@ -46,16 +45,14 @@ class Kernel extends HttpKernel
\Illuminate\View\Middleware\ShareErrorsFromSession::class, \Illuminate\View\Middleware\ShareErrorsFromSession::class,
\App\Http\Middleware\VerifyCsrfToken::class, \App\Http\Middleware\VerifyCsrfToken::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class, \Illuminate\Routing\Middleware\SubstituteBindings::class,
EmbeddableForms::class
], ],
'spa' => [ 'spa' => [
\Illuminate\Routing\Middleware\SubstituteBindings::class, \Illuminate\Routing\Middleware\SubstituteBindings::class,
EmbeddableForms::class
], ],
'api' => [ 'api' => [
'throttle:60,1', 'throttle:100,1',
\Illuminate\Routing\Middleware\SubstituteBindings::class, \Illuminate\Routing\Middleware\SubstituteBindings::class,
\App\Http\Middleware\EncryptCookies::class, \App\Http\Middleware\EncryptCookies::class,
\Illuminate\Session\Middleware\StartSession::class, \Illuminate\Session\Middleware\StartSession::class,

View File

@ -15,7 +15,7 @@ class Authenticate extends Middleware
protected function redirectTo($request) protected function redirectTo($request)
{ {
if (! $request->expectsJson()) { if (! $request->expectsJson()) {
return redirect('/login'); return redirect(front_url('login'));
} }
} }
} }

View File

@ -8,6 +8,7 @@ use Tymon\JWTAuth\Exceptions\JWTException;
class AuthenticateJWT class AuthenticateJWT
{ {
const API_SERVER_SECRET_HEADER_NAME = 'x-api-secret';
/** /**
* Verifies the JWT token and validates the IP and User Agent * Verifies the JWT token and validates the IP and User Agent
@ -24,6 +25,13 @@ class AuthenticateJWT
// Validate IP and User Agent // Validate IP and User Agent
if ($payload) { if ($payload) {
if ($frontApiSecret = $request->header(self::API_SERVER_SECRET_HEADER_NAME)) {
// If it's a trusted SSR request, skip the rest
if ($frontApiSecret === config('app.front_api_secret')) {
return $next($request);
}
}
$error = null; $error = null;
if (!\Hash::check($request->ip(), $payload->get('ip'))) { if (!\Hash::check($request->ip(), $payload->get('ip'))) {
$error = 'Origin IP is invalid'; $error = 'Origin IP is invalid';

View File

@ -11,7 +11,7 @@ use Illuminate\Database\Eloquent\Builder;
class CustomDomainRestriction class CustomDomainRestriction
{ {
const CUSTOM_DOMAIN_HEADER = "User-Custom-Domain"; const CUSTOM_DOMAIN_HEADER = "x-custom-domain";
/** /**
* Handle an incoming request. * Handle an incoming request.
@ -27,7 +27,8 @@ class CustomDomainRestriction
return response()->json([ return response()->json([
'success' => false, 'success' => false,
'message' => 'Invalid domain', 'message' => 'Invalid domain',
], 400); 'error' => 'invalid_domain',
], 420);
} }
// Check if domain is different from current domain // Check if domain is different from current domain
@ -41,7 +42,8 @@ class CustomDomainRestriction
return response()->json([ return response()->json([
'success' => false, 'success' => false,
'message' => 'Unknown domain', 'message' => 'Unknown domain',
], 400); 'error' => 'invalid_domain',
], 420);
} }
Workspace::addGlobalScope('domain-restricted', function (Builder $builder) use ($workspace) { Workspace::addGlobalScope('domain-restricted', function (Builder $builder) use ($workspace) {

View File

@ -1,36 +0,0 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Response;
class EmbeddableForms
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
*
* @return mixed
*/
public function handle($request, Closure $next)
{
if ($request->expectsJson() || $request->wantsJson()) {
return $next($request);
}
$response = $next($request);
if (!str_starts_with($request->url(), url('/forms/'))) {
if ($response instanceof Response) {
$response->header('X-Frame-Options', 'SAMEORIGIN');
} elseif ($response instanceof \Symfony\Component\HttpFoundation\Response) {
$response->headers->set('X-Frame-Options', 'SAMEORIGIN');
}
}
return $response;
}
}

View File

@ -50,7 +50,11 @@ class FormSubmissionResource extends JsonResource
return $file !== null && $file; return $file !== null && $file;
})->map(function ($file) { })->map(function ($file) {
return [ return [
'file_url' => route('open.forms.submissions.file', [$this->form_id, $file]), 'file_url' => \URL::signedRoute(
'open.forms.submissions.file',
[$this->form_id, $file],
now()->addMinutes(10)
),
'file_name' => $file, 'file_name' => $file,
]; ];
}); });

View File

@ -164,14 +164,14 @@ class StoreFormSubmissionJob implements ShouldQueue
return null; return null;
} }
if(filter_var($value, FILTER_VALIDATE_URL) !== FALSE && str_contains($value, parse_url(config('app.url'))['host'])) { // In case of prefill we have full url so convert to s3 if(filter_var($value, FILTER_VALIDATE_URL) !== false && str_contains($value, parse_url(config('app.url'))['host'])) { // In case of prefill we have full url so convert to s3
$fileName = basename($value); $fileName = basename($value);
$path = FormController::ASSETS_UPLOAD_PATH . '/' . $fileName; $path = FormController::ASSETS_UPLOAD_PATH . '/' . $fileName;
$newPath = Str::of(PublicFormController::FILE_UPLOAD_PATH)->replace('?', $this->form->id); $newPath = Str::of(PublicFormController::FILE_UPLOAD_PATH)->replace('?', $this->form->id);
Storage::move($path, $newPath.'/'.$fileName); Storage::move($path, $newPath.'/'.$fileName);
return $fileName; return $fileName;
} }
if($this->isSkipForUpload($value)) { if($this->isSkipForUpload($value)) {
return $value; return $value;
} }

View File

@ -157,12 +157,12 @@ class Form extends Model implements CachableAttributes
if ($this->custom_domain) { if ($this->custom_domain) {
return 'https://' . $this->custom_domain . '/forms/' . $this->slug; return 'https://' . $this->custom_domain . '/forms/' . $this->slug;
} }
return url('/forms/' . $this->slug); return front_url('/forms/' . $this->slug);
} }
public function getEditUrlAttribute() public function getEditUrlAttribute()
{ {
return url('/forms/' . $this->slug . '/show'); return front_url('/forms/' . $this->slug . '/show');
} }
public function getSubmissionsCountAttribute() public function getSubmissionsCountAttribute()

View File

@ -48,7 +48,7 @@ class Template extends Model
public function getShareUrlAttribute() public function getShareUrlAttribute()
{ {
return url('/form-templates/'.$this->slug); return front_url('/form-templates/'.$this->slug);
} }
public function setDescriptionAttribute($value) public function setDescriptionAttribute($value)

View File

@ -17,7 +17,7 @@ class ResetPassword extends Notification
{ {
return (new MailMessage) return (new MailMessage)
->line('You are receiving this email because we received a password reset request for your account.') ->line('You are receiving this email because we received a password reset request for your account.')
->action('Reset Password', url('password/reset/'.$this->token).'?email='.urlencode($notifiable->email)) ->action('Reset Password', front_url('password/reset/'.$this->token).'?email='.urlencode($notifiable->email))
->line('If you did not request a password reset, no further action is required.'); ->line('If you did not request a password reset, no further action is required.');
} }
} }

View File

@ -36,6 +36,6 @@ class FailedPaymentNotification extends Notification implements ShouldQueue
->line(__('Please go to OpenForm, click on your name on the top right corner, and click on "Billing". ->line(__('Please go to OpenForm, click on your name on the top right corner, and click on "Billing".
You will then be able to update your card details. To avoid any service disruption, you can reply to this email whenever You will then be able to update your card details. To avoid any service disruption, you can reply to this email whenever
you updated your card details, and we\'ll manually attempt to charge your card.')) you updated your card details, and we\'ll manually attempt to charge your card.'))
->action(__('Go to OpenForm'), url('/')); ->action(__('Go to OpenForm'), front_url('/'));
} }
} }

View File

@ -19,15 +19,6 @@ class RouteServiceProvider extends ServiceProvider
*/ */
public const HOME = '/home'; public const HOME = '/home';
/**
* The controller namespace for the application.
*
* When present, controller route declarations will automatically be prefixed with this namespace.
*
* @var string|null
*/
// protected $namespace = 'App\\Http\\Controllers';
/** /**
* Define your route model bindings, pattern filters, etc. * Define your route model bindings, pattern filters, etc.
* *
@ -39,19 +30,9 @@ class RouteServiceProvider extends ServiceProvider
$this->registerGlobalRouteParamConstraints(); $this->registerGlobalRouteParamConstraints();
$this->routes(function () { $this->routes(function () {
Route::middleware('api')
Route::prefix('api')
->middleware('api')
->namespace($this->namespace) ->namespace($this->namespace)
->group(base_path('routes/api.php')); ->group(base_path('routes/api.php'));
Route::middleware('web')
->namespace($this->namespace)
->group(base_path('routes/web.php'));
Route::middleware('spa')
->namespace($this->namespace)
->group(base_path('routes/spa.php'));
}); });
} }

View File

@ -27,7 +27,7 @@ class DiscordHandler extends AbstractWebhookHandler
$externalLinks[] = '[**🔗 Open Form**](' . $this->form->share_url . ')'; $externalLinks[] = '[**🔗 Open Form**](' . $this->form->share_url . ')';
} }
if(Arr::get($settings, 'link_edit_form', true)){ if(Arr::get($settings, 'link_edit_form', true)){
$editFormURL = url('forms/' . $this->form->slug . '/show'); $editFormURL = front_url('forms/' . $this->form->slug . '/show');
$externalLinks[] = '[**✍️ Edit Form**](' . $editFormURL . ')'; $externalLinks[] = '[**✍️ Edit Form**](' . $editFormURL . ')';
} }
if (Arr::get($settings, 'link_edit_submission', true) && $this->form->editable_submissions) { if (Arr::get($settings, 'link_edit_submission', true) && $this->form->editable_submissions) {

View File

@ -27,7 +27,7 @@ class SlackHandler extends AbstractWebhookHandler
$externalLinks[] = '*<' . $this->form->share_url . '|🔗 Open Form>*'; $externalLinks[] = '*<' . $this->form->share_url . '|🔗 Open Form>*';
} }
if(Arr::get($settings, 'link_edit_form', true)){ if(Arr::get($settings, 'link_edit_form', true)){
$editFormURL = url('forms/' . $this->form->slug . '/show'); $editFormURL = front_url('forms/' . $this->form->slug . '/show');
$externalLinks[] = '*<' . $editFormURL . '|✍️ Edit Form>*'; $externalLinks[] = '*<' . $editFormURL . '|✍️ Edit Form>*';
} }
if (Arr::get($settings, 'link_edit_submission', true) && $this->form->editable_submissions) { if (Arr::get($settings, 'link_edit_submission', true) && $this->form->editable_submissions) {

11
app/helpers.php Normal file
View File

@ -0,0 +1,11 @@
<?php
function front_url($path = '')
{
$baseUrl = config('app.front_url');
if (!$baseUrl) {
return $path;
}
return rtrim($baseUrl, '/'). '/' . ltrim($path, '/');
}

13
client/.env.example Normal file
View File

@ -0,0 +1,13 @@
NUXT_LOG_LEVEL=
NUXT_PUBLIC_APP_URL=
NUXT_PUBLIC_API_BASE=
NUXT_PUBLIC_AI_ENABLED=
NUXT_PUBLIC_AMPLITUDE_CODE=
NUXT_PUBLIC_CRISP_WEBSITE_ID=
NUXT_PUBLIC_CUSTOM_DOMAINS_ENABLED=
NUXT_PUBLIC_ENV=
NUXT_PUBLIC_GOOGLE_ANALYTICS_CODE=
NUXT_PUBLIC_H_CAPTCHA_SITE_KEY=
NUXT_PUBLIC_PAID_PLANS_ENABLED=
NUXT_PUBLIC_S3_ENABLED=
NUXT_API_SECRET=

25
client/.gitignore vendored Normal file
View File

@ -0,0 +1,25 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
.amplify-hosting
dist
# Node dependencies
node_modules
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example

75
client/README.md Normal file
View File

@ -0,0 +1,75 @@
# Nuxt 3 Minimal Starter
Look at the [Nuxt 3 documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
## Setup
Make sure to install the dependencies:
```bash
# npm
npm install
# pnpm
pnpm install
# yarn
yarn install
# bun
bun install
```
## Development Server
Start the development server on `http://localhost:3000`:
```bash
# npm
npm run dev
# pnpm
pnpm run dev
# yarn
yarn dev
# bun
bun run dev
```
## Production
Build the application for production:
```bash
# npm
npm run build
# pnpm
pnpm run build
# yarn
yarn build
# bun
bun run build
```
Locally preview production build:
```bash
# npm
npm run preview
# pnpm
pnpm run preview
# yarn
yarn preview
# bun
bun run preview
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.

101
client/app.vue Normal file
View File

@ -0,0 +1,101 @@
<template>
<div id="app" class="bg-white dark:bg-notion-dark">
<transition enter-active-class="linear duration-200 overflow-hidden"
enter-from-class="max-h-0"
enter-to-class="max-h-screen"
leave-active-class="linear duration-200 overflow-hidden"
leave-from-class="max-h-screen"
leave-to-class="max-h-0"
>
<div v-if="announcement && !isIframe" class="bg-nt-blue text-white text-center p-3 relative">
<a class="text-white font-semibold" href="" target="_blank">🚨
OpnForm beta is over 🚨</a>
<div role="button" class="text-white absolute right-0 top-0 p-3 cursor-pointer" @click="announcement=false">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clip-rule="evenodd"
/>
</svg>
</div>
</div>
</transition>
<NuxtLoadingIndicator color="#2563eb"/>
<NuxtLayout>
<NuxtPage/>
</NuxtLayout>
<ToolsStopImpersonation/>
<Notifications />
</div>
</template>
<script>
import {computed} from 'vue'
import {useAppStore} from '~/stores/app'
export default {
el: '#app',
name: 'OpnForm',
components: {},
setup() {
const config = useRuntimeConfig()
useOpnSeoMeta({
title: 'OpnForm',
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.',
ogImage: '/img/social-preview.jpg',
robots: () => {
return config.public.env === 'production' ? null : 'noindex, nofollow'
}
})
useHead({
titleTemplate: (titleChunk) => {
return titleChunk ? `${titleChunk} - OpnForm` : 'OpnForm';
}
})
const appStore = useAppStore()
return {
layout: computed(() => appStore.layout),
isIframe: useIsIframe()
}
},
mounted() {
useCrisp().showChat()
},
data: () => ({
announcement: false,
alert: {
type: null,
autoClose: 0,
message: '',
confirmationProceed: null,
confirmationCancel: null
},
navbarHidden: false
}),
computed: {
isOnboardingPage() {
return this.$route.name === 'onboarding'
},
},
methods: {
workspaceAdded() {
this.$router.push({name: 'home'})
},
hideNavbar(hidden = true) {
this.navbarHidden = hidden
}
}
}
</script>

View File

@ -0,0 +1,46 @@
<template>
<input-wrapper v-bind="inputWrapperProps">
<template #label>
<span />
</template>
<v-checkbox :id="id?id:name" v-model="compVal" :disabled="disabled?true:null" :name="name">
<slot name="label">
{{ label }} <span v-if="required" class="text-red-500 required-dot">*</span>
</slot>
</v-checkbox>
<template #help>
<slot name="help" />
</template>
<template #error>
<slot name="error" />
</template>
</input-wrapper>
</template>
<script>
import { inputProps, useFormInput } from './useFormInput.js'
import VCheckbox from './components/VCheckbox.vue'
import InputWrapper from './components/InputWrapper.vue'
export default {
name: 'CheckboxInput',
components: { InputWrapper, VCheckbox },
props: {
...inputProps
},
setup (props, context) {
return {
...useFormInput(props, context)
}
},
mounted () {
this.compVal = !!this.compVal
}
}
</script>

View File

@ -0,0 +1,63 @@
<template>
<input-wrapper
v-bind="inputWrapperProps"
>
<template #label>
<slot name="label" />
</template>
<template #help>
<slot name="help" />
</template>
<div
:class="[theme.CodeInput.input,{ '!ring-red-500 !ring-2': hasError, '!cursor-not-allowed !bg-gray-200':disabled }]"
>
<codemirror :id="id?id:name" v-model="compVal" :disabled="disabled?true:null"
:options="cmOptions"
:style="inputStyle" :name="name"
:placeholder="placeholder"
/>
</div>
<template #error>
<slot name="error" />
</template>
</input-wrapper>
</template>
<script>
import { codemirror } from 'vue-codemirror'
import 'codemirror/lib/codemirror.css'
import 'codemirror/mode/htmlmixed/htmlmixed.js'
import { inputProps, useFormInput } from './useFormInput.js'
import InputWrapper from './components/InputWrapper.vue'
export default {
components: { InputWrapper, codemirror },
props: {
...inputProps
},
setup (props, context) {
return {
...useFormInput(props, context)
}
},
data () {
return {
cmOptions: {
// codemirror options
tabSize: 4,
mode: 'text/html',
theme: 'default',
lineNumbers: true,
line: true
}
}
}
}
</script>

View File

@ -0,0 +1,45 @@
<template>
<input-wrapper v-bind="inputWrapperProps">
<template #label>
<span />
</template>
<div class="flex items-center">
<input :id="id?id:name" v-model="compVal" :disabled="disabled?true:null"
type="color" class="mr-2"
:name="name"
>
<slot name="label">
<span>{{ label }} <span v-if="required" class="text-red-500 required-dot">*</span></span>
</slot>
</div>
<template #help>
<slot name="help" />
</template>
<template #error>
<slot name="error" />
</template>
</input-wrapper>
</template>
<script>
import InputWrapper from './components/InputWrapper.vue'
import { inputProps, useFormInput } from './useFormInput.js'
export default {
name: 'ColorInput',
components: { InputWrapper },
props: {
...inputProps
},
setup (props, context) {
return {
...useFormInput(props, context)
}
}
}
</script>

View File

@ -0,0 +1,187 @@
<template>
<input-wrapper
v-bind="inputWrapperProps"
>
<template #label>
<slot name="label" />
</template>
<div v-if="!dateRange" class="flex">
<input :id="id?id:name" v-model="fromDate" :type="useTime ? 'datetime-local' : 'date'" :class="inputClasses"
:disabled="disabled?true:null"
:style="inputStyle" :name="name" data-date-format="YYYY-MM-DD"
:min="setMinDate" :max="setMaxDate"
>
</div>
<div v-else :class="inputClasses">
<div class="flex -mx-2">
<p class="text-gray-900 px-4">
From
</p>
<input :id="id?id:name" v-model="fromDate" :type="useTime ? 'datetime-local' : 'date'" :disabled="disabled?true:null"
:style="inputStyle" :name="name" data-date-format="YYYY-MM-DD"
class="flex-grow border-transparent focus:outline-none "
:min="setMinDate" :max="setMaxDate"
>
<p class="text-gray-900 px-4">
To
</p>
<input v-if="dateRange" :id="id?id:name" v-model="toDate" :type="useTime ? 'datetime-local' : 'date'"
:disabled="disabled?true:null"
:style="inputStyle" :name="name" class="flex-grow border-transparent focus:outline-none"
:min="setMinDate" :max="setMaxDate"
>
</div>
</div>
<template #help>
<slot name="help" />
</template>
<template #error>
<slot name="error" />
</template>
</input-wrapper>
</template>
<script>
import { inputProps, useFormInput } from './useFormInput.js'
import InputWrapper from './components/InputWrapper.vue'
export default {
name: 'DateInput',
components: { InputWrapper },
mixins: [],
props: {
...inputProps,
withTime: { type: Boolean, default: false },
dateRange: { type: Boolean, default: false },
disablePastDates: { type: Boolean, default: false },
disableFutureDates: { type: Boolean, default: false }
},
setup (props, context) {
return {
...useFormInput(props, context)
}
},
data: () => ({
fromDate: null,
toDate: null
}),
computed: {
inputClasses () {
let str = 'border border-gray-300 dark:bg-notion-dark-light dark:border-gray-600 dark:placeholder-gray-500 dark:text-gray-300 flex-1 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-opacity-100 placeholder-gray-400 px-4 py-2 rounded-lg shadow-sm text-base text-black text-gray-700'
str += this.dateRange ? ' w-50' : ' w-full'
str += this.disabled ? ' !cursor-not-allowed !bg-gray-200' : ''
return str
},
useTime () {
return this.withTime && !this.dateRange
},
setMinDate () {
if (this.disablePastDates) {
return new Date().toISOString().split('T')[0]
}
return false
},
setMaxDate () {
if (this.disableFutureDates) {
return new Date().toISOString().split('T')[0]
}
return false
}
},
watch: {
color: {
handler () {
this.setInputColor()
},
immediate: true
},
fromDate: {
handler (val) {
if (this.dateRange) {
if (!Array.isArray(this.compVal)) {
this.compVal = []
}
this.compVal[0] = this.dateToUTC(val)
} else {
this.compVal = this.dateToUTC(val)
}
},
immediate: false
},
toDate: {
handler (val) {
if (this.dateRange) {
if (!Array.isArray(this.compVal)) {
this.compVal = [null]
}
this.compVal[1] = this.dateToUTC(val)
} else {
this.compVal = null
}
},
immediate: false
}
},
mounted () {
if (this.compVal) {
if (Array.isArray(this.compVal)) {
this.fromDate = this.compVal[0] ?? null
this.toDate = this.compVal[1] ?? null
} else {
this.fromDate = this.dateToLocal(this.compVal)
}
}
this.setInputColor()
},
methods: {
/**
* Pressing enter won't submit form
* @param event
* @returns {boolean}
*/
onEnterPress (event) {
event.preventDefault()
return false
},
setInputColor () {
if (this.$refs.datepicker) {
const dateInput = this.$refs.datepicker.$el.getElementsByTagName('input')[0]
dateInput.style.setProperty('--tw-ring-color', this.color)
}
},
dateToUTC (val) {
if (!val) {
return null
}
if (!this.useTime) {
return val
}
return new Date(val).toISOString()
},
dateToLocal (val) {
if (!val) {
return null
}
const dateObj = new Date(val)
let dateStr = dateObj.getFullYear() + '-' +
String(dateObj.getMonth() + 1).padStart(2, '0') + '-' +
String(dateObj.getDate()).padStart(2, '0')
if (this.useTime) {
dateStr += 'T' + String(dateObj.getHours()).padStart(2, '0') + ':' +
String(dateObj.getMinutes()).padStart(2, '0')
}
return dateStr
}
}
}
</script>

View File

@ -1,21 +1,10 @@
<template> <template>
<div :class="wrapperClass"> <input-wrapper
<label v-bind="inputWrapperProps"
v-if="label" >
:class="[ <template #label>
theme.default.label, <slot name="label" />
{ </template>
'text-xs uppercase': uppercaseLabels,
'text-sm': !uppercaseLabels,
},
]"
>
{{ label }}
<span v-if="required" class="required-dot text-red-500">*</span>
</label>
<small v-if="help && helpPosition=='above_input'" :class="theme.default.help" class="flex mb-1">
<slot name="help"><span class="field-help" v-html="help" /></slot>
</small>
<div class="flex w-full items-center justify-center transition-colors duration-40" <div class="flex w-full items-center justify-center transition-colors duration-40"
:class="[{'!cursor-not-allowed':disabled, 'cursor-pointer':!disabled, :class="[{'!cursor-not-allowed':disabled, 'cursor-pointer':!disabled,
@ -30,7 +19,7 @@
v-if="loading" v-if="loading"
class="text-gray-600 dark:text-gray-400" class="text-gray-600 dark:text-gray-400"
> >
<loader class="mx-auto h-6 w-6" /> <Loader class="mx-auto h-6 w-6" />
<p class="mt-2 text-center text-sm text-gray-500"> <p class="mt-2 text-center text-sm text-gray-500">
Uploading your file... Uploading your file...
</p> </p>
@ -77,30 +66,39 @@
</template> </template>
</div> </div>
<small v-if="help && helpPosition=='below_input'" :class="theme.default.help"> <template #help>
<slot name="help"><span class="field-help" v-html="help" /></slot> <slot name="help" />
</small> </template>
<has-error v-if="hasValidation" :form="form" :field="name" /> <template #error>
</div> <slot name="error" />
</template>
</input-wrapper>
</template> </template>
<script> <script>
import axios from 'axios' import { inputProps, useFormInput } from './useFormInput.js'
import inputMixin from '~/mixins/forms/input.js' import InputWrapper from './components/InputWrapper.vue'
import UploadedFile from './components/UploadedFile.vue' import UploadedFile from './components/UploadedFile.vue'
import {storeFile} from "~/lib/file-uploads.js"
export default { export default {
name: 'FileInput', name: 'FileInput',
components: { InputWrapper, UploadedFile },
components: { UploadedFile }, mixins: [],
mixins: [inputMixin],
props: { props: {
...inputProps,
multiple: { type: Boolean, default: true }, multiple: { type: Boolean, default: true },
mbLimit: { type: Number, default: 5 }, mbLimit: { type: Number, default: 5 },
accept: { type: String, default: '' }, accept: { type: String, default: '' },
moveToFormAssets: { type: Boolean, default: false } moveToFormAssets: { type: Boolean, default: false }
}, },
setup (props, context) {
return {
...useFormInput(props, context)
}
},
data: () => ({ data: () => ({
files: [], files: [],
uploadDragoverEvent: false, uploadDragoverEvent: false,
@ -118,6 +116,7 @@ export default {
return this.accept return this.accept
.split(',') .split(',')
.map((i) => { .map((i) => {
if (!i) return null
return '.' + i.trim() return '.' + i.trim()
}) })
.join(',') .join(',')
@ -188,20 +187,23 @@ export default {
uploadFileToServer (file) { uploadFileToServer (file) {
if (this.disabled) return if (this.disabled) return
this.loading = true this.loading = true
this.storeFile(file) storeFile(file)
.then((response) => { .then((response) => {
if (!this.multiple) { if (!this.multiple) {
this.files = [] this.files = []
} }
if (this.moveToFormAssets) { if (this.moveToFormAssets) {
// Move file to permanent storage for form assets // Move file to permanent storage for form assets
axios.post('/api/open/forms/assets/upload', { opnFetch('/open/forms/assets/upload', {
type: 'files', method: 'POST',
url: file.name.split('.').slice(0, -1).join('.') + '_' + response.uuid + '.' + response.extension body: {
}).then(moveFileResponse => { type: 'files',
url: file.name.split('.').slice(0, -1).join('.') + '_' + response.uuid + '.' + response.extension
}
}).then(moveFileResponseData => {
this.files.push({ this.files.push({
file: file, file: file,
url: moveFileResponse.data.url, url: moveFileResponseData.url,
src: this.getFileSrc(file) src: this.getFileSrc(file)
}) })
this.loading = false this.loading = false

View File

@ -1,16 +1,12 @@
<template> <template>
<div :class="wrapperClass"> <input-wrapper
<label v-if="label" :for="id?id:name" v-bind="inputWrapperProps"
:class="[theme.default.label,{'uppercase text-xs':uppercaseLabels, 'text-sm':!uppercaseLabels}]" >
> <template #label>
{{ label }} <slot name="label" />
<span v-if="required" class="text-red-500 required-dot">*</span> </template>
</label>
<small v-if="help && helpPosition=='above_input'" :class="theme.SelectInput.help" class="block mb-1">
<slot name="help"><span class="field-help" v-html="help" /></slot>
</small>
<loader v-if="loading" key="loader" class="h-6 w-6 text-nt-blue mx-auto" /> <Loader v-if="loading" key="loader" class="h-6 w-6 text-nt-blue mx-auto" />
<div v-for="(option, index) in options" v-else :key="option[optionKey]" role="button" <div v-for="(option, index) in options" v-else :key="option[optionKey]" role="button"
:class="[theme.default.input,'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-900 flex',{ 'mb-2': index !== options.length,'!ring-red-500 !ring-2': hasValidation && form.errors.has(name), '!cursor-not-allowed !bg-gray-200':disabled }]" :class="[theme.default.input,'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-900 flex',{ 'mb-2': index !== options.length,'!ring-red-500 !ring-2': hasValidation && form.errors.has(name), '!cursor-not-allowed !bg-gray-200':disabled }]"
@click="onSelect(option[optionKey])" @click="onSelect(option[optionKey])"
@ -25,24 +21,28 @@
</div> </div>
</div> </div>
<small v-if="help && helpPosition=='below_input'" :class="theme.SelectInput.help" class="block"> <template #help>
<slot name="help"><span class="field-help" v-html="help" /></slot> <slot name="help" />
</small> </template>
<has-error v-if="hasValidation" :form="form" :field="name" /> <template #error>
</div> <slot name="error" />
</template>
</input-wrapper>
</template> </template>
<script> <script>
import inputMixin from '~/mixins/forms/input.js' import { inputProps, useFormInput } from './useFormInput.js'
import InputWrapper from './components/InputWrapper.vue'
/** /**
* Options: {name,value} objects * Options: {name,value} objects
*/ */
export default { export default {
name: 'FlatSelectInput', name: 'FlatSelectInput',
mixins: [inputMixin], components: { InputWrapper },
props: { props: {
...inputProps,
options: { type: Array, required: true }, options: { type: Array, required: true },
optionKey: { type: String, default: 'value' }, optionKey: { type: String, default: 'value' },
emitKey: { type: String, default: 'value' }, emitKey: { type: String, default: 'value' },
@ -50,16 +50,21 @@ export default {
loading: { type: Boolean, default: false }, loading: { type: Boolean, default: false },
multiple: { type: Boolean, default: false } multiple: { type: Boolean, default: false }
}, },
setup (props, context) {
return {
...useFormInput(props, context)
}
},
data () { data () {
return {} return {}
}, },
computed: {}, computed: {},
methods: { methods: {
onSelect (value) { onSelect (value) {
if(this.disabled){ if (this.disabled) {
return return
} }
if (this.multiple) { if (this.multiple) {
const emitValue = Array.isArray(this.compVal) ? [...this.compVal] : [] const emitValue = Array.isArray(this.compVal) ? [...this.compVal] : []

View File

@ -1,11 +1,11 @@
<template> <template>
<div :class="wrapperClass"> <input-wrapper
<label v-if="label" v-bind="inputWrapperProps"
:class="[theme.default.label,{'uppercase text-xs':uppercaseLabels, 'text-sm':!uppercaseLabels}]" >
> <template #label>
{{ label }} <slot name="label" />
<span v-if="required" class="text-red-500 required-dot">*</span> </template>
</label>
<span class="inline-block w-full rounded-md shadow-sm"> <span class="inline-block w-full rounded-md shadow-sm">
<button type="button" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" <button type="button" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label"
class="cursor-pointer relative w-full" :class="[theme.default.input,{'ring-red-500 ring-2': hasValidation && form.errors.has(name)}]" class="cursor-pointer relative w-full" :class="[theme.default.input,{'ring-red-500 ring-2': hasValidation && form.errors.has(name)}]"
@ -22,7 +22,7 @@
</div> </div>
<div v-else class="h-6 text-gray-600 dark:text-gray-400 flex"> <div v-else class="h-6 text-gray-600 dark:text-gray-400 flex">
<div class="flex-grow"> <div class="flex-grow">
<img :src="currentUrl" class="h-6 rounded shadow-md"> <NuxtImg :src="currentUrl" class="h-6 rounded shadow-md"/>
</div> </div>
<a href="#" class="hover:text-nt-blue flex" @click.prevent="clearUrl"> <a href="#" class="hover:text-nt-blue flex" @click.prevent="clearUrl">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24"
@ -35,10 +35,13 @@
</div> </div>
</button> </button>
</span> </span>
<small v-if="help" :class="theme.default.help">
<slot name="help"><span class="field-help" v-html="help" /></slot> <template #help>
</small> <slot name="help" />
<has-error v-if="hasValidation" :form="form" :field="name" /> </template>
<template #error>
<slot name="error" />
</template>
<!-- Modal --> <!-- Modal -->
<modal :show="showUploadModal" @close="showUploadModal=false"> <modal :show="showUploadModal" @close="showUploadModal=false">
@ -56,7 +59,7 @@
@drop.prevent="onUploadDropEvent($event)" @drop.prevent="onUploadDropEvent($event)"
> >
<div v-if="loading" class="text-gray-600 dark:text-gray-400"> <div v-if="loading" class="text-gray-600 dark:text-gray-400">
<loader class="h-6 w-6 mx-auto m-10" /> <Loader class="h-6 w-6 mx-auto m-10" />
<p class="text-center mt-6"> <p class="text-center mt-6">
Uploading your file... Uploading your file...
</p> </p>
@ -100,20 +103,28 @@
</div> </div>
</div> </div>
</modal> </modal>
</div> </input-wrapper>
</template> </template>
<script> <script>
import Modal from '../Modal.vue' import { inputProps, useFormInput } from './useFormInput.js'
import axios from 'axios' import InputWrapper from './components/InputWrapper.vue'
import inputMixin from '~/mixins/forms/input.js' import Modal from '../global/Modal.vue'
import {storeFile} from "~/lib/file-uploads.js"
export default { export default {
name: 'ImageInput', name: 'ImageInput',
components: { InputWrapper, Modal },
mixins: [],
props: {
...inputProps
},
components: { Modal }, setup (props, context) {
mixins: [inputMixin], return {
props: {}, ...useFormInput(props, context)
}
},
data: () => ({ data: () => ({
showUploadModal: false, showUploadModal: false,
@ -133,9 +144,10 @@ export default {
watch: { watch: {
showUploadModal: { showUploadModal: {
handler (val) { handler (val) {
if (process.server) return
document.removeEventListener('paste', this.onUploadPasteEvent) document.removeEventListener('paste', this.onUploadPasteEvent)
if(this.showUploadModal){ if (this.showUploadModal) {
document.addEventListener("paste", this.onUploadPasteEvent) document.addEventListener('paste', this.onUploadPasteEvent)
} }
} }
} }
@ -143,7 +155,7 @@ export default {
methods: { methods: {
clearUrl () { clearUrl () {
this.$set(this.form, this.name, null) this.form[this.name] = null
}, },
onUploadDragoverEvent (e) { onUploadDragoverEvent (e) {
this.uploadDragoverEvent = true this.uploadDragoverEvent = true
@ -155,7 +167,7 @@ export default {
this.droppedFiles(e.dataTransfer.files) this.droppedFiles(e.dataTransfer.files)
}, },
onUploadPasteEvent (e) { onUploadPasteEvent (e) {
if(!this.showUploadModal) return if (!this.showUploadModal) return
this.uploadDragoverEvent = false this.uploadDragoverEvent = false
this.uploadDragoverTracking = false this.uploadDragoverTracking = false
this.droppedFiles(e.clipboardData.files) this.droppedFiles(e.clipboardData.files)
@ -176,15 +188,18 @@ export default {
uploadFileToServer () { uploadFileToServer () {
this.loading = true this.loading = true
// Store file in s3 // Store file in s3
this.storeFile(this.file).then(response => { storeFile(this.file).then(response => {
// Move file to permanent storage for form assets // Move file to permanent storage for form assets
axios.post('/api/open/forms/assets/upload', { opnFetch('/open/forms/assets/upload', {
url: this.file.name.split('.').slice(0, -1).join('.') + '_' + response.uuid + '.' + response.extension method: 'POST',
}).then(moveFileResponse => { body: {
url: this.file.name.split('.').slice(0, -1).join('.') + '_' + response.uuid + '.' + response.extension
}
}).then(moveFileResponseData => {
if (!this.multiple) { if (!this.multiple) {
this.files = [] this.files = []
} }
this.compVal = moveFileResponse.data.url this.compVal = moveFileResponseData.url
this.showUploadModal = false this.showUploadModal = false
this.loading = false this.loading = false
}).catch((error) => { }).catch((error) => {

View File

@ -1,24 +1,17 @@
<template> <template>
<div :class="wrapperClass" :style="inputStyle"> <input-wrapper
<slot name="label"> v-bind="inputWrapperProps"
<label v-if="label" :for="id ? id : name" >
:class="[theme.default.label, { 'uppercase text-xs': uppercaseLabels, 'text-sm': !uppercaseLabels }]" <template #label>
> <slot name="label" />
{{ label }} </template>
<span v-if="required" class="text-red-500 required-dot">*</span>
</label>
</slot>
<div v-if="help && helpPosition == 'above_input'" class="flex mb-1">
<small :class="theme.default.help" class="grow">
<slot name="help"><span class="field-help" v-html="help" /></slot>
</small>
</div>
<div :id="id ? id : name" :name="name" :style="inputStyle" class="flex items-center"> <div :id="id ? id : name" :name="name" :style="inputStyle" class="flex items-center">
<v-select v-model="selectedCountryCode" class="w-[130px]" dropdown-class="w-[300px]" input-class="rounded-r-none" <v-select v-model="selectedCountryCode" class="w-[130px]" dropdown-class="w-[300px]" input-class="rounded-r-none"
:data="countries" :data="countries"
:disabled="disabled || countries.length===1" :searchable="true" :search-keys="['name']" :option-key="'code'" :color="color" :disabled="(disabled || countries.length===1)?true:null" :searchable="true" :search-keys="['name']" :option-key="'code'" :color="color"
:has-error="hasValidation && form.errors.has(name)" :has-error="hasValidation && form.errors.has(name)"
:placeholder="'Select a country'" :uppercase-labels="true" :theme="theme" @input="onChangeCountryCode" :placeholder="'Select a country'" :uppercase-labels="true" :theme="theme" @update:model-value="onChangeCountryCode"
> >
<template #option="props"> <template #option="props">
<div class="flex items-center space-x-2 hover:text-white"> <div class="flex items-center space-x-2 hover:text-white">
@ -34,37 +27,42 @@
</div> </div>
</template> </template>
</v-select> </v-select>
<input v-model="inputVal" type="text" class="inline-flex-grow !border-l-0 !rounded-l-none" :disabled="disabled" <input v-model="inputVal" type="text" class="inline-flex-grow !border-l-0 !rounded-l-none" :disabled="disabled?true:null"
:class="[theme.default.input, { '!ring-red-500 !ring-2': hasValidation && form.errors.has(name), '!cursor-not-allowed !bg-gray-200': disabled }]" :class="[theme.default.input, { '!ring-red-500 !ring-2': hasValidation && form.errors.has(name), '!cursor-not-allowed !bg-gray-200': disabled }]"
:placeholder="placeholder" :style="inputStyle" @input="onInput" :placeholder="placeholder" :style="inputStyle" @update:model-value="onInput"
> >
</div> </div>
<div v-if="help && helpPosition=='below_input'" class="flex">
<small :class="theme.default.help" class="grow"> <template #help>
<slot name="help"><span class="field-help" v-html="help" /></slot> <slot name="help" />
</small> </template>
</div>
<has-error v-if="hasValidation" :form="form" :field="name" /> <template #error>
</div> <slot name="error" />
</template>
</input-wrapper>
</template> </template>
<script> <script>
import { directive as onClickaway } from 'vue-clickaway' import { inputProps, useFormInput } from './useFormInput.js'
import inputMixin from '~/mixins/forms/input.js' import InputWrapper from './components/InputWrapper.vue'
import countryCodes from '../../../data/country_codes.json' import countryCodes from '../../../data/country_codes.json'
import CountryFlag from 'vue-country-flag' import CountryFlag from 'vue-country-flag-next'
import parsePhoneNumber from 'libphonenumber-js' import parsePhoneNumber from 'libphonenumber-js'
export default { export default {
phone: 'PhoneInput', phone: 'PhoneInput',
components: { CountryFlag }, components: { InputWrapper, CountryFlag },
directives: {
onClickaway: onClickaway
},
mixins: [inputMixin],
props: { props: {
...inputProps,
canOnlyCountry: { type: Boolean, default: false }, canOnlyCountry: { type: Boolean, default: false },
unavailableCountries: { type: Array, default: () => [] }, unavailableCountries: { type: Array, default: () => [] }
},
setup (props, context) {
return {
...useFormInput(props, context)
}
}, },
data () { data () {
@ -74,6 +72,14 @@ export default {
} }
}, },
computed: {
countries () {
return countryCodes.filter((item) => {
return !this.unavailableCountries.includes(item.code)
})
}
},
watch: { watch: {
inputVal: { inputVal: {
handler (val) { handler (val) {
@ -94,14 +100,6 @@ export default {
} }
}, },
computed: {
countries () {
return countryCodes.filter((item) => {
return !this.unavailableCountries.includes(item.code)
})
}
},
mounted () { mounted () {
if (this.compVal) { if (this.compVal) {
if (!this.compVal.startsWith('+')) { if (!this.compVal.startsWith('+')) {

View File

@ -1,14 +1,10 @@
<template> <template>
<div :class="wrapperClass" :style="inputStyle"> <input-wrapper
<label v-if="label" :for="id?id:name" v-bind="inputWrapperProps"
:class="[theme.default.label,{'uppercase text-xs':uppercaseLabels, 'text-sm':!uppercaseLabels}]" >
> <template #label>
{{ label }} <slot name="label" />
<span v-if="required" class="text-red-500 required-dot">*</span> </template>
</label>
<small v-if="help && helpPosition=='above_input'" :class="theme.default.help" class="flex mb-1">
<slot name="help"><span class="field-help" v-html="help" /></slot>
</small>
<div class="stars-outer"> <div class="stars-outer">
<div v-for="i in numberOfStars" :key="i" <div v-for="i in numberOfStars" :key="i"
@ -26,25 +22,34 @@
</div> </div>
</div> </div>
<small v-if="help && helpPosition=='below_input'" :class="theme.default.help"> <template #help>
<slot name="help"><span class="field-help" v-html="help" /></slot> <slot name="help" />
</small> </template>
<has-error v-if="hasValidation" :form="form" :field="name" /> <template #error>
</div> <slot name="error" />
</template>
</input-wrapper>
</template> </template>
<script> <script>
import inputMixin from '~/mixins/forms/input.js' import { inputProps, useFormInput } from './useFormInput.js'
import InputWrapper from './components/InputWrapper.vue'
export default { export default {
name: 'RatingInput', name: 'RatingInput',
components: { InputWrapper },
mixins: [inputMixin],
props: { props: {
...inputProps,
numberOfStars: { type: Number, default: 5 } numberOfStars: { type: Number, default: 5 }
}, },
setup (props, context) {
return {
...useFormInput(props, context)
}
},
data () { data () {
return { return {
hoverRating: -1 hoverRating: -1

View File

@ -1,41 +1,39 @@
<template> <template>
<div :class="wrapperClass"> <input-wrapper
<label v-if="label" :for="id?id:name" v-bind="inputWrapperProps"
:class="[theme.RichTextAreaInput.label, {'uppercase text-xs':uppercaseLabels, 'text-sm':!uppercaseLabels}]" >
> <template #label>
{{ label }} <slot name="label" />
<span v-if="required" class="text-red-500 required-dot">*</span> </template>
</label>
<div v-if="help && helpPosition=='above_input'" class="flex mb-1"> <vue-editor :id="id?id:name" ref="editor" v-model="compVal" :disabled="disabled?true:null"
<small :class="theme.RichTextAreaInput.help" class="grow">
<slot name="help"><span class="field-help" v-html="help" /></slot>
</small>
</div>
<vue-editor :id="id?id:name" ref="editor" v-model="compVal" :disabled="disabled"
:placeholder="placeholder" :class="[{ '!ring-red-500 !ring-2': hasValidation && form.errors.has(name), '!cursor-not-allowed !bg-gray-200':disabled }, theme.RichTextAreaInput.input]" :placeholder="placeholder" :class="[{ '!ring-red-500 !ring-2': hasValidation && form.errors.has(name), '!cursor-not-allowed !bg-gray-200':disabled }, theme.RichTextAreaInput.input]"
:editor-toolbar="editorToolbar" class="rich-editor resize-y" :editor-toolbar="editorToolbar" class="rich-editor resize-y"
:style="inputStyle" :style="inputStyle"
/> />
<small v-if="help && helpPosition=='below_input'" :class="theme.RichTextAreaInput.help"> <template #help>
<slot name="help"><span class="field-help" v-html="help" /></slot> <slot name="help" />
</small> </template>
<has-error v-if="hasValidation" :form="form" :field="name" /> <template #error>
</div> <slot name="error" />
</template>
</input-wrapper>
</template> </template>
<script> <script>
import { VueEditor, Quill } from 'vue2-editor' import { inputProps, useFormInput } from './useFormInput.js'
import inputMixin from '~/mixins/forms/input.js' import InputWrapper from './components/InputWrapper.vue'
import { VueEditor, Quill } from 'vue3-editor'
Quill.imports['formats/link'].PROTOCOL_WHITELIST.push('notion') Quill.imports['formats/link'].PROTOCOL_WHITELIST.push('notion')
export default { export default {
name: 'RichTextAreaInput', name: 'RichTextAreaInput',
components: { VueEditor }, components: { InputWrapper, VueEditor },
mixins: [inputMixin],
props: { props: {
...inputProps,
editorToolbar: { editorToolbar: {
type: Array, type: Array,
default: () => { default: () => {
@ -43,11 +41,18 @@ export default {
[{ header: 1 }, { header: 2 }], [{ header: 1 }, { header: 2 }],
['bold', 'italic', 'underline', 'link'], ['bold', 'italic', 'underline', 'link'],
[{ list: 'ordered' }, { list: 'bullet' }], [{ list: 'ordered' }, { list: 'bullet' }],
[{color: []}] [{ color: [] }]
] ]
} }
} }
},
setup (props, context) {
return {
...useFormInput(props, context)
}
} }
} }
</script> </script>

View File

@ -1,14 +1,10 @@
<template> <template>
<div :class="wrapperClass" :style="inputStyle"> <input-wrapper
<label v-if="label" :for="id?id:name" v-bind="inputWrapperProps"
:class="[theme.default.label,{'uppercase text-xs':uppercaseLabels, 'text-sm':!uppercaseLabels}]" >
> <template #label>
{{ label }} <slot name="label" />
<span v-if="required" class="text-red-500 required-dot">*</span> </template>
</label>
<small v-if="help && helpPosition=='above_input'" :class="theme.default.help" class="flex mb-1">
<slot name="help"><span class="field-help" v-html="help" /></slot>
</small>
<div class="rectangle-outer grid grid-cols-5 gap-2"> <div class="rectangle-outer grid grid-cols-5 gap-2">
<div v-for="i in scaleList" :key="i" <div v-for="i in scaleList" :key="i"
@ -20,34 +16,43 @@
</div> </div>
</div> </div>
<small v-if="help && helpPosition=='below_input'" :class="theme.default.help"> <template #help>
<slot name="help"><span class="field-help" v-html="help" /></slot> <slot name="help" />
</small> </template>
<has-error v-if="hasValidation" :form="form" :field="name" /> <template #error>
</div> <slot name="error" />
</template>
</input-wrapper>
</template> </template>
<script> <script>
import inputMixin from '~/mixins/forms/input.js' import { inputProps, useFormInput } from './useFormInput.js'
import InputWrapper from './components/InputWrapper.vue'
export default { export default {
name: 'ScaleInput', name: 'ScaleInput',
components: { InputWrapper },
mixins: [inputMixin],
props: { props: {
...inputProps,
minScale: { type: Number, default: 1 }, minScale: { type: Number, default: 1 },
maxScale: { type: Number, default: 5 }, maxScale: { type: Number, default: 5 },
stepScale: { type: Number, default: 1 } stepScale: { type: Number, default: 1 }
}, },
setup (props, context) {
return {
...useFormInput(props, context)
}
},
data () { data () {
return {} return {}
}, },
computed: { computed: {
scaleList () { scaleList () {
let list = [] const list = []
for (let i = this.minScale; i <= this.maxScale; i += this.stepScale) { for (let i = this.minScale; i <= this.maxScale; i += this.stepScale) {
list.push(i) list.push(i)
} }
@ -74,7 +79,7 @@ export default {
}, },
mounted () { mounted () {
if (this.compVal && typeof this.compVal === 'string'){ if (this.compVal && typeof this.compVal === 'string') {
this.compVal = parseInt(this.compVal) this.compVal = parseInt(this.compVal)
} }
}, },

View File

@ -1,7 +1,12 @@
<template> <template>
<div :class="wrapperClass"> <input-wrapper
v-bind="inputWrapperProps"
>
<template #label>
<slot name="label" />
</template>
<v-select v-model="compVal" <v-select v-model="compVal"
:dusk="name"
:data="finalOptions" :data="finalOptions"
:label="label" :label="label"
:option-key="optionKey" :option-key="optionKey"
@ -15,29 +20,29 @@
:uppercase-labels="uppercaseLabels" :uppercase-labels="uppercaseLabels"
:theme="theme" :theme="theme"
:has-error="hasValidation && form.errors.has(name)" :has-error="hasValidation && form.errors.has(name)"
:allowCreation="allowCreation" :allow-creation="allowCreation"
:disabled="disabled" :disabled="disabled?true:null"
:help="help" :help="help"
:help-position="helpPosition" :help-position="helpPosition"
@update-options="updateOptions" @update-options="updateOptions"
> >
<template #selected="{option}"> <template #selected="{option}">
<template v-if="multiple"> <slot name="selected" :option="option" :optionName="getOptionName(option)">
<div class="flex items-center truncate mr-6"> <template v-if="multiple">
<span v-for="(item,index) in option" :key="item" class="truncate"> <div class="flex items-center truncate mr-6">
<span v-if="index!==0">, </span> <span v-for="(item,index) in option" :key="item" class="truncate">
{{ getOptionName(item) }} <span v-if="index!==0">, </span>
</span> {{ getOptionName(item) }}
</div> </span>
</template> </div>
<template v-else> </template>
<slot name="selected" :option="option" :optionName="getOptionName(option)"> <template v-else>
<div class="flex items-center truncate mr-6"> <div class="flex items-center truncate mr-6">
<div>{{ getOptionName(option) }}</div> <div>{{ getOptionName(option) }}</div>
</div> </div>
</slot> </template>
</template> </slot>
</template> </template>
<template #option="{option, selected}"> <template #option="{option, selected}">
<slot name="option" :option="option" :selected="selected"> <slot name="option" :option="option" :selected="selected">
@ -57,22 +62,30 @@
</slot> </slot>
</template> </template>
</v-select> </v-select>
<has-error v-if="hasValidation" :form="form" :field="name" /> <template #help>
</div> <slot name="help" />
</template>
<template #error>
<slot name="error" />
</template>
</input-wrapper>
</template> </template>
<script> <script>
import inputMixin from '~/mixins/forms/input.js' import { inputProps, useFormInput } from './useFormInput.js'
import InputWrapper from './components/InputWrapper.vue'
/** /**
* Options: {name,value} objects * Options: {name,value} objects
*/ */
export default { export default {
name: 'SelectInput', name: 'SelectInput',
mixins: [inputMixin], components: { InputWrapper },
props: { props: {
...inputProps,
options: { type: Array, required: true }, options: { type: Array, required: true },
optionKey: { type: String, default: 'value' }, optionKey: { type: String, default: 'value' },
emitKey: { type: String, default: 'value' }, emitKey: { type: String, default: 'value' },
@ -82,13 +95,21 @@ export default {
searchable: { type: Boolean, default: false }, searchable: { type: Boolean, default: false },
allowCreation: { type: Boolean, default: false } allowCreation: { type: Boolean, default: false }
}, },
setup (props, context) {
return {
...useFormInput(props, context)
}
},
data () { data () {
return { return {
additionalOptions: [] additionalOptions: []
} }
}, },
computed: { computed: {
finalOptions(){ finalOptions () {
return this.options.concat(this.additionalOptions) return this.options.concat(this.additionalOptions)
} }
}, },
@ -100,11 +121,11 @@ export default {
if (option) return option[this.displayKey] if (option) return option[this.displayKey]
return null return null
}, },
updateOptions(newItem) { updateOptions (newItem) {
if(newItem){ if (newItem) {
this.additionalOptions.push(newItem) this.additionalOptions.push(newItem)
} }
} }
} }
} }
</script> </script>

View File

@ -0,0 +1,62 @@
<template>
<input-wrapper
v-bind="inputWrapperProps"
>
<template #label>
<slot name="label" />
</template>
<VueSignaturePad ref="signaturePad"
:class="[theme.default.input,{ '!ring-red-500 !ring-2': hasValidation && form.errors.has(name), '!cursor-not-allowed !bg-gray-200':disabled }]"
height="150px"
:name="name"
:options="{ onEnd }"
/>
<template #bottom_after_help>
<small :class="theme.default.help">
<a :class="theme.default.help" href="#" @click.prevent="clear">Clear</a>
</small>
</template>
<template #error>
<slot name="error" />
</template>
</input-wrapper>
</template>
<script>
import { inputProps, useFormInput } from './useFormInput.js'
import InputWrapper from './components/InputWrapper.vue'
import { VueSignaturePad } from 'vue-signature-pad'
export default {
name: 'SignatureInput',
components: { InputWrapper, VueSignaturePad },
props: {
...inputProps
},
setup (props, context) {
return {
...useFormInput(props, context)
}
},
methods: {
clear () {
this.$refs.signaturePad.clearSignature()
this.onEnd()
},
onEnd () {
if (this.disabled) {
this.$refs.signaturePad.clearSignature()
} else {
const { isEmpty, data } = this.$refs.signaturePad.saveSignature()
this.form[this.name] = (!isEmpty && data) ? data : null
}
}
}
}
</script>

View File

@ -0,0 +1,56 @@
<template>
<input-wrapper
v-bind="inputWrapperProps"
>
<template #label>
<slot name="label" />
</template>
<textarea :id="id?id:name" v-model="compVal" :disabled="disabled?true:null"
:class="[theme.default.input,{ '!ring-red-500 !ring-2': hasValidation && form.errors.has(name), '!cursor-not-allowed !bg-gray-200':disabled }]"
class="resize-y"
:name="name" :style="inputStyle"
:placeholder="placeholder"
:maxlength="maxCharLimit"
/>
<template v-if="maxCharLimit && showCharLimit" #bottom_after_help>
<small :class="theme.default.help">
{{ charCount }}/{{ maxCharLimit }}
</small>
</template>
<template #error>
<slot name="error" />
</template>
</input-wrapper>
</template>
<script>
import { inputProps, useFormInput } from './useFormInput.js'
import InputWrapper from './components/InputWrapper.vue'
export default {
name: 'TextAreaInput',
components: { InputWrapper },
mixins: [],
props: {
...inputProps,
maxCharLimit: { type: Number, required: false, default: null },
showCharLimit: { type: Boolean, required: false, default: false }
},
setup (props, context) {
return {
...useFormInput(props, context)
}
},
computed: {
charCount () {
return (this.compVal) ? this.compVal.length : 0
}
}
}
</script>

View File

@ -0,0 +1,77 @@
<template>
<input-wrapper
v-bind="inputWrapperProps"
>
<template #label>
<slot name="label" />
</template>
<input :id="id?id:name" v-model="compVal" :disabled="disabled?true:null"
:type="nativeType" :autocomplete="autocomplete"
:pattern="pattern"
:style="inputStyle"
:class="[theme.default.input, { '!ring-red-500 !ring-2': hasError, '!cursor-not-allowed !bg-gray-200': disabled }]"
:name="name" :accept="accept"
:placeholder="placeholder" :min="min" :max="max" :maxlength="maxCharLimit"
@change="onChange" @keydown.enter.prevent="onEnterPress"
>
<template v-if="maxCharLimit && showCharLimit" #bottom_after_help>
<small :class="theme.default.help">
{{ charCount }}/{{ maxCharLimit }}
</small>
</template>
<template #error>
<slot name="error" />
</template>
</input-wrapper>
</template>
<script>
import { inputProps, useFormInput } from './useFormInput.js'
import InputWrapper from './components/InputWrapper.vue'
export default {
name: 'TextInput',
components: { InputWrapper },
props: {
...inputProps,
nativeType: { type: String, default: 'text' },
accept: { type: String, default: null },
min: { type: Number, required: false, default: null },
max: { type: Number, required: false, default: null },
autocomplete: { default: null },
maxCharLimit: { type: Number, required: false, default: null },
showCharLimit: { type: Boolean, required: false, default: false },
pattern: { type: String, default: null }
},
setup (props, context) {
const onChange = (event) => {
if (props.nativeType !== 'file') return
const file = event.target.files[0]
// eslint-disable-next-line vue/no-mutating-props
props.form[props.name] = file
}
const onEnterPress = (event) => {
event.preventDefault()
return false
}
return {
...useFormInput(props, context, props.nativeType === 'file' ? 'file-' : null),
onEnterPress,
onChange
}
},
computed: {
charCount () {
return (this.compVal) ? this.compVal.length : 0
}
}
}
</script>

View File

@ -0,0 +1,46 @@
<template>
<input-wrapper v-bind="inputWrapperProps">
<template #label>
<span />
</template>
<div class="flex">
<v-switch :id="id?id:name" v-model="compVal" class="inline-block mr-2" :disabled="disabled?true:null" />
<slot name="label">
<span>{{ label }} <span v-if="required" class="text-red-500 required-dot">*</span></span>
</slot>
</div>
<template #help>
<slot name="help" />
</template>
<template #error>
<slot name="error" />
</template>
</input-wrapper>
</template>
<script>
import { inputProps, useFormInput } from './useFormInput.js'
import VSwitch from './components/VSwitch.vue'
import InputWrapper from './components/InputWrapper.vue'
export default {
name: 'ToggleSwitchInput',
components: { InputWrapper, VSwitch },
props: {
...inputProps
},
setup (props, context) {
return {
...useFormInput(props, context)
}
},
mounted () {
this.compVal = !!this.compVal
}
}
</script>

View File

@ -0,0 +1,21 @@
<template>
<div class="flex mb-1 input-help">
<small :class="theme.default.help" class="grow flex">
<slot name="help"><span class="field-help" v-html="help" /></slot>
</small>
<slot name="after-help">
<small class="flex-grow" />
</slot>
</div>
</template>
<script>
export default {
name: 'InputHelp',
props: {
theme: { type: Object, required: true },
help: { type: String, required: false }
}
}
</script>

View File

@ -0,0 +1,25 @@
<template>
<label :for="nativeFor"
class="input-label"
:class="[theme.default.label,{'uppercase text-xs': uppercaseLabels, 'text-sm': !uppercaseLabels}]"
>
<slot>
{{ label }}
<span v-if="required" class="text-red-500 required-dot">*</span>
</slot>
</label>
</template>
<script>
export default {
name: 'InputLabel',
props: {
nativeFor: { type: String, default: null },
theme: { type: Object, required: true },
uppercaseLabels: { type: Boolean, default: false },
required: { type: Boolean, default: false },
label: { type: String, required: true }
}
}
</script>

View File

@ -0,0 +1,54 @@
<template>
<div :class="wrapperClass" :style="inputStyle">
<slot name="label">
<input-label v-if="label && !hideFieldName"
:label="label"
:theme="theme"
:required="required"
:native-for="id?id:name"
:uppercase-labels="uppercaseLabels"
/>
</slot>
<slot v-if="help && helpPosition==='above_input'" name="help">
<input-help :help="help" :theme="theme" />
</slot>
<slot />
<slot v-if="(help && helpPosition==='below_input') || $slots.bottom_after_help" name="help">
<input-help :help="help" :theme="theme">
<template #after-help>
<slot name="bottom_after_help" />
</template>
</input-help>
</slot>
<slot name="error">
<has-error v-if="hasValidation && form" :form="form" :field="name" />
</slot>
</div>
</template>
<script>
import InputLabel from './InputLabel.vue'
import InputHelp from './InputHelp.vue'
export default {
name: 'InputWrapper',
components: { InputLabel, InputHelp },
props: {
id: { type: String, required: false },
name: { type: String, required: false },
label: { type: String, required: false },
form: { type: Object, required: false },
theme: { type: Object, required: true },
wrapperClass: { type: String, required: false },
inputStyle: { type: Object, required: false },
help: { type: String, required: false },
helpPosition: { type: String, default: 'below_input' },
uppercaseLabels: { type: Boolean, default: true },
hideFieldName: { type: Boolean, default: true },
required: { type: Boolean, default: false },
hasValidation: { type: Boolean, default: true }
}
}
</script>

View File

@ -4,7 +4,7 @@
:title="file.file.name" :title="file.file.name"
> >
<div v-if="file.src && !isImageHide" class="h-20 overflow-hidden flex"> <div v-if="file.src && !isImageHide" class="h-20 overflow-hidden flex">
<img class="block object-cover object-center w-full" :src="file.src" @error="isImageHide=true"> <img class="block object-cover object-center w-full" :src="file.src" @error="isImageHide=true"/>
</div> </div>
<div v-else class="h-20 flex items-center justify-center"> <div v-else class="h-20 flex items-center justify-center">
<svg class="w-10 h-10 text-gray-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" <svg class="w-10 h-10 text-gray-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"

View File

@ -0,0 +1,72 @@
<template>
<div class="flex items-center">
<input
:id="id || name"
:name="name"
:checked="internalValue"
type="checkbox"
:class="sizeClasses"
class="rounded border-gray-500 cursor-pointer"
:disabled="disabled?true:null"
@click="handleClick"
>
<label :for="id || name" class="text-gray-700 dark:text-gray-300 ml-2" :class="{'!cursor-not-allowed':disabled}">
<slot />
</label>
</div>
</template>
<script setup>
import { ref, watch, onMounted, defineProps, defineEmits, defineOptions } from 'vue'
defineOptions({
name: 'VCheckbox'
})
const props = defineProps({
id: { type: String, default: null },
name: { type: String, default: 'checkbox' },
modelValue: { type: [Boolean, String], default: false },
checked: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
sizeClasses: { type: String, default: 'w-4 h-4' }
})
const emit = defineEmits(['update:modelValue', 'click'])
const internalValue = ref(props.modelValue)
watch(() => props.modelValue, val => {
internalValue.value = val
})
watch(() => props.checked, val => {
internalValue.value = val
})
watch(() => internalValue.value, (val, oldVal) => {
if (val === 0 || val === '0') val = false
if (val === 1 || val === '1') val = true
if (val !== oldVal) {
emit('update:modelValue', val)
}
})
if ('checked' in props) {
internalValue.value = props.checked
}
onMounted(() => {
emit('update:modelValue', internalValue.value)
})
const handleClick = (e) => {
emit('click', e)
if (!e.isPropagationStopped) {
internalValue.value = e.target.checked
emit('update:modelValue', internalValue.value)
}
}
</script>

View File

@ -0,0 +1,230 @@
<template>
<div class="v-select relative" ref="select">
<span class="inline-block w-full rounded-md">
<button type="button" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label"
class="cursor-pointer"
:style="inputStyle"
:class="[theme.SelectInput.input,{'py-2': !multiple || loading,'py-1': multiple, '!ring-red-500 !ring-2': hasError, '!cursor-not-allowed !bg-gray-200': disabled}, inputClass]"
@click="toggleDropdown"
>
<div :class="{'h-6': !multiple, 'min-h-8': multiple && !loading}">
<transition name="fade" mode="out-in">
<Loader v-if="loading" key="loader" class="h-6 w-6 text-nt-blue mx-auto" />
<div v-else-if="modelValue" key="value" class="flex" :class="{'min-h-8': multiple}">
<slot name="selected" :option="modelValue" />
</div>
<div v-else key="placeholder">
<slot name="placeholder">
<div class="text-gray-400 dark:text-gray-500 w-full text-left truncate pr-3"
:class="{'py-1': multiple && !loading}"
>
{{ placeholder }}
</div>
</slot>
</div>
</transition>
</div>
<span class="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<svg class="h-5 w-5 text-gray-400" viewBox="0 0 20 20" fill="none" stroke="currentColor">
<path d="M7 7l3-3 3 3m0 6l-3 3-3-3" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</span>
</button>
</span>
<collapsible v-model="isOpen" @click-away="onClickAway"
class="absolute mt-1 rounded-md bg-white dark:bg-notion-dark-light shadow-xl z-10"
:class="dropdownClass"
>
<ul tabindex="-1" role="listbox"
class="rounded-md text-base leading-6 shadow-xs overflow-auto focus:outline-none sm:text-sm sm:leading-5 relative"
:class="{'max-h-42 py-1': !isSearchable,'max-h-48 pb-1': isSearchable}"
>
<div v-if="isSearchable" class="px-2 pt-2 sticky top-0 bg-white dark-bg-notion-dark-light z-10">
<text-input v-model="searchTerm" name="search" :color="color" :theme="theme"
placeholder="Search..."
/>
</div>
<div v-if="loading" class="w-full py-2 flex justify-center">
<Loader class="h-6 w-6 text-nt-blue mx-auto" />
</div>
<template v-if="filteredOptions.length > 0">
<li v-for="item in filteredOptions" :key="item[optionKey]" role="option" :style="optionStyle"
:class="{'px-3 pr-9': multiple, 'px-3': !multiple}"
class="text-gray-900 cursor-default select-none relative py-2 cursor-pointer group hover:text-white hover:bg-form-color focus:outline-none focus-text-white focus-nt-blue"
@click="select(item)"
>
<slot name="option" :option="item" :selected="isSelected(item)" />
</li>
</template>
<p v-else-if="!loading && !(allowCreation && searchTerm)" class="w-full text-gray-500 text-center py-2">
{{ (allowCreation ? 'Type something to add an option' : 'No option available') }}.
</p>
<li v-if="allowCreation && searchTerm" role="option" :style="optionStyle"
:class="{'px-3 pr-9': multiple, 'px-3': !multiple}"
class="text-gray-900 cursor-default select-none relative py-2 cursor-pointer group hover:text-white hover:bg-form-color focus:outline-none focus-text-white focus-nt-blue"
@click="createOption(searchTerm)"
>
Create <b class="px-1 bg-gray-300 rounded group-hover-text-black">{{ searchTerm }}</b>
</li>
</ul>
</collapsible>
</div>
</template>
<script>
import Collapsible from '~/components/global/transitions/Collapsible.vue'
import { themes } from '../../../lib/forms/form-themes.js'
import TextInput from '../TextInput.vue'
import debounce from 'debounce'
import Fuse from 'fuse.js'
export default {
name: 'VSelect',
components: { Collapsible, TextInput },
directives: {},
props: {
data: Array,
modelValue: { default: null },
inputClass: { type: String, default: null },
dropdownClass: { type: String, default: 'w-full' },
loading: { type: Boolean, default: false },
required: { type: Boolean, default: false },
multiple: { type: Boolean, default: false },
searchable: { type: Boolean, default: false },
hasError: { type: Boolean, default: false },
remote: { type: Function, default: null },
searchKeys: { type: Array, default: () => ['name'] },
optionKey: { type: String, default: 'id' },
emitKey: { type: String, default: null },
color: { type: String, default: '#3B82F6' },
placeholder: { type: String, default: null },
uppercaseLabels: { type: Boolean, default: true },
theme: { type: Object, default: () => themes.default },
allowCreation: { type: Boolean, default: false },
disabled: { type: Boolean, default: false }
},
data () {
return {
isOpen: false,
searchTerm: '',
defaultValue: this.modelValue ?? null
}
},
computed: {
optionStyle () {
return {
'--bg-form-color': this.color
}
},
inputStyle () {
return {
'--tw-ring-color': this.color
}
},
debouncedRemote () {
if (this.remote) {
return debounce(this.remote, 300)
}
return null
},
filteredOptions () {
if (!this.data) return []
if (!this.searchable || this.remote || this.searchTerm === '') {
return this.data
}
// Fuse search
const fuzeOptions = {
keys: this.searchKeys
}
const fuse = new Fuse(this.data, fuzeOptions)
return fuse.search(this.searchTerm).map((res) => {
return res.item
})
},
isSearchable () {
return this.searchable || this.remote !== null || this.allowCreation
}
},
watch: {
searchTerm (val) {
if (!this.debouncedRemote) return
if ((this.remote && val) || (val === '' && !this.modelValue) || (val === '' && this.isOpen)) {
return this.debouncedRemote(val)
}
}
},
methods: {
onClickAway (event) {
// Check that event target isn't children of dropdown
if (this.$refs.select && !this.$refs.select.contains(event.target)) {
this.isOpen = false
}
},
isSelected (value) {
if (!this.modelValue) return false
if (this.emitKey && value[this.emitKey]) {
value = value[this.emitKey]
}
if (this.multiple) {
return this.modelValue.includes(value)
}
return this.modelValue === value
},
toggleDropdown () {
if (this.disabled) {
this.isOpen = false
}
this.isOpen = !this.isOpen
if (!this.isOpen) {
this.searchTerm = ''
}
},
select (value) {
if (!this.multiple) {
// Close after select
this.toggleDropdown()
}
if (this.emitKey) {
value = value[this.emitKey]
}
if (this.multiple) {
const emitValue = Array.isArray(this.modelValue) ? [...this.modelValue] : []
if (this.isSelected(value)) {
this.$emit('update:modelValue', emitValue.filter((item) => {
if (this.emitKey) {
return item !== value
}
return item[this.optionKey] !== value && item[this.optionKey] !== value[this.optionKey]
}))
return
}
emitValue.push(value)
this.$emit('update:modelValue', emitValue)
} else {
if (this.modelValue === value) {
this.$emit('update:modelValue', this.defaultValue ?? null)
} else {
this.$emit('update:modelValue', value)
}
}
},
createOption (newOption) {
if (newOption) {
const newItem = {
name: newOption,
value: newOption
}
this.$emit('update-options', newItem)
this.select(newItem)
}
}
}
}
</script>

View File

@ -0,0 +1,22 @@
<template>
<div role="button" @click="onClick">
<div class="inline-flex items-center h-6 w-12 p-1 bg-gray-300 border rounded-full cursor-pointer focus:outline-none transition-all transform ease-in-out duration-100" :class="{'bg-nt-blue': modelValue}">
<div class="inline-block h-4 w-4 rounded-full bg-white shadow transition-all transform ease-in-out duration-150 rounded-2xl scale-100" :class="{'translate-x-5.5': modelValue}" />
</div>
</div>
</template>
<script setup>
import { defineProps, defineEmits } from 'vue'
const { modelValue, disabled } = defineProps({
modelValue: { type: Boolean, default: false },
disabled: { type: Boolean, default: false }
})
const emit = defineEmits(['update:modelValue'])
const onClick = () => {
if (disabled) return
emit('update:modelValue', !modelValue)
}
</script>

88
client/components/forms/useFormInput.js vendored Normal file
View File

@ -0,0 +1,88 @@
import { ref, computed, watch } from 'vue'
import { themes } from '~/lib/forms/form-themes.js'
export const inputProps = {
id: { type: String, default: null },
name: { type: String, required: true },
label: { type: String, required: false },
form: { type: Object, required: false },
theme: { type: Object, default: () => themes.default },
modelValue: { required: false },
required: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
placeholder: { type: String, default: null },
uppercaseLabels: { type: Boolean, default: false },
hideFieldName: { type: Boolean, default: false },
help: { type: String, default: null },
helpPosition: { type: String, default: 'below_input' },
color: { type: String, default: '#3B82F6' },
wrapperClass: { type: String, default: 'relative mb-3' }
}
export function useFormInput (props, context, formPrefixKey = null) {
const content = ref(props.modelValue)
const inputStyle = computed(() => {
return {
'--tw-ring-color': props.color
}
})
const hasValidation = computed(() => {
return props.form !== null && props.form !== undefined && props.form.hasOwnProperty('errors')
})
const hasError = computed(() => {
return hasValidation && props.form?.errors?.has(props.name)
})
const compVal = computed({
get: () => {
if (props.form) {
return props.form[(formPrefixKey || '') + props.name]
}
return content.value
},
set: (val) => {
if (props.form) {
props.form[(formPrefixKey || '') + props.name] = val
} else {
content.value = val
}
if (hasValidation.value) {
props.form.errors.clear(props.name)
}
context.emit('update:modelValue', compVal.value)
}
})
const inputWrapperProps = computed(() => {
const wrapperProps = {}
Object.keys(inputProps).forEach((key) => {
if (!['modelValue', 'disabled', 'placeholder', 'color'].includes(key)) {
wrapperProps[key] = props[key]
}
})
return wrapperProps
})
// Watch for changes in props.modelValue and update the local content
watch(
() => props.modelValue,
(newValue) => {
if (content.value !== newValue) {
content.value = newValue
}
}
)
return {
compVal,
inputStyle,
hasValidation,
hasError,
inputWrapperProps
}
}

View File

@ -21,7 +21,7 @@ export default {
}, },
computed: { computed: {
errorMessage () { errorMessage () {
if (!this.form.errors || !this.form.errors.any()) return null if (!this.form || !this.form.errors || !this.form.errors.any()) return null
const subErrorsKeys = Object.keys(this.form.errors.all()).filter((key) => { const subErrorsKeys = Object.keys(this.form.errors.all()).filter((key) => {
return key.startsWith(this.field) && key !== this.field return key.startsWith(this.field) && key !== this.field
}) })

View File

@ -6,7 +6,7 @@
<div class="px-4 mx-auto sm:px-6 lg:px-8 max-w-7xl"> <div class="px-4 mx-auto sm:px-6 lg:px-8 max-w-7xl">
<div class="flex items-center justify-center space-x-4"> <div class="flex items-center justify-center space-x-4">
<div v-if="displayHome" class="flex items-center"> <div v-if="displayHome" class="flex items-center">
<router-link class="text-gray-400 hover:text-gray-500" :to="{ name: (authenticated) ? 'home' : 'welcome' }"> <NuxtLink class="text-gray-400 hover:text-gray-500" :to="{ name: (authenticated) ? 'home' : 'index' }">
<svg class="flex-shrink-0 w-5 h-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <svg class="flex-shrink-0 w-5 h-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" <path fill-rule="evenodd"
d="M9.293 2.293a1 1 0 011.414 0l7 7A1 1 0 0117 11h-1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-3a1 1 0 00-1-1H9a1 1 0 00-1 1v3a1 1 0 01-1 1H5a1 1 0 01-1-1v-6H3a1 1 0 01-.707-1.707l7-7z" d="M9.293 2.293a1 1 0 011.414 0l7 7A1 1 0 0117 11h-1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-3a1 1 0 00-1-1H9a1 1 0 00-1 1v3a1 1 0 01-1 1H5a1 1 0 01-1-1v-6H3a1 1 0 01-.707-1.707l7-7z"
@ -14,7 +14,7 @@
/> />
</svg> </svg>
<span class="sr-only">Home</span> <span class="sr-only">Home</span>
</router-link> </NuxtLink>
<svg class="flex-shrink-0 w-5 h-5 text-gray-400 ml-4" viewBox="0 0 20 20" fill="currentColor" <svg class="flex-shrink-0 w-5 h-5 text-gray-400 ml-4" viewBox="0 0 20 20" fill="currentColor"
aria-hidden="true" aria-hidden="true"
> >
@ -26,11 +26,11 @@
</div> </div>
<div v-for="(item,index) in path" :key="index" class="flex items-center"> <div v-for="(item,index) in path" :key="index" class="flex items-center">
<router-link v-if="item.route" class="text-sm font-semibold text-gray-500 hover:text-gray-700 truncate" <NuxtLink v-if="item.route" class="text-sm font-semibold text-gray-500 hover:text-gray-700 truncate"
:to="item.route" :to="item.route"
> >
{{ item.label }} {{ item.label }}
</router-link> </NuxtLink>
<div v-else class="text-sm font-semibold sm:w-full w-36 text-blue-500 truncate"> <div v-else class="text-sm font-semibold sm:w-full w-36 text-blue-500 truncate">
{{ item.label }} {{ item.label }}
</div> </div>
@ -54,7 +54,8 @@
</template> </template>
<script> <script>
import { mapGetters } from 'vuex' import { computed } from 'vue'
import { useAuthStore } from '../../stores/auth';
export default { export default {
name: 'Breadcrumb', name: 'Breadcrumb',
@ -66,20 +67,22 @@ export default {
path: { type: Array } path: { type: Array }
}, },
setup () {
const authStore = useAuthStore()
return {
authenticated : computed(() => authStore.check)
}
},
data () { data () {
return { return {
displayHome: true displayHome: true
} }
}, },
computed: { computed: {},
...mapGetters({
authenticated: 'auth/check'
})
},
mounted () { mounted () {},
},
methods: {} methods: {}
} }

View File

@ -15,34 +15,27 @@
</svg> </svg>
</div> </div>
</div> </div>
<v-transition> <VTransition>
<div v-if="showContent" class="w-full"> <div v-if="showContent" class="w-full">
<slot /> <slot />
</div> </div>
</v-transition> </VTransition>
</div> </div>
</template> </template>
<script> <script setup>
import VTransition from './transitions/VTransition.vue' import VTransition from './transitions/VTransition.vue'
import { ref, defineProps, defineEmits } from 'vue'
export default { const props = defineProps({
name: 'Collapse', modelValue: { type: Boolean, default: null }
components: { VTransition }, })
props: {
defaultValue: { type: Boolean, default: false }, const showContent = ref(props.modelValue)
value: { type: Boolean, default: null } const emit = defineEmits()
},
data () { const trigger = () => {
return { showContent.value = !showContent.value
showContent: this.value ?? this.defaultValue emit('update:modelValue', showContent.value)
}
},
methods: {
trigger () {
this.showContent = !this.showContent
this.$emit('input', this.showContent)
}
}
} }
</script> </script>

View File

@ -0,0 +1,49 @@
<template>
<div class="relative" ref="dropdown">
<slot name="trigger"
:toggle="toggle"
:open="open"
:close="close"
/>
<collapsible v-model="isOpen" :class="dropdownClass" @click-away="onClickAway">
<div class="py-1 " role="menu" aria-orientation="vertical" aria-labelledby="options-menu">
<slot/>
</div>
</collapsible>
</div>
</template>
<script setup>
import {ref} from 'vue'
import Collapsible from './transitions/Collapsible.vue'
const props = defineProps({
dropdownClass: {
type: String,
default: 'origin-top-right absolute right-0 mt-2 w-56 rounded-md shadow-lg bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5 z-20'
}
})
const isOpen = ref(false)
const dropdown = ref(null)
const open = (event) => {
isOpen.value = true
}
const close = (event) => {
isOpen.value = false
}
const toggle = (event) => {
isOpen.value = !isOpen.value
}
const onClickAway = (event) => {
// Check that event target isn't children of dropdown
if (dropdown.value && !dropdown.value.contains(event.target)) {
close(event)
}
}
</script>

View File

@ -0,0 +1,72 @@
<template>
<div ref="parentRef"
tabindex="0"
:class="{
'hover:bg-gray-100 dark:hover:bg-gray-800 rounded px-2 cursor-pointer': !editing
}"
class="relative"
:style="{ height: editing ? divHeight + 'px' : 'auto' }"
@focus="startEditing"
>
<slot v-if="!editing" :content="content">
<label class="cursor-pointer truncate w-full">
{{ content }}
</label>
</slot>
<div v-if="editing" class="absolute inset-0 border-2 transition-colors"
:class="{ 'border-transparent': !editing, 'border-blue-500': editing }"
>
<input ref="editInputRef" v-model="content"
class="absolute inset-0 focus:outline-none bg-white transition-colors"
:class="[{'bg-blue-50': editing}, contentClass]" @blur="editing = false" @keyup.enter="editing = false"
@input="handleInput"
>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, watch, nextTick, defineProps, defineEmits } from 'vue'
const props = defineProps({
modelValue: { type: String, required: true },
textAlign: { type: String, default: 'left' },
contentClass: { type: String, default: '' }
})
const emit = defineEmits()
const content = ref(props.modelValue)
const editing = ref(false)
const divHeight = ref(0)
const parentRef = ref(null) // Ref for parent element
const editInputRef = ref(null) // Ref for edit input element
const startEditing = () => {
if (parentRef.value) {
divHeight.value = parentRef.value.offsetHeight
editing.value = true
nextTick(() => {
if (editInputRef.value) {
editInputRef.value.focus()
}
})
}
}
const handleInput = () => {
emit('update:modelValue', content.value)
}
// Watch for changes in props.modelValue and update the local content
watch(() => props.modelValue, (newValue) => {
content.value = newValue
})
// Wait until the component is mounted to set the initial divHeight
onMounted(() => {
if (parentRef.value) {
divHeight.value = parentRef.value.offsetHeight
}
})
</script>

View File

@ -0,0 +1,175 @@
<template>
<Teleport to="body">
<transition @leave="onLeave">
<div v-if="show" ref="backdrop"
class="fixed z-30 top-0 inset-0 px-4 sm:px-0 flex items-top justify-center bg-gray-700/75 w-full h-screen overflow-y-scroll"
:class="{'backdrop-blur-sm':backdropBlur}"
@click.self="close"
>
<div ref="content"
class="self-start bg-white dark:bg-notion-dark w-full relative p-4 md:p-6 my-6 rounded-xl shadow-xl"
:class="maxWidthClass"
>
<div v-if="closeable" class="absolute top-4 right-4">
<button class="text-gray-500 hover:text-gray-900 cursor-pointer" @click="close()">
<svg class="h-6 w-6" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
</div>
<div class="sm:flex sm:flex-col sm:items-start">
<div v-if="$slots.hasOwnProperty('icon')" class="flex w-full justify-center mb-4">
<div class="w-14 h-14 rounded-full flex justify-center items-center"
:class="'bg-'+iconColor+'-100 text-'+iconColor+'-600'"
>
<slot name="icon"/>
</div>
</div>
<div class="mt-3 text-center sm:mt-0 w-full">
<h2 v-if="$slots.hasOwnProperty('title')"
class="text-2xl font-semibold text-center text-gray-900"
>
<slot name="title"/>
</h2>
</div>
</div>
<div class="w-full">
<slot/>
</div>
<div v-if="$slots.hasOwnProperty('footer')" class="px-6 py-4 bg-gray-100 text-right">
<slot name="footer"/>
</div>
</div>
</div>
</transition>
</Teleport>
</template>
<script setup>
import {watch} from "vue";
const props = defineProps({
show: {
default: false
},
backdropBlur: {
type: Boolean,
default: false
},
iconColor: {
default: 'blue'
},
maxWidth: {
default: '2xl'
},
closeable: {
default: true
}
})
const emits = defineEmits(['close'])
useHead({
bodyAttrs: {
class: {
'overflow-hidden': props.show
}
}
})
const closeOnEscape = (e) => {
if (e.key === 'Escape' && this.show) {
this.close()
}
}
onMounted(() => {
if (process.server) return
document.addEventListener('keydown', closeOnEscape)
initMotions()
})
onBeforeUnmount(() => {
if (process.server) return
document.removeEventListener('keydown', closeOnEscape)
})
const maxWidthClass = computed(() => {
return {
sm: 'sm:max-w-sm',
md: 'sm:max-w-md',
lg: 'sm:max-w-lg',
xl: 'sm:max-w-xl',
'2xl': 'sm:max-w-2xl'
}[props.maxWidth]
})
const motionFadeIn = {
initial: {
opacity: 0,
transition: {
delay: 100,
duration: 200,
ease: 'easeIn'
}
},
enter: {
opacity: 1,
transition: {
duration: 200
}
}
}
const motionSlideBottom = {
initial: {
y: 150,
opacity: 0,
transition: {
ease: 'easeIn',
duration: 200
}
},
enter: {
y: 0,
opacity: 1,
transition: {
duration: 250,
ease: 'easeOut',
delay: 100
}
}
}
const onLeave = (el, done) => {
contentMotion.value.leave(()=>{})
backdropMotion.value.leave(done)
}
const close = () => {
if (props.closeable) {
emits('close')
}
}
const backdrop = ref(null)
const content = ref(null)
const backdropMotion = ref(null)
const contentMotion = ref(null)
const initMotions = () => {
if (props.show) {
nextTick(() => {
backdropMotion.value = useMotion(backdrop.value, motionFadeIn)
contentMotion.value = useMotion(content.value, motionSlideBottom)
})
}
}
watch(() => props.show, initMotions)
</script>

View File

@ -3,44 +3,46 @@
<div class="max-w-7xl mx-auto px-8"> <div class="max-w-7xl mx-auto px-8">
<div class="flex items-center justify-between h-16"> <div class="flex items-center justify-between h-16">
<div class="flex items-center"> <div class="flex items-center">
<router-link :to="{ name: user ? 'home' : 'welcome' }" class="flex-shrink-0 font-semibold hover:no-underline flex items-center"> <NuxtLink :to="{ name: user ? 'home' : 'index' }"
<img :src="asset('img/logo.svg')" alt="notion tools logo" class="w-8 h-8"> class="flex-shrink-0 font-semibold hover:no-underline flex items-center">
<span <NuxtImg src="/img/logo.svg" alt="notion tools logo" class="w-8 h-8"/>
class="ml-2 text-md hidden sm:inline text-black dark:text-white" <span class="ml-2 text-md hidden sm:inline text-black dark:text-white">OpnForm</span>
> </NuxtLink>
{{ appName }}</span> <workspace-dropdown class="ml-6"/>
</router-link>
<workspace-dropdown class="ml-6" />
</div> </div>
<div v-if="showAuth" class="hidden md:block ml-auto relative"> <div v-if="showAuth" class="hidden md:block ml-auto relative">
<router-link v-if="$route.name !== 'templates'" :to="{name:'templates'}" <NuxtLink v-if="$route.name !== 'templates'" :to="{name:'templates'}"
class="text-sm text-gray-600 dark:text-white hover:text-gray-800 cursor-pointer mt-1 mr-8" class="text-sm text-gray-600 dark:text-white hover:text-gray-800 cursor-pointer mt-1 mr-8"
> >
Templates Templates
</router-link> </NuxtLink>
<router-link v-if="$route.name !== 'aiformbuilder'" :to="{name:'aiformbuilder'}" <NuxtLink v-if="$route.name !== 'ai-form-builder'" :to="{name:'ai-form-builder'}"
class="text-sm text-gray-600 dark:text-white hidden lg:inline hover:text-gray-800 cursor-pointer mt-1 mr-8" class="text-sm text-gray-600 dark:text-white hidden lg:inline hover:text-gray-800 cursor-pointer mt-1 mr-8"
> >
AI Form Builder AI Form Builder
</router-link> </NuxtLink>
<router-link v-if="paidPlansEnabled && (user===null || (user && workspace && !workspace.is_pro)) && $route.name !== 'pricing'" :to="{name:'pricing'}" <NuxtLink
class="text-sm text-gray-600 dark:text-white hover:text-gray-800 cursor-pointer mt-1 mr-8" v-if="paidPlansEnabled && (user===null || (user && workspace && !workspace.is_pro)) && $route.name !== 'pricing'"
:to="{name:'pricing'}"
class="text-sm text-gray-600 dark:text-white hover:text-gray-800 cursor-pointer mt-1 mr-8"
> >
<span v-if="user">Upgrade</span> <span v-if="user">Upgrade</span>
<span v-else>Pricing</span> <span v-else>Pricing</span>
</router-link> </NuxtLink>
<a v-if="hasCrisp" href="#" <a v-if="hasCrisp" href="#"
class="text-sm text-gray-600 dark:text-white hover:text-gray-800 cursor-pointer mt-1" @click.prevent="openCrisp" class="text-sm text-gray-600 dark:text-white hover:text-gray-800 cursor-pointer mt-1"
@click.prevent="openCrisp"
> >
Help Help
</a> </a>
<a v-else :href="helpUrl" <NuxtLink v-else :href="helpUrl"
class="text-sm text-gray-600 dark:text-white hover:text-gray-800 cursor-pointer mt-1" target="_blank" class="text-sm text-gray-600 dark:text-white hover:text-gray-800 cursor-pointer mt-1"
target="_blank"
> >
Help Help
</a> </NuxtLink>
</div> </div>
<div v-if="showAuth" class="hidden md:block pl-5 border-gray-300 border-r h-5" /> <div v-if="showAuth" class="hidden md:block pl-5 border-gray-300 border-r h-5"/>
<div v-if="showAuth" class="block"> <div v-if="showAuth" class="block">
<div class="flex items-center"> <div class="flex items-center">
<div class="ml-3 mr-4 relative"> <div class="ml-3 mr-4 relative">
@ -49,17 +51,17 @@
<template #trigger="{toggle}"> <template #trigger="{toggle}">
<button id="dropdown-menu-button" type="button" <button id="dropdown-menu-button" type="button"
class="flex items-center justify-center w-full rounded-md px-4 py-2 text-sm text-gray-700 dark:text-gray-50 hover:bg-gray-50 dark:hover:bg-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-100 focus:ring-gray-500" class="flex items-center justify-center w-full rounded-md px-4 py-2 text-sm text-gray-700 dark:text-gray-50 hover:bg-gray-50 dark:hover:bg-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-100 focus:ring-gray-500"
dusk="nav-dropdown-button" @click.prevent="toggle()" dusk="nav-dropdown-button" @click.stop="toggle()"
> >
<img :src="user.photo_url" class="rounded-full w-6 h-6"> <img :src="user.photo_url" class="rounded-full w-6 h-6"/>
<p class="ml-2 hidden sm:inline"> <p class="ml-2 hidden sm:inline">
{{ user.name }} {{ user.name }}
</p> </p>
</button> </button>
</template> </template>
<router-link v-if="userOnboarded" :to="{ name: 'home' }" <NuxtLink v-if="userOnboarded" :to="{ name: 'home' }"
class="block block px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600 flex items-center" class="block block px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600 flex items-center"
> >
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24" <svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
@ -69,19 +71,21 @@
/> />
</svg> </svg>
My Forms My Forms
</router-link> </NuxtLink>
<router-link v-if="userOnboarded" :to="{ name: 'my_templates' }" <NuxtLink v-if="userOnboarded" :to="{ name: 'templates-my-templates' }"
class="block block px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600 flex items-center" class="block block px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600 flex items-center"
> >
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24"
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" /> stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"/>
</svg> </svg>
My Templates My Templates
</router-link> </NuxtLink>
<router-link :to="{ name: 'settings.profile' }" <NuxtLink :to="{ name: 'settings-profile' }"
class="block block px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600 flex items-center" class="block block px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600 flex items-center"
> >
<svg class="w-4 h-4 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" <svg class="w-4 h-4 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
@ -93,8 +97,8 @@
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/> />
</svg> </svg>
{{ $t('settings') }} Settings
</router-link> </NuxtLink>
<a href="#" <a href="#"
class="block block px-4 py-2 text-md text-gray-700 dark:text-white hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600 flex items-center" class="block block px-4 py-2 text-md text-gray-700 dark:text-white hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600 flex items-center"
@ -107,18 +111,19 @@
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/> />
</svg> </svg>
{{ $t('logout') }} Logout
</a> </a>
</dropdown> </dropdown>
<div v-else class="flex gap-2"> <div v-else class="flex gap-2">
<router-link v-if="$route.name !== 'login'" :to="{ name: 'login' }" <NuxtLink v-if="$route.name !== 'login'" :to="{ name: 'login' }"
class="text-gray-600 dark:text-white hover:text-gray-800 dark:hover:text-white px-0 sm:px-3 py-2 rounded-md text-sm" class="text-gray-600 dark:text-white hover:text-gray-800 dark:hover:text-white px-0 sm:px-3 py-2 rounded-md text-sm"
active-class="text-gray-800 dark:text-white" active-class="text-gray-800 dark:text-white"
> >
{{ $t('login') }} Login
</router-link> </NuxtLink>
<v-button v-track.nav_create_form_click size="small" :to="{ name: 'forms.create.guest' }" color="outline-blue" :arrow="true"> <v-button v-track.nav_create_form_click size="small" :to="{ name: 'forms-create-guest' }"
color="outline-blue" :arrow="true">
Create a form Create a form
</v-button> </v-button>
</div> </div>
@ -132,9 +137,10 @@
</template> </template>
<script> <script>
import { mapGetters, mapState } from 'vuex' import {computed} from 'vue'
import Dropdown from './common/Dropdown.vue' import Dropdown from '~/components/global/Dropdown.vue'
import WorkspaceDropdown from './WorkspaceDropdown.vue' import WorkspaceDropdown from './WorkspaceDropdown.vue'
import opnformConfig from "~/opnform.config.js";
export default { export default {
components: { components: {
@ -142,29 +148,42 @@ export default {
Dropdown Dropdown
}, },
data: () => ({ async setup() {
appName: window.config.appName const {openCrisp} = useCrisp()
}), const authStore = useAuthStore()
return {
authStore,
openCrisp,
opnformConfig,
appStore: useAppStore(),
formsStore: useFormsStore(),
workspacesStore: useWorkspacesStore(),
config: useRuntimeConfig(),
user: computed(() => authStore.user),
isIframe: useIsIframe(),
}
},
computed: { computed: {
githubUrl: () => window.config.links.github_url, helpUrl() {
helpUrl: () => window.config.links.help_url, return this.opnformConfig.links.help_url
form () { },
form() {
if (this.$route.name && this.$route.name.startsWith('forms.show_public')) { if (this.$route.name && this.$route.name.startsWith('forms.show_public')) {
return this.$store.getters['open/forms/getBySlug'](this.$route.params.slug) return this.formsStore.getByKey(this.$route.params.slug)
} }
return null return null
}, },
workspace () { workspace() {
return this.$store.getters['open/workspaces/getCurrent']() return this.workspacesStore.getCurrent
}, },
paidPlansEnabled () { paidPlansEnabled() {
return window.config.paid_plans_enabled return this.config.public.paidPlansEnabled
}, },
showAuth () { showAuth() {
return this.$route.name && !this.$route.name.startsWith('forms.show_public') return this.$route.name && !this.$route.name.startsWith('forms.show_public')
}, },
hasNavbar () { hasNavbar() {
if (this.isIframe) return false if (this.isIframe) return false
if (this.$route.name && this.$route.name.startsWith('forms.show_public')) { if (this.$route.name && this.$route.name.startsWith('forms.show_public')) {
@ -177,41 +196,29 @@ export default {
return false return false
} }
} }
return !this.$root.navbarHidden return !this.appStore.navbarHidden
}, },
isIframe () { userOnboarded() {
return window.location !== window.parent.location || window.frameElement return this.user && this.user.has_forms === true
}, },
...mapGetters({ hasCrisp() {
user: 'auth/user' return this.config.crispWebsiteId
}),
...mapState({
workspacesLoading: state => state['open/workspaces'].loading
}),
userOnboarded () {
return this.user && (this.workspacesLoading || this.workspace)
},
hasCrisp () {
return window.config.crisp_website_id
} }
}, },
methods: { methods: {
async logout () { async logout() {
// Log out the user. // Log out the user.
await this.$store.dispatch('auth/logout') await this.authStore.logout()
// Reset store // Reset store
this.$store.dispatch('open/workspaces/resetState') this.workspacesStore.resetState()
this.$store.dispatch('open/forms/resetState') this.formsStore.resetState()
// Redirect to login. // Redirect to login.
this.$router.push({ name: 'login' }) const router = useRouter()
router.push({name: 'login'})
}, },
openCrisp () {
window.$crisp.push(['do', 'chat:show'])
window.$crisp.push(['do', 'chat:open'])
}
} }
} }
</script> </script>

View File

@ -0,0 +1,105 @@
<template>
<div class="fixed top-0 bottom-24 right-0 flex gap-y-4 items-start justify-end z-50 pointer-events-auto">
<NuxtNotifications>
<template #body="props">
<div class="p-2">
<div
class="flex max-w-sm w-full mx-auto bg-white shadow-md rounded-lg overflow-hidden relative"
>
<div class="flex justify-center items-center w-12" :class="notifTypes[props.item.type].background"
v-html="notifTypes[props.item.type].svg"/>
<div class="-mx-3 py-2 px-4">
<div class="mx-3">
<span :class="notifTypes[props.item.type].text" class="font-semibold pr-6">{{ props.item.title }}</span>
<p class="text-gray-600 text-sm">{{ props.item.text }}</p>
<div class="w-full flex gap-2 mt-1" v-if="props.item.type == 'confirm'">
<v-button color="blue" size="small" @click.prevent="props.item.data.success();props.close()">Yes
</v-button>
<v-button color="white" size="small"
@click.prevent="props.item.data.failure();props.close()">No
</v-button>
</div>
</div>
</div>
<button @click="props.close()" class="absolute top-0 right-0 px-2 py-2 cursor-pointer">
<svg
class="fill-current h-6 w-6 text-gray-300 hover:text-gray-500"
role="button"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
>
<title>Close</title>
<path
d="M14.348 14.849a1.2 1.2 0 0 1-1.697 0L10 11.819l-2.651 3.029a1.2 1.2 0 1 1-1.697-1.697l2.758-3.15-2.759-3.152a1.2 1.2 0 1 1 1.697-1.697L10 8.183l2.651-3.031a1.2 1.2 0 1 1 1.697 1.697l-2.758 3.152 2.758 3.15a1.2 1.2 0 0 1 0 1.698z"
/>
</svg>
</button>
</div>
</div>
</template>
</NuxtNotifications>
</div>
</template>
<script>
export default {
name: 'Notifications',
data() {
return {
notifTypes: {
success: {
background: 'bg-green-500',
text: 'text-green-500',
svg: '<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 fill-current text-white" viewBox="0 0 20 20" fill="currentColor">' +
' <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />' +
' </svg>'
},
warning: {
background: 'bg-yellow-500',
text: 'text-yellow-500',
svg: '<svg' +
' class="h-6 w-6 fill-current text-white"' +
' viewBox="0 0 40 40"' +
' xmlns="http://www.w3.org/2000/svg"' +
' >' +
' <path' +
' d="M20 3.33331C10.8 3.33331 3.33337 10.8 3.33337 20C3.33337 29.2 10.8 36.6666 20 36.6666C29.2 36.6666 36.6667 29.2 36.6667 20C36.6667 10.8 29.2 3.33331 20 3.33331ZM21.6667 28.3333H18.3334V25H21.6667V28.3333ZM21.6667 21.6666H18.3334V11.6666H21.6667V21.6666Z"' +
' />' +
' </svg>',
},
error: {
background: 'bg-red-500',
text: 'text-red-500',
svg: '<svg' +
' class="h-6 w-6 fill-current text-white"' +
' viewBox="0 0 40 40"' +
' xmlns="http://www.w3.org/2000/svg"' +
' >' +
' <path' +
' d="M20 3.33331C10.8 3.33331 3.33337 10.8 3.33337 20C3.33337 29.2 10.8 36.6666 20 36.6666C29.2 36.6666 36.6667 29.2 36.6667 20C36.6667 10.8 29.2 3.33331 20 3.33331ZM21.6667 28.3333H18.3334V25H21.6667V28.3333ZM21.6667 21.6666H18.3334V11.6666H21.6667V21.6666Z"' +
' />' +
' </svg>'
},
confirm: {
background: 'bg-blue-500',
text: 'text-blue-500',
svg: '<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 fill-current text-white" viewBox="0 0 20 20" fill="currentColor">' +
' <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />' +
' </svg>'
},
info: {
background: 'bg-blue-500',
text: 'text-blue-500',
svg: '<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 fill-current text-white" viewBox="0 0 20 20" fill="currentColor">' +
' <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />' +
' </svg>'
}
}
}
},
}
</script>

View File

@ -0,0 +1,32 @@
<template>
<notion-renderer v-if="!loading" :block-map="blockMap"/>
<div class="p-6 flex items-center justify-center" v-else>
<loader class="w-6 h-6"/>
</div>
</template>
<script>
import {NotionRenderer} from 'vue-notion'
export default {
name: 'NotionPage',
components: {NotionRenderer},
props: {
blockMap: {
type: Object
},
loading: {
type: Boolean,
required: true
}
},
}
</script>
<style lang="scss">
@import "vue-notion/src/styles.css";
.notion-blue {
@apply text-nt-blue;
}
</style>

View File

@ -37,8 +37,10 @@
</template> </template>
<script> <script>
import Modal from '../Modal.vue' import { computed } from 'vue'
import {mapGetters} from 'vuex' import Modal from './Modal.vue'
import { useAuthStore } from '../../stores/auth';
import { useWorkspacesStore } from '../../stores/workspaces';
import PricingTable from "../pages/pricing/PricingTable.vue"; import PricingTable from "../pages/pricing/PricingTable.vue";
export default { export default {
@ -46,6 +48,15 @@ export default {
components: {PricingTable, Modal}, components: {PricingTable, Modal},
props: {}, props: {},
setup () {
const authStore = useAuthStore()
const workspacesStore = useWorkspacesStore()
return {
user : computed(() => authStore.user),
currentWorkSpace : computed(() => workspacesStore.getCurrent())
}
},
data() { data() {
return { return {
showPremiumModal: false, showPremiumModal: false,
@ -54,12 +65,8 @@ export default {
}, },
computed: { computed: {
...mapGetters({
user: 'auth/user',
currentWorkSpace: 'open/workspaces/getCurrent',
}),
shouldDisplayProTag() { shouldDisplayProTag() {
if (!window.config.paid_plans_enabled) return false if (!this.$config.paid_plans_enabled) return false
if (!this.user || !this.currentWorkSpace) return true if (!this.user || !this.currentWorkSpace) return true
return !(this.currentWorkSpace.is_pro) return !(this.currentWorkSpace.is_pro)
}, },

View File

@ -50,7 +50,9 @@ export default {
bottom: false, bottom: false,
left: false left: false
}, },
debounceTimeout: null debounceTimeout: null,
scrollContainerObserver: null,
wrapObserver: null
} }
}, },
mounted () { mounted () {
@ -60,20 +62,21 @@ export default {
const scrollContainerObserver = newResizeObserver(this.toggleShadow) const scrollContainerObserver = newResizeObserver(this.toggleShadow)
if (scrollContainerObserver) { if (scrollContainerObserver) {
scrollContainerObserver.observe(this.$refs.scrollContainer) scrollContainerObserver.observe(this.$refs.scrollContainer)
// Cleanup when the component is destroyed.
this.$once('hook:destroyed', () => scrollContainerObserver.disconnect())
} }
// Recalculate the container dimensions when the wrapper is resized. // Recalculate the container dimensions when the wrapper is resized.
const wrapObserver = newResizeObserver(this.calcDimensions) this.wrapObserver = newResizeObserver(this.calcDimensions)
if (wrapObserver) { if (this.wrapObserver) {
wrapObserver.observe(this.$el) this.wrapObserver.observe(this.$el)
// Cleanup when the component is destroyed.
this.$once('hook:destroyed', () => wrapObserver.disconnect())
} }
}, },
destroyed () { unmounted () {
window.removeEventListener('resize', this.calcDimensions) window.removeEventListener('resize', this.calcDimensions)
// Cleanup when the component is unmounted.
this.wrapObserver.disconnect()
if (this.scrollContainerObserver) {
this.scrollContainerObserver.disconnect()
}
}, },
methods: { methods: {
async calcDimensions () { async calcDimensions () {
@ -87,6 +90,7 @@ export default {
}, },
// Check if shadows are needed. // Check if shadows are needed.
toggleShadow () { toggleShadow () {
if (!this.$refs.scrollContainer) return
const hasHorizontalScrollbar = const hasHorizontalScrollbar =
this.$refs.scrollContainer.clientWidth < this.$refs.scrollContainer.clientWidth <
this.$refs.scrollContainer.scrollWidth this.$refs.scrollContainer.scrollWidth

View File

@ -1,10 +1,8 @@
<template> <template>
<a v-if="href" :class="btnClasses" :href="href" :target="target"> <NuxtLink v-if="href" :class="btnClasses" :href="href" :target="target">
<slot /> <slot />
</a> </NuxtLink>
<button v-else-if="!to" :type="nativeType" :disabled="loading" :class="btnClasses" <button v-else-if="!to" :type="nativeType" :disabled="loading?true:null" :class="btnClasses">
@click="onClick($event)"
>
<template v-if="!loading"> <template v-if="!loading">
<span class="no-underline mx-auto"> <span class="no-underline mx-auto">
<slot /> <slot />
@ -15,9 +13,9 @@
/> />
</svg> </svg>
</template> </template>
<loader v-else class="h-6 w-6 mx-auto" :class="`text-${colorShades['text']}`" /> <Loader v-else class="h-6 w-6 mx-auto" :class="`text-${colorShades['text']}`" />
</button> </button>
<router-link v-else :class="btnClasses" :to="to" :target="target"> <NuxtLink v-else :class="btnClasses" :to="to" :target="target">
<span class="no-underline mx-auto"> <span class="no-underline mx-auto">
<slot /> <slot />
</span> </span>
@ -26,7 +24,7 @@
stroke-linejoin="round" stroke-linejoin="round"
/> />
</svg> </svg>
</router-link> </NuxtLink>
</template> </template>
<script> <script>
@ -175,11 +173,5 @@ export default {
} }
} }
}, },
methods: {
onClick (event) {
this.$emit('click', event)
}
}
} }
</script> </script>

View File

@ -4,12 +4,12 @@
dusk="workspace-dropdown" dusk="workspace-dropdown"
> >
<template v-if="workspace" #trigger="{toggle}"> <template v-if="workspace" #trigger="{toggle}">
<div class="flex items-center cursor group" role="button" @click.prevent="toggle()"> <div class="flex items-center cursor group" role="button" @click.stop="toggle()">
<div class="rounded-full h-8 8"> <div class="rounded-full h-8 8">
<img v-if="isUrl(workspace.icon)" <img v-if="isUrl(workspace.icon)"
:src="workspace.icon" :src="workspace.icon"
:alt="workspace.name + ' icon'" class="flex-shrink-0 h-8 w-8 rounded-full shadow" :alt="workspace.name + ' icon'" class="flex-shrink-0 h-8 w-8 rounded-full shadow"
> />
<div v-else class="rounded-full pt-2 text-xs truncate bg-nt-blue-lighter h-8 w-8 text-center shadow" <div v-else class="rounded-full pt-2 text-xs truncate bg-nt-blue-lighter h-8 w-8 text-center shadow"
v-text="workspace.icon" v-text="workspace.icon"
/> />
@ -20,8 +20,8 @@
</div> </div>
</template> </template>
<template v-for="worksp in workspaces"> <template v-for="worksp in workspaces" :key="worksp.id">
<a :key="worksp.id" href="#" <a href="#"
class="px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600 flex items-center" class="px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600 flex items-center"
:class="{'bg-blue-100 dark:bg-blue-900':workspace.id === worksp.id}" @click.prevent="switchWorkspace(worksp)" :class="{'bg-blue-100 dark:bg-blue-900':workspace.id === worksp.id}" @click.prevent="switchWorkspace(worksp)"
> >
@ -29,7 +29,7 @@
<img v-if="isUrl(worksp.icon)" <img v-if="isUrl(worksp.icon)"
:src="worksp.icon" :src="worksp.icon"
:alt="worksp.name + ' icon'" class="flex-shrink-0 h-8 w-8 rounded-full shadow" :alt="worksp.name + ' icon'" class="flex-shrink-0 h-8 w-8 rounded-full shadow"
> />
<div v-else class="rounded-full flex-shrink-0 pt-1 text-xs truncate bg-nt-blue-lighter h-8 w-8 text-center shadow" <div v-else class="rounded-full flex-shrink-0 pt-1 text-xs truncate bg-nt-blue-lighter h-8 w-8 text-center shadow"
v-text="worksp.icon" v-text="worksp.icon"
/> />
@ -41,8 +41,8 @@
</template> </template>
<script> <script>
import Dropdown from './common/Dropdown.vue' import { computed } from 'vue'
import { mapGetters, mapState } from 'vuex' import Dropdown from '~/components/global/Dropdown.vue'
export default { export default {
@ -51,20 +51,22 @@ export default {
Dropdown Dropdown
}, },
data: () => ({ setup () {
appName: window.config.appName const authStore = useAuthStore()
}), const formsStore = useFormsStore()
const workspacesStore = useWorkspacesStore()
return {
formsStore,
workspacesStore,
user: computed(() => authStore.user),
workspaces: computed(() => workspacesStore.getAll),
loading: computed(() => workspacesStore.loading)
}
},
computed: { computed: {
...mapState({
workspaces: state => state['open/workspaces'].content,
loading: state => state['open/workspaces'].loading
}),
...mapGetters({
user: 'auth/user'
}),
workspace () { workspace () {
return this.$store.getters['open/workspaces/getCurrent']() return this.workspacesStore.getCurrent
} }
}, },
@ -76,12 +78,15 @@ export default {
methods: { methods: {
switchWorkspace (workspace) { switchWorkspace (workspace) {
this.$store.commit('open/workspaces/setCurrentId', workspace.id) this.workspacesStore.setCurrentId(workspace.id)
this.$refs.dropdown.close() this.formsStore.resetState()
if (this.$route.name !== 'home') { this.formsStore.loadAll(workspace.id)
this.$router.push({ name: 'home' }) const router = useRouter()
const route = useRoute()
if (route.name !== 'home') {
router.push({ name: 'home' })
} }
this.$store.dispatch('open/forms/load', workspace.id) this.formsStore.loadAll(workspace.id)
}, },
isUrl (str) { isUrl (str) {
try { try {

View File

@ -0,0 +1,52 @@
<template>
<transition @leave="onLeave">
<div
ref="collapsible"
v-if="modelValue"
v-on-click-outside.bubble="onClickAway"
>
<slot/>
</div>
</transition>
</template>
<script setup>
import {vOnClickOutside} from '@vueuse/components'
const props = defineProps({
modelValue: {type: Boolean},
maxHeight: {type: Number, default: 200},
})
const emits = defineEmits(['click-away'])
const motion = ref(null)
const collapsible = ref(null)
const variants = {
initial: {
opacity: 0,
y: -10,
transition: {duration: 75, ease: 'easeIn'}
},
enter: {
opacity: 1,
y: 0,
transition: {duration: 150, ease: 'easeOut'}
}
}
watch(() => props.modelValue, (newValue) => {
if (newValue) {
nextTick(() => {
motion.value = useMotion(collapsible.value, variants)
})
}
})
const onLeave = (el, done) => {
motion.value.leave(done)
}
const onClickAway = (event) => {
emits('click-away', event)
}
</script>

View File

@ -1,10 +1,10 @@
<template> <template>
<transition v-if="name=='slideInUp'" <transition v-if="name=='slideInUp'"
enter-active-class="linear duration-300 overflow-hidden" enter-active-class="linear duration-300 overflow-hidden"
enter-class="max-h-0" enter-from-class="max-h-0"
enter-to-class="max-h-screen" enter-to-class="max-h-screen"
leave-active-class="linear duration-300 overflow-hidden" leave-active-class="linear duration-300 overflow-hidden"
leave-class="max-h-screen" leave-from-class="max-h-screen"
leave-to-class="max-h-0" leave-to-class="max-h-0"
> >
<slot /> <slot />

View File

@ -16,9 +16,6 @@
</template> </template>
<script> <script>
import { mapState } from 'vuex'
import store from '~/store'
import axios from 'axios'
export default { export default {
components: { }, components: { },
@ -36,6 +33,11 @@ export default {
default: () => {} default: () => {}
} }
}, },
setup () {
return {
useAlert: useAlert()
}
},
data () { data () {
return { return {
} }
@ -46,18 +48,18 @@ export default {
}, },
methods: { methods: {
onDeleteClick () { onDeleteClick () {
this.alertConfirm('Do you really want to delete this record?', this.deleteRecord) this.useAlert.confirm('Do you really want to delete this record?', this.deleteRecord)
}, },
async deleteRecord () { async deleteRecord () {
axios.delete('/api/open/forms/' + this.form.id + '/records/' + this.rowid + '/delete').then(async (response) => { opnFetch('/open/forms/' + this.form.id + '/records/' + this.rowid + '/delete', {method:'DELETE'}).then(async (data) => {
if (response.data.type === 'success') { if (data.type === 'success') {
this.$emit('deleted') this.$emit('deleted')
this.alertSuccess(response.data.message) this.useAlert.success(data.message)
} else { } else {
this.alertError('Something went wrong!') this.useAlert.error('Something went wrong!')
} }
}).catch((error) => { }).catch((error) => {
this.alertError(error.response.data.message) this.useAlert.error(error.response.data.message)
}) })
} }
} }

View File

@ -17,8 +17,8 @@
</template> </template>
<script> <script>
import Collapse from '../../common/Collapse.vue' import Collapse from '~/components/global/Collapse.vue'
import ProTag from '../../common/ProTag.vue' import ProTag from '~/components/global/ProTag.vue'
export default { export default {
name: 'EditorOptionsPanel', name: 'EditorOptionsPanel',

View File

@ -0,0 +1,32 @@
<template>
<transition @leave="(el,done) => sidebarMotion.leave(done)">
<div v-if="show" ref="sidebar"
class="absolute shadow-lg shadow-gray-800/30 top-0 h-[calc(100vh-53px)] right-0 lg:shadow-none lg:relative bg-white w-full md:w-1/2 lg:w-2/5 border-l overflow-y-scroll md:max-w-[20rem] flex-shrink-0 z-50"
>
<slot />
</div>
</transition>
</template>
<script setup>
import {slideRight, useMotion} from "@vueuse/motion"
import {watch} from "vue";
const props = defineProps({
show: {
type: Boolean,
default: false
}
})
const sidebar = ref(null)
const sidebarMotion = ref(null)
watch(() => props.show, (newVal) => {
if (newVal) {
nextTick(() => {
sidebarMotion.value = useMotion(sidebar.value, slideRight)
})
}
})
</script>

View File

@ -54,29 +54,27 @@
<transition <transition
v-if="!form.is_password_protected && (!isPublicFormPage || (!form.is_closed && !form.max_number_of_submissions_reached && form.visibility!='closed'))" v-if="!form.is_password_protected && (!isPublicFormPage || (!form.is_closed && !form.max_number_of_submissions_reached && form.visibility!='closed'))"
enter-active-class="duration-500 ease-out" enter-active-class="duration-500 ease-out"
enter-class="translate-x-full opacity-0" enter-from-class="translate-x-full opacity-0"
enter-to-class="translate-x-0 opacity-100" enter-to-class="translate-x-0 opacity-100"
leave-active-class="duration-500 ease-in" leave-active-class="duration-500 ease-in"
leave-class="translate-x-0 opacity-100" leave-from-class="translate-x-0 opacity-100"
leave-to-class="translate-x-full opacity-0" leave-to-class="translate-x-full opacity-0"
mode="out-in" mode="out-in"
> >
<div v-if="!submitted" key="form"> <div v-if="!submitted" key="form">
<p v-if="form.description && form.description !==''" <div v-if="form.description" v-html="form.description"
class="form-description mb-4 text-gray-700 dark:text-gray-300 whitespace-pre-wrap px-2" class="form-description mb-4 text-gray-700 dark:text-gray-300 whitespace-pre-wrap px-2"/>
v-html="form.description"
/>
<open-form v-if="form" <open-form v-if="form"
:form="form" :form="form"
:loading="loading" :loading="loading"
:fields="form.properties" :fields="form.properties"
:theme="theme" :theme="theme"
:admin-preview="adminPreview" :admin-preview="adminPreview"
@submit="submitForm" @submit="submitForm"
> >
<template #submit-btn="{submitForm}"> <template #submit-btn="{submitForm}">
<open-form-button :loading="loading" :theme="theme" :color="form.color" class="mt-2 px-8 mx-1" <open-form-button :loading="loading" :theme="theme" :color="form.color" class="mt-2 px-8 mx-1"
:class="submitButtonClass" @click.prevent="submitForm" :class="submitButtonClass" @click.prevent="submitForm"
> >
{{ form.submit_button_text }} {{ form.submit_button_text }}
</open-form-button> </open-form-button>
@ -86,7 +84,9 @@
<a href="https://opnform.com?utm_source=form&utm_content=powered_by" <a href="https://opnform.com?utm_source=form&utm_content=powered_by"
class="text-gray-400 hover:text-gray-500 dark:text-gray-600 dark:hover:text-gray-500 cursor-pointer hover:underline text-xs" class="text-gray-400 hover:text-gray-500 dark:text-gray-600 dark:hover:text-gray-500 cursor-pointer hover:underline text-xs"
target="_blank" target="_blank"
>Powered by <span class="font-semibold">OpnForm</span></a> >
Powered by <span class="font-semibold">OpnForm</span>
</a>
</p> </p>
</div> </div>
<div v-else key="submitted" class="px-2"> <div v-else key="submitted" class="px-2">
@ -100,7 +100,9 @@
</a> </a>
</p> </p>
<p v-if="!form.no_branding" class="mt-5"> <p v-if="!form.no_branding" class="mt-5">
<a target="_parent" href="https://opnform.com/?utm_source=form&utm_content=create_form_free" class="text-nt-blue hover:underline">Create your form for free with OpnForm</a> <a target="_parent" href="https://opnform.com/?utm_source=form&utm_content=create_form_free" class="text-nt-blue hover:underline">
Create your form for free with OpnForm
</a>
</p> </p>
</div> </div>
</transition> </transition>
@ -108,14 +110,14 @@
</template> </template>
<script> <script>
import Form from 'vform'
import OpenForm from './OpenForm.vue' import OpenForm from './OpenForm.vue'
import OpenFormButton from './OpenFormButton.vue' import OpenFormButton from './OpenFormButton.vue'
import { themes } from '~/config/form-themes.js' import { themes } from '~/lib/forms/form-themes.js'
import VButton from '../../common/Button.vue' import VButton from '~/components/global/VButton.vue'
import VTransition from '../../common/transitions/VTransition.vue'
import FormPendingSubmissionKey from '../../../mixins/forms/form-pending-submission-key.js'
import FormCleanings from '../../pages/forms/show/FormCleanings.vue' import FormCleanings from '../../pages/forms/show/FormCleanings.vue'
import VTransition from '~/components/global/transitions/VTransition.vue'
import {pendingSubmission} from "~/composables/forms/pendingSubmission.js";
import clonedeep from "clone-deep";
export default { export default {
components: { VTransition, VButton, OpenFormButton, OpenForm, FormCleanings }, components: { VTransition, VButton, OpenFormButton, OpenForm, FormCleanings },
@ -127,14 +129,20 @@ export default {
submitButtonClass: { type: String, default: '' } submitButtonClass: { type: String, default: '' }
}, },
mixins: [FormPendingSubmissionKey], setup(props) {
return {
isIframe: useIsIframe(),
pendingSubmission: pendingSubmission(props.form),
confetti: useConfetti()
}
},
data () { data () {
return { return {
loading: false, loading: false,
submitted: false, submitted: false,
themes: themes, themes: themes,
passwordForm: new Form({ passwordForm: useForm({
password: null password: null
}), }),
hidePasswordDisabledMsg: false, hidePasswordDisabledMsg: false,
@ -143,11 +151,8 @@ export default {
}, },
computed: { computed: {
isIframe () {
return window.location !== window.parent.location || window.frameElement
},
isEmbedPopup () { isEmbedPopup () {
return window.location.href.includes('popup=true') return process.client && window.location.href.includes('popup=true')
}, },
theme () { theme () {
return this.themes[this.themes.hasOwnProperty(this.form.theme) ? this.form.theme : 'default'] return this.themes[this.themes.hasOwnProperty(this.form.theme) ? this.form.theme : 'default']
@ -156,13 +161,10 @@ export default {
return this.$route.name === 'forms.show_public' return this.$route.name === 'forms.show_public'
}, },
isHideTitle () { isHideTitle () {
return this.form.hide_title || window.location.href.includes('hide_title=true') return this.form.hide_title || (process.client && window.location.href.includes('hide_title=true'))
} }
}, },
mounted () {
},
methods: { methods: {
submitForm (form, onFailure) { submitForm (form, onFailure) {
if (this.creating) { if (this.creating) {
@ -171,44 +173,39 @@ export default {
return return
} }
if (form.busy) return
this.loading = true this.loading = true
this.closeAlert() // this.closeAlert()
form.post('/api/forms/' + this.form.slug + '/answer').then((response) => { form.post('/forms/' + this.form.slug + '/answer').then((data) => {
this.$logEvent('form_submission', { useAmplitude().logEvent('form_submission', {
workspace_id: this.form.workspace_id, workspace_id: this.form.workspace_id,
form_id: this.form.id form_id: this.form.id
}) })
if (this.isIframe) { const payload = clonedeep({
window.parent.postMessage({
type: 'form-submitted',
form: {
slug: this.form.slug,
id: this.form.id
},
submission_data: form.data()
}, '*')
}
window.postMessage({
type: 'form-submitted', type: 'form-submitted',
form: { form: {
slug: this.form.slug, slug: this.form.slug,
id: this.form.id id: this.form.id
}, },
submission_data: form.data() submission_data: form.data()
}, '*') })
if (this.isIframe) {
window.parent.postMessage(payload, '*')
}
window.postMessage(payload, '*')
try { try {
window.localStorage.removeItem(this.formPendingSubmissionKey) this.pendingSubmission.remove()
} catch (e) {} } catch (e) {}
if (response.data.redirect && response.data.redirect_url) { if (data.redirect && data.redirect_url) {
window.location.href = response.data.redirect_url window.location.href = data.redirect_url
} }
if (response.data.submission_id) { if (data.submission_id) {
this.submissionId = response.data.submission_id this.submissionId = data.submission_id
} }
this.loading = false this.loading = false
@ -216,13 +213,13 @@ export default {
this.$emit('submitted', true) this.$emit('submitted', true)
// If enabled display confetti // If enabled display confetti
if(this.form.confetti_on_submission){ if (this.form.confetti_on_submission) {
this.playConfetti() this.confetti.play()
} }
}).catch((error) => { }).catch((error) => {
if (error.response.data && error.response.data.message) { console.error(error)
this.alertError(error.response.data.message) if (error.response && error.data && error.data.message) {
useAlert().error(error.data.message)
} }
this.loading = false this.loading = false
onFailure() onFailure()

View File

@ -1,43 +1,40 @@
<template> <template>
<div v-if="isAutoSubmit"> <div v-if="isAutoSubmit">
<p class="text-center p-4"> <p class="text-center p-4">
<loader class="h-6 w-6 text-nt-blue mx-auto" /> <Loader class="h-6 w-6 text-nt-blue mx-auto" />
</p> </p>
</div> </div>
<form v-else-if="dataForm" @submit.prevent=""> <form v-else-if="dataForm" @submit.prevent="">
<transition name="fade" mode="out-in" appear> <transition name="fade" mode="out-in">
<template v-for="group, groupIndex in fieldGroups"> <div :key="currentFieldGroupIndex" class="form-group flex flex-wrap w-full">
<div v-if="currentFieldGroupIndex===groupIndex" <draggable v-model="currentFields"
:key="groupIndex" item-key="id"
class="form-group flex flex-wrap w-full"> class="flex flex-wrap transition-all"
:class="{'-m-6 p-2 bg-gray-50 rounded-md':dragging}"
<draggable v-model="currentFields" ghost-class="ghost-item"
class="flex flex-wrap transition-all" handle=".draggable" :animation="200"
:class="{'-m-6 p-2 bg-gray-50 rounded-md':dragging}" @start="onDragStart" @end="onDragEnd"
ghost-class="ghost-item" >
handle=".draggable" :animation="200" <template #item="{element}">
@start="onDragStart" @end="onDragEnd" <open-form-field
> :field="element"
<open-form-field v-for="field in group" :show-hidden="showHidden"
:key="field.id + formVersionId" :form="form"
:field="field" :data-form="dataForm"
:show-hidden="showHidden" :data-form-value="dataFormValue"
:form="form" :theme="theme"
:data-form="dataForm" :admin-preview="adminPreview"
:data-form-value="dataFormValue"
:theme="theme"
:admin-preview="adminPreview"
/> />
</draggable> </template>
</div> </draggable>
</template> </div>
</transition> </transition>
<!-- Captcha --> <!-- Captcha -->
<template v-if="form.use_captcha && isLastPage"> <template v-if="form.use_captcha && isLastPage">
<div class="mb-3 px-2 mt-2 mx-auto w-max"> <div class="mb-3 px-2 mt-2 mx-auto w-max">
<vue-hcaptcha ref="hcaptcha" :sitekey="hCaptchaSiteKey" :theme="darkModeEnabled?'dark':'light'"/> <vue-hcaptcha ref="hcaptcha" :sitekey="hCaptchaSiteKey" :theme="darkModeEnabled?'dark':'light'" />
<has-error :form="dataForm" field="h-captcha-response"/> <has-error :form="dataForm" field="h-captcha-response" />
</div> </div>
</template> </template>
@ -49,7 +46,7 @@
{{ previousFieldsPageBreak.previous_btn_text }} {{ previousFieldsPageBreak.previous_btn_text }}
</open-form-button> </open-form-button>
<slot v-if="isLastPage" name="submit-btn" :submitForm="submitForm"/> <slot v-if="isLastPage" name="submit-btn" :submitForm="submitForm" />
<open-form-button v-else native-type="button" :color="form.color" :theme="theme" class="mt-2 px-8 mx-1" <open-form-button v-else native-type="button" :color="form.color" :theme="theme" class="mt-2 px-8 mx-1"
@click="nextPage" @click="nextPage"
> >
@ -63,20 +60,17 @@
</template> </template>
<script> <script>
import axios from 'axios'
import Form from 'vform'
import OpenFormButton from './OpenFormButton.vue'
import clonedeep from 'clone-deep' import clonedeep from 'clone-deep'
import FormLogicPropertyResolver from '../../../forms/FormLogicPropertyResolver.js'
import OpenFormField from './OpenFormField.vue'
import draggable from 'vuedraggable' import draggable from 'vuedraggable'
const VueHcaptcha = () => import('@hcaptcha/vue-hcaptcha') import OpenFormButton from './OpenFormButton.vue'
import FormPendingSubmissionKey from '../../../mixins/forms/form-pending-submission-key.js' import VueHcaptcha from "@hcaptcha/vue3-hcaptcha"
import OpenFormField from './OpenFormField.vue'
import {pendingSubmission} from "~/composables/forms/pendingSubmission.js";
import FormLogicPropertyResolver from "~/lib/forms/FormLogicPropertyResolver.js";
export default { export default {
name: 'OpenForm', name: 'OpenForm',
components: {draggable, OpenFormField, OpenFormButton, VueHcaptcha}, components: { draggable, OpenFormField, OpenFormButton, VueHcaptcha },
mixins: [FormPendingSubmissionKey],
props: { props: {
form: { form: {
type: Object, type: Object,
@ -98,17 +92,29 @@ export default {
type: Array, type: Array,
required: true required: true
}, },
adminPreview: { type: Boolean, default: false }, // If used in FormEditorPreview adminPreview: { type: Boolean, default: false } // If used in FormEditorPreview
}, },
data() {
setup (props) {
const recordsStore = useRecordsStore()
const workingFormStore = useWorkingFormStore()
const dataForm = ref(useForm())
return {
dataForm,
recordsStore,
workingFormStore,
darkModeEnabled: useDark(),
pendingSubmission: pendingSubmission(props.form)
}
},
data () {
return { return {
dataForm: null,
currentFieldGroupIndex: 0, currentFieldGroupIndex: 0,
/** /**
* Used to force refresh components by changing their keys * Used to force refresh components by changing their keys
*/ */
formVersionId: 1,
darkModeEnabled: document.body.classList.contains('dark'),
isAutoSubmit: false, isAutoSubmit: false,
/** /**
* If currently dragging a field * If currently dragging a field
@ -118,11 +124,13 @@ export default {
}, },
computed: { computed: {
hCaptchaSiteKey: () => window.config.hCaptchaSiteKey, hCaptchaSiteKey () {
return useRuntimeConfig().public.hCaptchaSiteKey
},
/** /**
* Create field groups (or Page) using page breaks if any * Create field groups (or Page) using page breaks if any
*/ */
fieldGroups() { fieldGroups () {
if (!this.fields) return [] if (!this.fields) return []
const groups = [] const groups = []
let currentGroup = [] let currentGroup = []
@ -154,19 +162,19 @@ export default {
newFields.push(...group) newFields.push(...group)
} }
}) })
// set the properties on working_form vuex // set the properties on working_form store
this.$store.commit('open/working_form/setProperties', newFields) this.workingFormStore.setProperties(newFields)
} }
}, },
/** /**
* Returns the page break block for the current group of fields * Returns the page break block for the current group of fields
*/ */
currentFieldsPageBreak() { currentFieldsPageBreak () {
const block = this.currentFields[this.currentFields.length - 1] const block = this.currentFields[this.currentFields.length - 1]
if (block && block.type === 'nf-page-break') return block if (block && block.type === 'nf-page-break') return block
return null return null
}, },
previousFieldsPageBreak() { previousFieldsPageBreak () {
if (this.currentFieldGroupIndex === 0) return null if (this.currentFieldGroupIndex === 0) return null
const previousFields = this.fieldGroups[this.currentFieldGroupIndex - 1] const previousFields = this.fieldGroups[this.currentFieldGroupIndex - 1]
const block = previousFields[previousFields.length - 1] const block = previousFields[previousFields.length - 1]
@ -177,13 +185,13 @@ export default {
* Returns true if we're on the last page * Returns true if we're on the last page
* @returns {boolean}xs * @returns {boolean}xs
*/ */
isLastPage() { isLastPage () {
return this.currentFieldGroupIndex === (this.fieldGroups.length - 1) return this.currentFieldGroupIndex === (this.fieldGroups.length - 1)
}, },
isPublicFormPage() { isPublicFormPage () {
return this.$route.name === 'forms.show_public' return this.$route.name === 'forms-slug'
}, },
dataFormValue() { dataFormValue () {
// For get values instead of Id for select/multi select options // For get values instead of Id for select/multi select options
const data = this.dataForm.data() const data = this.dataForm.data()
const selectionFields = this.fields.filter((field) => { const selectionFields = this.fields.filter((field) => {
@ -206,50 +214,41 @@ export default {
watch: { watch: {
form: { form: {
deep: true, deep: true,
handler() { handler () {
this.initForm() this.initForm()
} }
}, },
fields: { fields: {
deep: true, deep: true,
handler() { handler () {
this.initForm() this.initForm()
} }
}, },
theme: { dataFormValue: {
handler() {
this.formVersionId++
}
},
dataForm: {
deep: true, deep: true,
handler() { handler () {
if (this.isPublicFormPage && this.form && this.form.auto_save && this.dataFormValue) { if (this.isPublicFormPage && this.form && this.form.auto_save) {
try { this.pendingSubmission.set(this.dataFormValue)
window.localStorage.setItem(this.formPendingSubmissionKey, JSON.stringify(this.dataFormValue))
} catch (e) {
}
} }
} }
}, }
}, },
mounted() { mounted () {
this.initForm() this.initForm()
if (window.client && window.location.href.includes('auto_submit=true')) {
if (window.location.href.includes('auto_submit=true')) {
this.isAutoSubmit = true this.isAutoSubmit = true
this.submitForm() this.submitForm()
} }
}, },
methods: { methods: {
submitForm() { submitForm () {
if (this.currentFieldGroupIndex !== this.fieldGroups.length - 1) { if (this.currentFieldGroupIndex !== this.fieldGroups.length - 1) {
return return
} }
if (this.form.use_captcha) { if (this.form.use_captcha && process.client) {
this.dataForm['h-captcha-response'] = document.getElementsByName('h-captcha-response')[0].value this.dataForm['h-captcha-response'] = document.getElementsByName('h-captcha-response')[0].value
this.$refs.hcaptcha.reset() this.$refs.hcaptcha.reset()
} }
@ -263,7 +262,7 @@ export default {
/** /**
* If more than one page, show first page with error * If more than one page, show first page with error
*/ */
onSubmissionFailure() { onSubmissionFailure () {
this.isAutoSubmit = false this.isAutoSubmit = false
if (this.fieldGroups.length > 1) { if (this.fieldGroups.length > 1) {
// Find first mistake and show page // Find first mistake and show page
@ -281,6 +280,7 @@ export default {
} }
// Scroll to error // Scroll to error
if (process.server) return
const elements = document.getElementsByClassName('has-error') const elements = document.getElementsByClassName('has-error')
if (elements.length > 0) { if (elements.length > 0) {
window.scroll({ window.scroll({
@ -289,50 +289,46 @@ export default {
}) })
} }
}, },
async getSubmissionData() { async getSubmissionData () {
if (!this.form || !this.form.editable_submissions || !this.form.submission_id) { return null } if (!this.form || !this.form.editable_submissions || !this.form.submission_id) {
await this.$store.dispatch('open/records/loadRecord', return null
axios.get('/api/forms/' + this.form.slug + '/submissions/' + this.form.submission_id).then((response) => { }
return { submission_id: this.form.submission_id, ...response.data.data } await this.recordsStore.loadRecord(
opnFetch('/forms/' + this.form.slug + '/submissions/' + this.form.submission_id).then((data) => {
return { submission_id: this.form.submission_id, ...data.data }
}) })
) )
return this.$store.getters['open/records/getById'](this.form.submission_id) return this.recordsStore.getById(this.form.submission_id)
}, },
async initForm() { async initForm () {
if (this.isPublicFormPage && this.form.editable_submissions) { if (this.isPublicFormPage && this.form.editable_submissions) {
const urlParam = new URLSearchParams(window.location.search) const urlParam = new URLSearchParams(window.location.search)
if (urlParam && urlParam.get('submission_id')) { if (urlParam && urlParam.get('submission_id')) {
this.form.submission_id = urlParam.get('submission_id') this.form.submission_id = urlParam.get('submission_id')
const data = await this.getSubmissionData() const data = await this.getSubmissionData()
if (data !== null && data) { if (data !== null && data) {
this.dataForm = new Form(data) this.dataForm = useForm(data)
return return
} }
} }
} }
if (this.isPublicFormPage && this.form.auto_save) { if (this.isPublicFormPage && this.form.auto_save) {
let pendingData let pendingData = this.pendingSubmission.get()
try {
pendingData = window.localStorage.getItem(this.formPendingSubmissionKey)
} catch (e) {
pendingData = null
}
if (pendingData !== null && pendingData) { if (pendingData !== null && pendingData) {
pendingData = JSON.parse(pendingData)
this.fields.forEach((field) => { this.fields.forEach((field) => {
if (field.type === 'date' && field.prefill_today === true) { // For Prefill with 'today' if (field.type === 'date' && field.prefill_today === true) { // For Prefill with 'today'
const dateObj = new Date() const dateObj = new Date()
let currentDate = dateObj.getFullYear() + '-' + let currentDate = dateObj.getFullYear() + '-' +
String(dateObj.getMonth() + 1).padStart(2, '0') + '-' + String(dateObj.getMonth() + 1).padStart(2, '0') + '-' +
String(dateObj.getDate()).padStart(2, '0') String(dateObj.getDate()).padStart(2, '0')
if(field.with_time === true){ if (field.with_time === true) {
currentDate += 'T' + String(dateObj.getHours()).padStart(2, '0') + ':' + currentDate += 'T' + String(dateObj.getHours()).padStart(2, '0') + ':' +
String(dateObj.getMinutes()).padStart(2, '0'); String(dateObj.getMinutes()).padStart(2, '0')
} }
pendingData[field.id] = currentDate pendingData[field.id] = currentDate
} }
}) })
this.dataForm = new Form(pendingData) this.dataForm = useForm(pendingData)
return return
} }
} }
@ -367,24 +363,23 @@ export default {
let currentDate = dateObj.getFullYear() + '-' + let currentDate = dateObj.getFullYear() + '-' +
String(dateObj.getMonth() + 1).padStart(2, '0') + '-' + String(dateObj.getMonth() + 1).padStart(2, '0') + '-' +
String(dateObj.getDate()).padStart(2, '0') String(dateObj.getDate()).padStart(2, '0')
if(field.with_time === true){ if (field.with_time === true) {
currentDate += 'T' + String(dateObj.getHours()).padStart(2, '0') + ':' + currentDate += 'T' + String(dateObj.getHours()).padStart(2, '0') + ':' +
String(dateObj.getMinutes()).padStart(2, '0') String(dateObj.getMinutes()).padStart(2, '0')
} }
formData[field.id] = currentDate formData[field.id] = currentDate
} else { // Default prefill if any } else { // Default prefill if any
formData[field.id] = field.prefill formData[field.id] = field.prefill
} }
}) })
this.dataForm = new Form(formData) this.dataForm = useForm(formData)
}, },
previousPage() { previousPage () {
this.currentFieldGroupIndex -= 1 this.currentFieldGroupIndex -= 1
window.scrollTo({ top: 0, behavior: 'smooth' }) window.scrollTo({ top: 0, behavior: 'smooth' })
return false return false
}, },
nextPage() { nextPage () {
this.currentFieldGroupIndex += 1 this.currentFieldGroupIndex += 1
window.scrollTo({ top: 0, behavior: 'smooth' }) window.scrollTo({ top: 0, behavior: 'smooth' })
return false return false

View File

@ -1,16 +1,16 @@
<template> <template>
<button :type="nativeType" :disabled="loading" :class="`py-${sizes['p-y']} px-${sizes['p-x']} text-${sizes['font']} ${theme.Button.body}`" :style="buttonStyle" <button :type="nativeType" :disabled="loading?true:null" :class="`py-${sizes['p-y']} px-${sizes['p-x']} text-${sizes['font']} ${theme.Button.body}`" :style="buttonStyle"
class="btn" @click="$emit('click',$event)" class="btn" @click="$emit('click',$event)"
> >
<template v-if="!loading"> <template v-if="!loading">
<slot /> <slot />
</template> </template>
<loader v-else class="h-6 w-6 text-white mx-auto" /> <Loader v-else class="h-6 w-6 text-white mx-auto" />
</button> </button>
</template> </template>
<script> <script>
import { themes } from '~/config/form-themes.js' import { themes } from '~/lib/forms/form-themes.js'
export default { export default {
name: 'OpenFormButton', name: 'OpenFormButton',

View File

@ -51,7 +51,7 @@
</div> </div>
<component :is="getFieldComponents" v-if="getFieldComponents" <component :is="getFieldComponents" v-if="getFieldComponents"
v-bind="inputProperties(field)" :required="isFieldRequired" v-bind="inputProperties(field)" :required="isFieldRequired"
:disabled="isFieldDisabled" :disabled="isFieldDisabled?true:null"
/> />
<template v-else> <template v-else>
<div v-if="field.type === 'nf-text' && field.content" :id="field.id" :key="field.id" <div v-if="field.type === 'nf-text' && field.content" :id="field.id" :key="field.id"
@ -71,7 +71,7 @@
<div v-if="!field.image_block" class="p-4 border border-dashed"> <div v-if="!field.image_block" class="p-4 border border-dashed">
Open <b>{{ field.name }}'s</b> block settings to upload image. Open <b>{{ field.name }}'s</b> block settings to upload image.
</div> </div>
<img v-else :alt="field.name" :src="field.image_block" class="max-w-full"> <img v-else :alt="field.name" :src="field.image_block" class="max-w-full"/>
</div> </div>
</template> </template>
</div> </div>
@ -79,14 +79,12 @@
</template> </template>
<script> <script>
import FormLogicPropertyResolver from '../../../forms/FormLogicPropertyResolver.js' import { computed } from 'vue'
import FormPendingSubmissionKey from '../../../mixins/forms/form-pending-submission-key.js' import FormLogicPropertyResolver from "~/lib/forms/FormLogicPropertyResolver.js"
import { mapState } from 'vuex'
export default { export default {
name: 'OpenFormField', name: 'OpenFormField',
components: {}, components: {},
mixins: [FormPendingSubmissionKey],
props: { props: {
form: { form: {
type: Object, type: Object,
@ -114,29 +112,17 @@ export default {
}, },
adminPreview: { type: Boolean, default: false } // If used in FormEditorPreview adminPreview: { type: Boolean, default: false } // If used in FormEditorPreview
}, },
data () {
return {} setup (props) {
const workingFormStore = useWorkingFormStore()
return {
workingFormStore,
selectedFieldIndex: computed(() => workingFormStore.selectedFieldIndex),
showEditFieldSidebar: computed(() => workingFormStore.showEditFieldSidebar)
}
}, },
computed: { computed: {
...mapState({
selectedFieldIndex: state => state['open/working_form'].selectedFieldIndex,
showEditFieldSidebar: state => state['open/working_form'].showEditFieldSidebar
}),
fieldComponents () {
return {
text: 'TextInput',
number: 'TextInput',
select: 'SelectInput',
multi_select: 'SelectInput',
date: 'DateInput',
files: 'FileInput',
checkbox: 'CheckboxInput',
url: 'TextInput',
email: 'TextInput',
phone_number: 'TextInput'
}
},
/** /**
* Get the right input component for the field/options combination * Get the right input component for the field/options combination
*/ */
@ -166,7 +152,18 @@ export default {
if (field.type === 'phone_number' && !field.use_simple_text_input) { if (field.type === 'phone_number' && !field.use_simple_text_input) {
return 'PhoneInput' return 'PhoneInput'
} }
return this.fieldComponents[field.type] return {
text: 'TextInput',
number: 'TextInput',
select: 'SelectInput',
multi_select: 'SelectInput',
date: 'DateInput',
files: 'FileInput',
checkbox: 'CheckboxInput',
url: 'TextInput',
email: 'TextInput',
phone_number: 'TextInput'
}[field.type]
}, },
isPublicFormPage () { isPublicFormPage () {
return this.$route.name === 'forms.show_public' return this.$route.name === 'forms.show_public'
@ -215,10 +212,10 @@ export default {
methods: { methods: {
editFieldOptions () { editFieldOptions () {
this.$store.commit('open/working_form/openSettingsForField', this.field) this.workingFormStore.openSettingsForField(this.field)
}, },
openAddFieldSidebar () { openAddFieldSidebar () {
this.$store.commit('open/working_form/openAddFieldSidebar', this.field) this.workingFormStore.openAddFieldSidebar(this.field)
}, },
/** /**
* Get the right input component for the field/options combination * Get the right input component for the field/options combination
@ -302,7 +299,7 @@ export default {
} }
} else if (field.type === 'files' || (field.type === 'url' && field.file_upload)) { } else if (field.type === 'files' || (field.type === 'url' && field.file_upload)) {
inputProperties.multiple = (field.multiple !== undefined && field.multiple) inputProperties.multiple = (field.multiple !== undefined && field.multiple)
inputProperties.mbLimit = this.form.max_file_size inputProperties.mbLimit = 5
inputProperties.accept = (this.form.is_pro && field.allowed_file_types) ? field.allowed_file_types : '' inputProperties.accept = (this.form.is_pro && field.allowed_file_types) ? field.allowed_file_types : ''
} else if (field.type === 'number' && field.is_rating) { } else if (field.type === 'number' && field.is_rating) {
inputProperties.numberOfStars = parseInt(field.rating_max_value) inputProperties.numberOfStars = parseInt(field.rating_max_value)

View File

@ -1,30 +1,30 @@
<template> <template>
<collapse class="py-5 w-full" :default-value="false"> <collapse class="py-5 w-full" :model-value="false">
<template #title> <template #title>
<div class="flex"> <div class="flex">
<h3 class="font-semibold block text-lg"> <h3 class="font-semibold block text-lg">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 inline text-gray-500 mr-2 -mt-1" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 inline text-gray-500 mr-2 -mt-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg> Show advanced options </svg> Show advanced sharing options
</h3> </h3>
</div> </div>
</template> </template>
<toggle-switch-input :value="value.hide_title" name="hide_title" class="mt-4" <toggle-switch-input :model-value="modelValue.hide_title" name="hide_title" class="mt-4"
label="Hide Form Title" label="Hide Form Title"
:disabled="form.hide_title===true" :disabled="(form.hide_title===true)?true:null"
@input="onChangeHideTitle" :help="hideTitleHelp"
:help="hideTitleHelp" @update:model-value="onChangeHideTitle"
/> />
<toggle-switch-input :value="value.auto_submit" name="auto_submit" class="mt-4" <toggle-switch-input :model-value="modelValue.auto_submit" name="auto_submit" class="mt-4"
label="Auto Submit Form" label="Auto Submit Form"
help="Form will auto submit immediate after open URL" help="Form will auto submit immediate after open URL"
@input="onChangeAutoSubmit" @update:model-value="onChangeAutoSubmit"
/> />
</collapse> </collapse>
</template> </template>
<script> <script>
import Collapse from '../../../common/Collapse.vue' import Collapse from '~/components/global/Collapse.vue'
export default { export default {
name: 'AdvancedFormUrlSettings', name: 'AdvancedFormUrlSettings',
components: { Collapse }, components: { Collapse },
@ -33,7 +33,7 @@ export default {
type: Object, type: Object,
required: true required: true
}, },
value: { modelValue: {
type: Object, type: Object,
required: true required: true
} }
@ -56,10 +56,10 @@ export default {
methods: { methods: {
onChangeHideTitle (val) { onChangeHideTitle (val) {
this.value.hide_title = val this.modelValue.hide_title = val
}, },
onChangeAutoSubmit (val) { onChangeAutoSubmit (val) {
this.value.auto_submit = val this.modelValue.auto_submit = val
} }
} }
} }

View File

@ -6,7 +6,7 @@
</p> </p>
</div> </div>
<div class="w-full sm:w-40 sm:ml-2 mt-2 sm:mt-0 shrink-0"> <div class="w-full sm:w-40 sm:ml-2 mt-2 sm:mt-0 shrink-0">
<v-button color="light-gray" class="w-full" @click="copyToClipboard(content)"> <v-button color="light-gray" class="w-full" @click="copyToClipboard">
<slot name="icon"> <slot name="icon">
<svg class="h-4 w-4 -mt-1 text-blue-600 inline mr-1" viewBox="0 0 20 20" fill="none" <svg class="h-4 w-4 -mt-1 text-blue-600 inline mr-1" viewBox="0 0 20 20" fill="none"
xmlns="http://www.w3.org/2000/svg"> xmlns="http://www.w3.org/2000/svg">
@ -21,46 +21,28 @@
</div> </div>
</template> </template>
<script> <script setup>
export default { import { defineProps } from 'vue'
name: 'CopyContent', const { copy } = useClipboard()
props: {
content: { const props = defineProps({
type: String, content: {
required: true type: String,
}, required: true
isDraft: {
type: Boolean,
default: false
},
}, },
isDraft: {
type: Boolean,
default: false
}
})
data() { const copyToClipboard = () => {
return {} if (process.server) return
}, copy(props.content)
if(props.isDraft){
computed: {}, useAlert().warning('Copied! But other people won\'t be able to see the form since it\'s currently in draft mode')
} else {
watch: {}, useAlert().success('Copied!')
mounted() {
},
methods: {
copyToClipboard(str) {
const el = document.createElement('textarea')
el.value = str
document.body.appendChild(el)
el.select()
document.execCommand('copy')
document.body.removeChild(el)
if(this.isDraft){
this.alertWarning('Copied! But other people won\'t be able to see the form since it\'s currently in draft mode')
} else {
this.alertSuccess('Copied!')
}
}
} }
} }
</script> </script>

View File

@ -49,45 +49,41 @@
</div> </div>
</div> </div>
<div class="w-full flex grow overflow-y-scroll relative"> <div class="w-full flex grow overflow-y-scroll relative bg-gray-50">
<div class="relative w-full shrink-0 overflow-y-scroll border-r md:w-1/2 md:max-w-sm lg:w-2/5"> <div class="relative w-full bg-white shrink-0 overflow-y-scroll border-r md:w-1/2 md:max-w-sm lg:w-2/5">
<div class="border-b bg-blue-50 p-5 text-nt-blue-dark md:hidden"> <div class="border-b bg-blue-50 p-5 text-nt-blue-dark md:hidden">
Please create this form on a device with a larger screen. That will allow you to preview your form changes. Please create this form on a device with a larger screen. That will allow you to preview your form changes.
</div> </div>
<form-information/> <form-information />
<form-structure/> <form-structure />
<form-customization/> <form-customization />
<form-notifications/> <form-notifications />
<form-about-submission/> <form-about-submission />
<form-access /> <form-access />
<form-security-privacy/> <form-security-privacy />
<form-custom-seo /> <form-custom-seo />
<form-custom-code/> <form-custom-code />
</div> </div>
<form-editor-preview/> <form-editor-preview />
<form-editor-sidebar />
<form-field-edit-sidebar/>
<add-form-block-sidebar/>
<!-- Form Error Modal --> <!-- Form Error Modal -->
<form-error-modal <form-error-modal
:show="showFormErrorModal" :show="showFormErrorModal"
:validation-error-response="validationErrorResponse" :form="form"
@close="showFormErrorModal=false" @close="showFormErrorModal=false"
/> />
</div> </div>
</div> </div>
<div v-else class="flex justify-center items-center"> <div v-else class="flex justify-center items-center p-8">
<loader class="w-6 h-6"/> <Loader class="w-6 h-6" />
</div> </div>
</template> </template>
<script> <script>
import {mapGetters} from 'vuex' import FormEditorSidebar from './form-components/FormEditorSidebar.vue'
import AddFormBlockSidebar from './form-components/AddFormBlockSidebar.vue'
import FormFieldEditSidebar from '../fields/FormFieldEditSidebar.vue'
import FormErrorModal from './form-components/FormErrorModal.vue' import FormErrorModal from './form-components/FormErrorModal.vue'
import FormInformation from './form-components/FormInformation.vue' import FormInformation from './form-components/FormInformation.vue'
import FormStructure from './form-components/FormStructure.vue' import FormStructure from './form-components/FormStructure.vue'
@ -99,14 +95,13 @@ import FormEditorPreview from './form-components/FormEditorPreview.vue'
import FormSecurityPrivacy from './form-components/FormSecurityPrivacy.vue' import FormSecurityPrivacy from './form-components/FormSecurityPrivacy.vue'
import FormCustomSeo from './form-components/FormCustomSeo.vue' import FormCustomSeo from './form-components/FormCustomSeo.vue'
import FormAccess from './form-components/FormAccess.vue' import FormAccess from './form-components/FormAccess.vue'
import saveUpdateAlert from '../../../../mixins/forms/saveUpdateAlert.js' import {validatePropertiesLogic} from "~/composables/forms/validatePropertiesLogic.js"
import fieldsLogic from '../../../../mixins/forms/fieldsLogic.js' import opnformConfig from "~/opnform.config.js";
export default { export default {
name: 'FormEditor', name: 'FormEditor',
components: { components: {
AddFormBlockSidebar, FormEditorSidebar,
FormFieldEditSidebar,
FormEditorPreview, FormEditorPreview,
FormNotifications, FormNotifications,
FormAboutSubmission, FormAboutSubmission,
@ -119,7 +114,6 @@ export default {
FormCustomSeo, FormCustomSeo,
FormAccess FormAccess
}, },
mixins: [saveUpdateAlert, fieldsLogic],
props: { props: {
isEdit: { isEdit: {
required: false, required: false,
@ -143,35 +137,37 @@ export default {
} }
}, },
data() { setup () {
const {user} = storeToRefs(useAuthStore())
const formsStore = useFormsStore()
const {content: form} = storeToRefs(useWorkingFormStore())
const {getCurrent: workspace} = storeToRefs(useWorkspacesStore())
return {
appStore: useAppStore(),
crisp: useCrisp(),
amplitude: useAmplitude(),
opnformConfig,
workspace,
formsStore,
form,
user
}
},
data () {
return { return {
showFormErrorModal: false, showFormErrorModal: false,
validationErrorResponse: null, validationErrorResponse: null,
updateFormLoading: false, updateFormLoading: false,
createdFormId: null createdFormSlug: null
} }
}, },
computed: { computed: {
...mapGetters({ createdForm () {
user: 'auth/user' return this.formsStore.getByKey(this.createdFormSlug)
}),
form: {
get() {
return this.$store.state['open/working_form'].content
},
/* We add a setter */
set(value) {
this.$store.commit('open/working_form/set', value)
}
}, },
createdForm() { steps () {
return this.$store.getters['open/forms/getById'](this.createdFormId)
},
workspace() {
return this.$store.getters['open/workspaces/getCurrent']()
},
steps() {
return [ return [
{ {
target: '#v-step-0', target: '#v-step-0',
@ -206,31 +202,40 @@ export default {
} }
] ]
}, },
helpUrl: () => window.config.links.help helpUrl () {
return this.opnformConfig.links.help
}
}, },
watch: {}, watch: {},
mounted() { mounted () {
this.$emit('mounted') this.$emit('mounted')
this.$root.hideNavbar() this.appStore.hideNavbar()
}, },
beforeDestroy () { beforeUnmount () {
this.$root.hideNavbar(false) this.appStore.showNavbar()
}, },
methods: { methods: {
openCrisp () { displayFormModificationAlert (responseData) {
window.$crisp.push(['do', 'chat:show']) const alert = useAlert()
window.$crisp.push(['do', 'chat:open']) if (responseData.form && responseData.form.cleanings && Object.keys(responseData.form.cleanings).length > 0) {
alert.warning(responseData.message)
} else if (responseData.message) {
alert.success(responseData.message)
}
}, },
showValidationErrors() { openCrisp () {
this.crisp.openChat()
},
showValidationErrors () {
this.showFormErrorModal = true this.showFormErrorModal = true
}, },
saveForm() { saveForm () {
this.form.properties = this.validateFieldsLogic(this.form.properties) this.form.properties = validatePropertiesLogic(this.form.properties)
if(this.isGuest) { if (this.isGuest) {
this.saveFormGuest() this.saveFormGuest()
} else if (this.isEdit) { } else if (this.isEdit) {
this.saveFormEdit() this.saveFormEdit()
@ -238,17 +243,16 @@ export default {
this.saveFormCreate() this.saveFormCreate()
} }
}, },
saveFormEdit() { saveFormEdit () {
if (this.updateFormLoading) return if (this.updateFormLoading) return
this.updateFormLoading = true this.updateFormLoading = true
this.validationErrorResponse = null this.validationErrorResponse = null
this.form.put('/api/open/forms/{id}/'.replace('{id}', this.form.id)).then((response) => { this.form.put('/open/forms/{id}/'.replace('{id}', this.form.id)).then((data) => {
const data = response.data this.formsStore.save(data.form)
this.$store.commit('open/forms/addOrUpdate', data.form)
this.$emit('on-save') this.$emit('on-save')
this.$router.push({name: 'forms.show', params: {slug: this.form.slug}}) this.$router.push({ name: 'forms-slug-show-share', params: { slug: this.form.slug } })
this.$logEvent('form_saved', {form_id: this.form.id, form_slug: this.form.slug}) this.amplitude.logEvent('form_saved', { form_id: this.form.id, form_slug: this.form.slug })
this.displayFormModificationAlert(data) this.displayFormModificationAlert(data)
}).catch((error) => { }).catch((error) => {
if (error.response.status === 422) { if (error.response.status === 422) {
@ -259,40 +263,40 @@ export default {
this.updateFormLoading = false this.updateFormLoading = false
}) })
}, },
saveFormCreate() { saveFormCreate () {
if (this.updateFormLoading) return if (this.updateFormLoading) return
this.form.workspace_id = this.workspace.id this.form.workspace_id = this.workspace.id
this.validationErrorResponse = null this.validationErrorResponse = null
this.updateFormLoading = true this.updateFormLoading = true
this.form.post('/api/open/forms').then((response) => { this.form.post('/open/forms').then((response) => {
this.$store.commit('open/forms/addOrUpdate', response.data.form) this.formsStore.save(response.form)
this.$emit('on-save') this.$emit('on-save')
this.createdFormId = response.data.form.id this.createdFormSlug = response.form.slug
this.$logEvent('form_created', {form_id: response.data.form.id, form_slug: response.data.form.slug}) this.amplitude.logEvent('form_created', { form_id: response.form.id, form_slug: response.form.slug })
this.$crisp.push(['set', 'session:event', [[['form_created', { this.crisp.pushEvent('form_created',{
form_id: response.data.form.id, form_id: response.form.id,
form_slug: response.data.form.slug form_slug: response.form.slug
}, 'blue']]]]) })
this.displayFormModificationAlert(response.data) this.displayFormModificationAlert(response)
this.$router.push({ useRouter().push({
name: 'forms.show', name: 'forms-show',
params: { params: {
slug: this.createdForm.slug, slug: this.createdForm.slug,
new_form: response.data.users_first_form new_form: response.users_first_form
} }
}) })
}).catch((error) => { }).catch((error) => {
if (error.response && error.response.status === 422) { if (error.response && error.response.status === 422) {
this.validationErrorResponse = error.response.data this.validationErrorResponse = error.response
this.showValidationErrors() this.showValidationErrors()
} }
}).finally(() => { }).finally(() => {
this.updateFormLoading = false this.updateFormLoading = false
}) })
}, },
saveFormGuest() { saveFormGuest () {
this.$emit('openRegister') this.$emit('openRegister')
} }
} }

View File

@ -0,0 +1,306 @@
<template>
<div>
<v-button v-if="form.properties && form.properties.length > 8"
class="w-full mb-3" color="light-gray"
@click="openAddFieldSidebar"
>
<svg class="w-4 h-4 text-nt-blue inline mr-1 -mt-1" viewBox="0 0 14 14" fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M7.00001 1.1665V12.8332M1.16667 6.99984H12.8333" stroke="currentColor" stroke-width="1.67"
stroke-linecap="round" stroke-linejoin="round"
/>
</svg>
Add block
</v-button>
<draggable v-model="form.properties"
item-key="id"
class="bg-white overflow-hidden dark:bg-notion-dark-light rounded-md w-full mx-auto border transition-colors"
ghost-class="bg-gray-50"
handle=".draggable"
:animation="200"
>
<template #item="{element, index}">
<div class="w-full mx-auto transition-colors"
:class="{'bg-gray-100 dark:bg-gray-800':element.hidden,'bg-white dark:bg-notion-dark-light':!element.hidden && !element.type==='nf-page-break', 'border-b': (index!== form.properties.length -1), 'bg-blue-50 dark:bg-blue-900':element && element.type==='nf-page-break'}"
>
<div v-if="element" class="flex items-center space-x-1 group py-2 pr-4 relative">
<!-- Drag handler -->
<div class="cursor-move draggable p-2 -mr-2">
<svg class="h-4 w-4 text-gray-400" viewBox="0 0 18 8" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.5 1.0835H16.5M1.5 6.91683H16.5" stroke="currentColor" stroke-width="1.67"
stroke-linecap="round" stroke-linejoin="round"
/>
</svg>
</div>
<!-- Field name and type -->
<div class="flex flex-col flex-grow truncate">
<editable-div class="max-w-full flex items-center" :model-value="element.name"
@update:model-value="onChangeName(element, $event)"
>
<div class="cursor-pointer max-w-full truncate">
{{ element.name }}
</div>
</editable-div>
<p class="text-xs text-gray-400 w-full truncate pl-2">
<span class="capitalize">{{ formatType(element) }}</span>
</p>
</div>
<template v-if="removing == element.id">
<div class="flex text-sm items-center">
Remove block?
<v-button class="inline ml-1" color="red" size="small" @click="removeBlock(index)">
Yes
</v-button>
<v-button class="inline ml-1" color="light-gray" size="small" @click="removing=false">
No
</v-button>
</div>
</template>
<template v-else>
<button
class="hover:bg-red-50 text-gray-500 hover:text-red-600 rounded transition-colors cursor-pointer p-2 hidden md:group-hover:block"
@click="removing=element.id"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M3 6H5M5 6H21M5 6V20C5 20.5304 5.21071 21.0391 5.58579 21.4142C5.96086 21.7893 6.46957 22 7 22H17C17.5304 22 18.0391 21.7893 18.4142 21.4142C18.7893 21.0391 19 20.5304 19 20V6H5ZM8 6V4C8 3.46957 8.21071 2.96086 8.58579 2.58579C8.96086 2.21071 9.46957 2 10 2H14C14.5304 2 15.0391 2.21071 15.4142 2.58579C15.7893 2.96086 16 3.46957 16 4V6M10 11V17M14 11V17"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
/>
</svg>
</button>
<button class="hover:bg-nt-blue-lighter rounded transition-colors cursor-pointer p-2 hidden"
:class="{'text-blue-500': !element.hidden, 'text-gray-500': element.hidden, 'group-hover:md:block': !element.hidden, 'md:block':element.hidden}"
@click="toggleHidden(element)"
>
<template v-if="!element.hidden">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 12C1 12 5 4 12 4C19 4 23 12 23 12C23 12 19 20 12 20C5 20 1 12 1 12Z"
stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
/>
<path
d="M12 15C13.6569 15 15 13.6569 15 12C15 10.3431 13.6569 9 12 9C10.3431 9 9 10.3431 9 12C9 13.6569 10.3431 15 12 15Z"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
/>
</svg>
</template>
<template v-else>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1027_7292)">
<path
d="M9.9 4.24C10.5883 4.07888 11.2931 3.99834 12 4C19 4 23 12 23 12C22.393 13.1356 21.6691 14.2047 20.84 15.19M14.12 14.12C13.8454 14.4147 13.5141 14.6512 13.1462 14.8151C12.7782 14.9791 12.3809 15.0673 11.9781 15.0744C11.5753 15.0815 11.1752 15.0074 10.8016 14.8565C10.4281 14.7056 10.0887 14.481 9.80385 14.1962C9.51897 13.9113 9.29439 13.5719 9.14351 13.1984C8.99262 12.8248 8.91853 12.4247 8.92563 12.0219C8.93274 11.6191 9.02091 11.2218 9.18488 10.8538C9.34884 10.4859 9.58525 10.1546 9.88 9.88M1 1L23 23M17.94 17.94C16.2306 19.243 14.1491 19.9649 12 20C5 20 1 12 1 12C2.24389 9.6819 3.96914 7.65661 6.06 6.06L17.94 17.94Z"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
/>
</g>
<defs>
<clipPath id="clip0_1027_7292">
<rect width="24" height="24" fill="white" />
</clipPath>
</defs>
</svg>
</template>
</button>
<button v-if="!element.type.startsWith('nf-')"
class="hover:bg-nt-blue-lighter rounded transition-colors cursor-pointer p-2 hidden"
:class="{'group-hover:md:block': !element.required, 'md:block':element.required}"
@click="toggleRequired(element)"
>
<div class="w-4 h-4 text-center font-bold text-3xl"
:class="{'text-red-500': element.required, 'text-gray-500': !element.required}"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M12 2V12M12 12V22M12 12L4.93 4.93M12 12L19.07 19.07M12 12H2M12 12H22M12 12L4.93 19.07M12 12L19.07 4.93"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
/>
</svg>
</div>
</button>
<button class="hover:bg-nt-blue-lighter rounded transition-colors cursor-pointer p-2"
@click="editOptions(index)"
>
<svg class="h-4 w-4 text-blue-600" width="24" height="24" viewBox="0 0 24 24" fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clip-path="url(#clip0_1027_7210)">
<path
d="M12 15C13.6569 15 15 13.6569 15 12C15 10.3431 13.6569 9 12 9C10.3431 9 9 10.3431 9 12C9 13.6569 10.3431 15 12 15Z"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
/>
<path
d="M19.4 15C19.2669 15.3016 19.2272 15.6362 19.286 15.9606C19.3448 16.285 19.4995 16.5843 19.73 16.82L19.79 16.88C19.976 17.0657 20.1235 17.2863 20.2241 17.5291C20.3248 17.7719 20.3766 18.0322 20.3766 18.295C20.3766 18.5578 20.3248 18.8181 20.2241 19.0609C20.1235 19.3037 19.976 19.5243 19.79 19.71C19.6043 19.896 19.3837 20.0435 19.1409 20.1441C18.8981 20.2448 18.6378 20.2966 18.375 20.2966C18.1122 20.2966 17.8519 20.2448 17.6091 20.1441C17.3663 20.0435 17.1457 19.896 16.96 19.71L16.9 19.65C16.6643 19.4195 16.365 19.2648 16.0406 19.206C15.7162 19.1472 15.3816 19.1869 15.08 19.32C14.7842 19.4468 14.532 19.6572 14.3543 19.9255C14.1766 20.1938 14.0813 20.5082 14.08 20.83V21C14.08 21.5304 13.8693 22.0391 13.4942 22.4142C13.1191 22.7893 12.6104 23 12.08 23C11.5496 23 11.0409 22.7893 10.6658 22.4142C10.2907 22.0391 10.08 21.5304 10.08 21V20.91C10.0723 20.579 9.96512 20.258 9.77251 19.9887C9.5799 19.7194 9.31074 19.5143 9 19.4C8.69838 19.2669 8.36381 19.2272 8.03941 19.286C7.71502 19.3448 7.41568 19.4995 7.18 19.73L7.12 19.79C6.93425 19.976 6.71368 20.1235 6.47088 20.2241C6.22808 20.3248 5.96783 20.3766 5.705 20.3766C5.44217 20.3766 5.18192 20.3248 4.93912 20.2241C4.69632 20.1235 4.47575 19.976 4.29 19.79C4.10405 19.6043 3.95653 19.3837 3.85588 19.1409C3.75523 18.8981 3.70343 18.6378 3.70343 18.375C3.70343 18.1122 3.75523 17.8519 3.85588 17.6091C3.95653 17.3663 4.10405 17.1457 4.29 16.96L4.35 16.9C4.58054 16.6643 4.73519 16.365 4.794 16.0406C4.85282 15.7162 4.81312 15.3816 4.68 15.08C4.55324 14.7842 4.34276 14.532 4.07447 14.3543C3.80618 14.1766 3.49179 14.0813 3.17 14.08H3C2.46957 14.08 1.96086 13.8693 1.58579 13.4942C1.21071 13.1191 1 12.6104 1 12.08C1 11.5496 1.21071 11.0409 1.58579 10.6658C1.96086 10.2907 2.46957 10.08 3 10.08H3.09C3.42099 10.0723 3.742 9.96512 4.0113 9.77251C4.28059 9.5799 4.48572 9.31074 4.6 9C4.73312 8.69838 4.77282 8.36381 4.714 8.03941C4.65519 7.71502 4.50054 7.41568 4.27 7.18L4.21 7.12C4.02405 6.93425 3.87653 6.71368 3.77588 6.47088C3.67523 6.22808 3.62343 5.96783 3.62343 5.705C3.62343 5.44217 3.67523 5.18192 3.77588 4.93912C3.87653 4.69632 4.02405 4.47575 4.21 4.29C4.39575 4.10405 4.61632 3.95653 4.85912 3.85588C5.10192 3.75523 5.36217 3.70343 5.625 3.70343C5.88783 3.70343 6.14808 3.75523 6.39088 3.85588C6.63368 3.95653 6.85425 4.10405 7.04 4.29L7.1 4.35C7.33568 4.58054 7.63502 4.73519 7.95941 4.794C8.28381 4.85282 8.61838 4.81312 8.92 4.68H9C9.29577 4.55324 9.54802 4.34276 9.72569 4.07447C9.90337 3.80618 9.99872 3.49179 10 3.17V3C10 2.46957 10.2107 1.96086 10.5858 1.58579C10.9609 1.21071 11.4696 1 12 1C12.5304 1 13.0391 1.21071 13.4142 1.58579C13.7893 1.96086 14 2.46957 14 3V3.09C14.0013 3.41179 14.0966 3.72618 14.2743 3.99447C14.452 4.26276 14.7042 4.47324 15 4.6C15.3016 4.73312 15.6362 4.77282 15.9606 4.714C16.285 4.65519 16.5843 4.50054 16.82 4.27L16.88 4.21C17.0657 4.02405 17.2863 3.87653 17.5291 3.77588C17.7719 3.67523 18.0322 3.62343 18.295 3.62343C18.5578 3.62343 18.8181 3.67523 19.0609 3.77588C19.3037 3.87653 19.5243 4.02405 19.71 4.21C19.896 4.39575 20.0435 4.61632 20.1441 4.85912C20.2448 5.10192 20.2966 5.36217 20.2966 5.625C20.2966 5.88783 20.2448 6.14808 20.1441 6.39088C20.0435 6.63368 19.896 6.85425 19.71 7.04L19.65 7.1C19.4195 7.33568 19.2648 7.63502 19.206 7.95941C19.1472 8.28381 19.1869 8.61838 19.32 8.92V9C19.4468 9.29577 19.6572 9.54802 19.9255 9.72569C20.1938 9.90337 20.5082 9.99872 20.83 10H21C21.5304 10 22.0391 10.2107 22.4142 10.5858C22.7893 10.9609 23 11.4696 23 12C23 12.5304 22.7893 13.0391 22.4142 13.4142C22.0391 13.7893 21.5304 14 21 14H20.91C20.5882 14.0013 20.2738 14.0966 20.0055 14.2743C19.7372 14.452 19.5268 14.7042 19.4 15Z"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
/>
</g>
<defs>
<clipPath id="clip0_1027_7210">
<rect width="24" height="24" fill="white" />
</clipPath>
</defs>
</svg>
</button>
</template>
</div>
</div>
</template>
</draggable>
<v-button
class="w-full mt-3" color="light-gray"
@click="openAddFieldSidebar"
>
<svg class="w-4 h-4 text-nt-blue inline mr-1 -mt-1" viewBox="0 0 14 14" fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M7.00001 1.1665V12.8332M1.16667 6.99984H12.8333" stroke="currentColor" stroke-width="1.67"
stroke-linecap="round" stroke-linejoin="round"
/>
</svg>
Add block
</v-button>
</div>
</template>
<script>
import draggable from 'vuedraggable'
import ProTag from '~/components/global/ProTag.vue'
import clonedeep from 'clone-deep'
import EditableDiv from '~/components/global/EditableDiv.vue'
import VButton from '~/components/global/VButton.vue'
draggable.compatConfig = { MODE: 3 }
export default {
name: 'FormFieldsEditor',
components: {
VButton,
ProTag,
draggable,
EditableDiv
},
setup () {
const workingFormStore = useWorkingFormStore()
return {
route: useRoute(),
workingFormStore,
form: storeToRefs(workingFormStore).content,
}
},
data () {
return {
removing: null
}
},
mounted () {
this.init()
},
methods: {
onChangeName (field, newName) {
field.name = newName
},
toggleHidden (field) {
field.hidden = !field.hidden
if (field.hidden) {
field.required = false
} else {
field.generates_uuid = false
field.generates_auto_increment_id = false
}
},
toggleRequired (field) {
field.required = !field.required
if (field.required) {
field.hidden = false
}
},
getDefaultFields () {
return [
{
name: 'Name',
type: 'text',
hidden: false,
required: true,
id: this.generateUUID()
},
{
name: 'Email',
type: 'email',
hidden: false,
id: this.generateUUID()
},
{
name: 'Message',
type: 'text',
hidden: false,
multi_lines: true,
id: this.generateUUID()
}
]
},
init () {
if (this.route.name === 'forms-create' || this.route.name === 'forms-create-guest') { // Set Default fields
if (!this.form.properties || this.form.properties.length===0) {
this.form.properties = this.getDefaultFields()
}
} else {
this.form.properties = this.form.properties.map((field) => {
// Add more field properties
field.placeholder = field.placeholder || null
field.prefill = field.prefill || null
field.help = field.help || null
field.help_position = field.help_position || 'below_input'
return field
})
}
},
generateUUID () {
let d = new Date().getTime()// Timestamp
let d2 = ((typeof performance !== 'undefined') && performance.now && (performance.now() * 1000)) || 0// Time in microseconds since page-load or 0 if unsupported
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
let r = Math.random() * 16// random number between 0 and 16
if (d > 0) { // Use timestamp until depleted
r = (d + r) % 16 | 0
d = Math.floor(d / 16)
} else { // Use microseconds since page-load if supported
r = (d2 + r) % 16 | 0
d2 = Math.floor(d2 / 16)
}
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16)
})
},
formatType (field) {
let type = field.type.replace('_', ' ')
if (!type.startsWith('nf')) {
type = type + ' Input'
} else {
type = type.replace('nf-', '')
}
if (field.generates_uuid || field.generates_auto_increment_id) {
type = type + ' - Auto ID'
}
return type
},
editOptions (index) {
this.workingFormStore.openSettingsForField(index)
},
removeBlock (blockIndex) {
this.form.properties.splice(blockIndex, 1)
this.closeSidebar()
},
closeSidebar () {
this.workingFormStore.closeEditFieldSidebar()
},
openAddFieldSidebar () {
this.workingFormStore.openAddFieldSidebar(null)
}
}
}
</script>

View File

@ -13,22 +13,22 @@
</p> </p>
</div> </div>
</div> </div>
<img :src="asset('img/pages/forms/blurred_graph.png')" <NuxtImg src="/img/pages/forms/blurred_graph.png"
alt="Sample Graph" alt="Sample Graph"
class="mx-auto filter blur-md z-0" class="mx-auto filter blur-md z-0"
> />
</div> </div>
<loader v-else-if="isLoading" class="h-6 w-6 text-nt-blue mx-auto" /> <Loader v-else-if="isLoading" class="h-6 w-6 text-nt-blue mx-auto" />
<LineChart v-else <LineChart v-else
:chart-options="chartOptions" :options="chartOptions"
:chart-data="chartData" :data="chartData"
/> />
</div> </div>
</template> </template>
<script> <script>
import axios from 'axios' import { Line as LineChart } from 'vue-chartjs'
import { Line as LineChart } from 'vue-chartjs/legacy'
import { import {
Chart as ChartJS, Chart as ChartJS,
Title, Title,
@ -39,7 +39,7 @@ import {
CategoryScale, CategoryScale,
PointElement PointElement
} from 'chart.js' } from 'chart.js'
import ProTag from '../../../common/ProTag.vue' import ProTag from '~/components/global/ProTag.vue'
ChartJS.register( ChartJS.register(
Title, Title,
@ -93,7 +93,7 @@ export default {
} }
}, },
responsive: true, responsive: true,
maintainAspectRatio: false maintainAspectRatio: true
} }
} }
}, },
@ -104,8 +104,7 @@ export default {
getChartData () { getChartData () {
if (!this.form) { return null } if (!this.form) { return null }
this.isLoading = true this.isLoading = true
axios.get('/api/open/workspaces/' + this.form.workspace_id + '/form-stats/' + this.form.id).then((response) => { opnFetch('/open/workspaces/' + this.form.workspace_id + '/form-stats/' + this.form.id).then((statsData) => {
const statsData = response.data
if (statsData && statsData.views !== undefined) { if (statsData && statsData.views !== undefined) {
this.chartData.labels = Object.keys(statsData.views) this.chartData.labels = Object.keys(statsData.views)
this.chartData.datasets[0].data = statsData.views this.chartData.datasets[0].data = statsData.views

View File

@ -1,6 +1,7 @@
<template> <template>
<div <div
class="my-4 w-full mx-auto"> class="my-4 w-full mx-auto"
>
<h3 class="font-semibold mb-4 text-xl"> <h3 class="font-semibold mb-4 text-xl">
Form Submissions Form Submissions
</h3> </h3>
@ -9,8 +10,8 @@
<modal :show="showColumnsModal" @close="showColumnsModal=false"> <modal :show="showColumnsModal" @close="showColumnsModal=false">
<template #icon> <template #icon>
<svg class="w-8 h-8" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg class="w-8 h-8" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16 5H8C4.13401 5 1 8.13401 1 12C1 15.866 4.13401 19 8 19H16C19.866 19 23 15.866 23 12C23 8.13401 19.866 5 16 5Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> <path d="M16 5H8C4.13401 5 1 8.13401 1 12C1 15.866 4.13401 19 8 19H16C19.866 19 23 15.866 23 12C23 8.13401 19.866 5 16 5Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M8 15C9.65685 15 11 13.6569 11 12C11 10.3431 9.65685 9 8 9C6.34315 9 5 10.3431 5 12C5 13.6569 6.34315 15 8 15Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> <path d="M8 15C9.65685 15 11 13.6569 11 12C11 10.3431 9.65685 9 8 9C6.34315 9 5 10.3431 5 12C5 13.6569 6.34315 15 8 15Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg> </svg>
</template> </template>
<template #title> <template #title>
@ -19,33 +20,51 @@
<div class="px-4"> <div class="px-4">
<template v-if="properties.length > 0"> <template v-if="properties.length > 0">
<h4 class="font-bold mb-2">Form Fields</h4> <h4 class="font-bold mb-2">
<div v-for="field in properties" :key="field.id" class="p-2 border"> Form Fields
{{ field.name }} </h4>
<v-switch v-model="displayColumns[field.id]" @input="onChangeDisplayColumns" class="float-right"/> <div class="border border-gray-300 rounded-md">
<div v-for="(field,index) in properties" :key="field.id" class="p-2 border-gray-300 flex items-center" :class="{'border-t':index!=0}">
<p class="flex-grow truncate">
{{ field.name }}
</p>
<v-switch v-model="displayColumns[field.id]" class="float-right" @update:model-value="onChangeDisplayColumns" />
</div>
</div> </div>
</template> </template>
<template v-if="removed_properties.length > 0"> <template v-if="removed_properties.length > 0">
<h4 class="font-bold mb-2 mt-4">Removed Fields</h4> <h4 class="font-bold mb-2 mt-4">
<div v-for="field in removed_properties" :key="field.id" class="p-2 border"> Removed Fields
{{ field.name }} </h4>
<v-switch v-model="displayColumns[field.id]" @input="onChangeDisplayColumns" class="float-right"/> <div class="border border-gray-300 rounded-md">
<div v-for="(field,index) in removed_properties" :key="field.id" class="p-2 border-gray-300 flex items-center" :class="{'border-t':index!=0}">
<p class="flex-grow truncate">
{{ field.name }}
</p>
<v-switch v-model="displayColumns[field.id]" class="float-right" @update:model-value="onChangeDisplayColumns" />
</div>
</div> </div>
</template> </template>
</div> </div>
</modal> </modal>
<loader v-if="!form || isLoading" class="h-6 w-6 text-nt-blue mx-auto"/> <Loader v-if="!form || !formInitDone" class="h-6 w-6 text-nt-blue mx-auto" />
<div v-else> <div v-else>
<div class="flex flex-wrap items-end"> <div v-if="form && tableData.length > 0" class="flex flex-wrap items-end">
<div class="flex-grow"> <div class="flex-grow">
<text-input class="w-64" :form="searchForm" name="search" placeholder="Search..." /> <text-input class="w-64" :form="searchForm" name="search" placeholder="Search..." />
</div> </div>
<div class="font-semibold flex gap-4"> <div class="font-semibold flex gap-4">
<p v-if="form && !isLoading && formInitDone" class="float-right text-xs uppercase mb-2"> <a <p class="float-right text-xs uppercase mb-2">
href="javascript:void(0);" class="text-gray-500" @click="showColumnsModal=true">Display columns</a></p> <a
<p v-if="form && !isLoading && tableData.length > 0" class="text-right text-xs uppercase"><a href="javascript:void(0);" class="text-gray-500" @click="showColumnsModal=true"
:href="exportUrl" target="_blank">Export as CSV</a></p> >Display columns</a>
</p>
<p class="text-right text-xs uppercase">
<a
:href="exportUrl" target="_blank"
>Export as CSV</a>
</p>
</div> </div>
</div> </div>
@ -62,27 +81,32 @@
:loading="isLoading" :loading="isLoading"
@resize="dataChanged()" @resize="dataChanged()"
@deleted="onDeleteRecord()" @deleted="onDeleteRecord()"
> />
</open-table>
</scroll-shadow> </scroll-shadow>
</div> </div>
</div> </div>
</template> </template>
<script> <script>
import axios from 'axios'
import Fuse from 'fuse.js' import Fuse from 'fuse.js'
import Form from 'vform' import clonedeep from 'clone-deep'
import ScrollShadow from '../../../common/ScrollShadow.vue'
import OpenTable from '../../tables/OpenTable.vue'
import clonedeep from "clone-deep";
import VSwitch from '../../../forms/components/VSwitch.vue' import VSwitch from '../../../forms/components/VSwitch.vue'
import OpenTable from '../../tables/OpenTable.vue'
export default { export default {
name: 'FormSubmissions', name: 'FormSubmissions',
components: {ScrollShadow, OpenTable, VSwitch}, components: { OpenTable, VSwitch },
props: {}, props: {},
data() {
setup () {
const workingFormStore = useWorkingFormStore()
return {
workingFormStore,
runtimeConfig: useRuntimeConfig()
}
},
data () {
return { return {
formInitDone: false, formInitDone: false,
isLoading: false, isLoading: false,
@ -93,43 +117,30 @@ export default {
properties: [], properties: [],
removed_properties: [], removed_properties: [],
displayColumns: {}, displayColumns: {},
searchForm: new Form({ searchForm: useForm({
search: '' search: ''
}) })
} }
}, },
mounted() {
this.initFormStructure()
this.getSubmissionsData()
},
watch: {
form() {
if (this.form === null) {
return
}
this.initFormStructure()
this.getSubmissionsData()
}
},
computed: { computed: {
form: { form: {
get() { get () {
return this.$store.state['open/working_form'].content return this.workingFormStore.content
}, },
set(value) { set (value) {
this.$store.commit('open/working_form/set', value) this.workingFormStore.set(value)
} }
}, },
exportUrl() { exportUrl () {
if (!this.form) { if (!this.form) {
return '' return ''
} }
return '/api/open/forms/' + this.form.id + '/submissions/export' return this.runtimeConfig.public.apiBase + '/open/forms/' + this.form.id + '/submissions/export'
}, },
filteredData () { filteredData () {
if(!this.tableData) return [] if (!this.tableData) return []
let filteredData = clonedeep(this.tableData) const filteredData = clonedeep(this.tableData)
if (this.searchForm.search === '' || this.searchForm.search === null) { if (this.searchForm.search === '' || this.searchForm.search === null) {
return filteredData return filteredData
@ -145,9 +156,22 @@ export default {
}) })
} }
}, },
watch: {
'form.id' () {
if (this.form === null) {
return
}
this.initFormStructure()
this.getSubmissionsData()
}
},
mounted () {
this.initFormStructure()
this.getSubmissionsData()
},
methods: { methods: {
initFormStructure() { initFormStructure () {
if (!this.form || this.formInitDone) { if (!this.form || !this.form.properties || this.formInitDone) {
return return
} }
@ -163,12 +187,12 @@ export default {
// Add a "created at" column // Add a "created at" column
const columns = clonedeep(this.form.properties) const columns = clonedeep(this.form.properties)
columns.push({ columns.push({
"name": "Created at", name: 'Created at',
"id": "created_at", id: 'created_at',
"type": "date", type: 'date',
"width": 140, width: 140
}) })
this.$set(this.form, 'properties', columns) this.form.properties = columns
} }
this.formInitDone = true this.formInitDone = true
@ -186,15 +210,14 @@ export default {
}) })
} }
}, },
getSubmissionsData() { getSubmissionsData () {
if (!this.form || this.fullyLoaded) { if (!this.form || this.fullyLoaded) {
return return
} }
this.isLoading = true this.isLoading = true
axios.get('/api/open/forms/' + this.form.id + '/submissions?page=' + this.currentPage).then((response) => { opnFetch('/open/forms/' + this.form.id + '/submissions?page=' + this.currentPage).then((resData) => {
const resData = response.data
this.tableData = this.tableData.concat(resData.data.map((record) => record.data)) this.tableData = this.tableData.concat(resData.data.map((record) => record.data))
this.dataChanged()
if (this.currentPage < resData.meta.last_page) { if (this.currentPage < resData.meta.last_page) {
this.currentPage += 1 this.currentPage += 1
@ -208,24 +231,24 @@ export default {
this.isLoading = false this.isLoading = false
}) })
}, },
dataChanged() { dataChanged () {
if (this.$refs.shadows) { if (this.$refs.shadows) {
this.$refs.shadows.toggleShadow() this.$refs.shadows.toggleShadow()
this.$refs.shadows.calcDimensions() this.$refs.shadows.calcDimensions()
} }
}, },
onChangeDisplayColumns() { onChangeDisplayColumns () {
if (process.client)
window.localStorage.setItem('display-columns-formid-' + this.form.id, JSON.stringify(this.displayColumns)) window.localStorage.setItem('display-columns-formid-' + this.form.id, JSON.stringify(this.displayColumns))
const final_properties = this.properties.concat(this.removed_properties).filter((field) => { this.form.properties = this.properties.concat(this.removed_properties).filter((field) => {
return this.displayColumns[field.id] === true return this.displayColumns[field.id] === true
}) })
this.$set(this.form, 'properties', final_properties)
}, },
onDeleteRecord() { onDeleteRecord () {
this.fullyLoaded = false this.fullyLoaded = false
this.tableData = [] this.tableData = []
this.getSubmissionsData() this.getSubmissionsData()
} }
}, }
} }
</script> </script>

View File

@ -0,0 +1,66 @@
<template>
<div
class="border border-nt-blue-light bg-blue-50 dark:bg-notion-dark-light shadow rounded-md p-4 mb-5 w-full mx-auto mt-4 select-all"
>
<div class="flex items-center">
<p class="select-all flex-grow break-all" v-html="preFillUrl" />
<div class="hover:bg-nt-blue-lighter rounded transition-colors cursor-pointer" @click="copyToClipboard">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-nt-blue" fill="none" viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"
/>
</svg>
</div>
</div>
</div>
</template>
<script setup>
import { defineProps, computed } from 'vue'
const { copy } = useClipboard()
const props = defineProps({
form: {
type: Object,
required: true
},
formData: {
type: Object,
required: true
},
extraQueryParam: {
type: String,
default: ''
}
})
const preFillUrl = computed(() => {
const url = props.form.share_url
const uriComponents = new URLSearchParams()
props.form.properties.filter((property) => {
return props.formData.hasOwnProperty(property.id) && props.formData[property.id] !== null
}).forEach((property) => {
if (Array.isArray(props.formData[property.id])) {
props.formData[property.id].forEach((value) => {
uriComponents.append(property.id + '[]', value)
})
} else {
uriComponents.append(property.id, props.formData[property.id])
}
})
if(uriComponents.toString() !== ""){
return (props.extraQueryParam) ? url + '?' + uriComponents + '&' + props.extraQueryParam : url + '?' + uriComponents
}else{
return (props.extraQueryParam) ? url + '?' + props.extraQueryParam : url
}
})
const copyToClipboard = () => {
if (process.server) return
copy(preFillUrl.value)
useAlert().success('Copied!')
}
</script>

View File

@ -1,13 +1,12 @@
<template> <template>
<div v-if="showSidebar" <div>
class="absolute shadow-lg shadow-blue-800/30 top-0 h-[calc(100vh-45px)] right-0 lg:shadow-none lg:relative bg-white w-full md:w-1/2 lg:w-2/5 border-l overflow-y-scroll md:max-w-[20rem] flex-shrink-0">
<div class="p-4 border-b sticky top-0 z-10 bg-white"> <div class="p-4 border-b sticky top-0 z-10 bg-white">
<div class="flex"> <div class="flex">
<button class="text-gray-500 hover:text-gray-900 cursor-pointer" @click.prevent="closeSidebar"> <button class="text-gray-500 hover:text-gray-900 cursor-pointer" @click.prevent="closeSidebar">
<svg class="h-6 w-6" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg class="h-6 w-6" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" <path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round"/> stroke-linejoin="round"
/>
</svg> </svg>
</button> </button>
<div class="font-semibold inline ml-2 truncate flex-grow truncate"> <div class="font-semibold inline ml-2 truncate flex-grow truncate">
@ -18,32 +17,42 @@
<div class="py-2 px-4"> <div class="py-2 px-4">
<div> <div>
<p class="text-gray-500 uppercase text-xs font-semibold mb-2">Input Blocks</p> <p class="text-gray-500 uppercase text-xs font-semibold mb-2">
Input Blocks
</p>
<div class="grid grid-cols-2 gap-2"> <div class="grid grid-cols-2 gap-2">
<div v-for="(block, i) in inputBlocks" :key="block.name" <div v-for="(block, i) in inputBlocks" :key="block.name"
class="bg-gray-50 border hover:bg-gray-100 dark:bg-gray-900 rounded-md dark:hover:bg-gray-800 py-2 flex flex-col" class="bg-gray-50 border hover:bg-gray-100 dark:bg-gray-900 rounded-md dark:hover:bg-gray-800 py-2 flex flex-col"
role="button" @click.prevent="addBlock(block.name)" role="button" @click.prevent="addBlock(block.name)"
> >
<div class="mx-auto"> <div class="mx-auto">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-gray-500" fill="none" viewBox="0 0 24 24" <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-gray-500" fill="none" viewBox="0 0 24 24"
stroke="currentColor" stroke-width="2" v-html="block.icon"></svg> stroke="currentColor" stroke-width="2" v-html="block.icon"
></svg>
</div> </div>
<p class="w-full text-xs text-gray-500 uppercase text-center font-semibold mt-1">{{ block.title }}</p> <p class="w-full text-xs text-gray-500 uppercase text-center font-semibold mt-1">
{{ block.title }}
</p>
</div> </div>
</div> </div>
</div> </div>
<div class="border-t mt-6"> <div class="border-t mt-6">
<p class="text-gray-500 uppercase text-xs font-semibold mb-2 mt-6">Layout Blocks</p> <p class="text-gray-500 uppercase text-xs font-semibold mb-2 mt-6">
Layout Blocks
</p>
<div class="grid grid-cols-2 gap-2"> <div class="grid grid-cols-2 gap-2">
<div v-for="(block, i) in layoutBlocks" :key="block.name" <div v-for="(block, i) in layoutBlocks" :key="block.name"
class="bg-gray-50 border hover:bg-gray-100 dark:bg-gray-900 rounded-md dark:hover:bg-gray-800 py-2 flex flex-col" class="bg-gray-50 border hover:bg-gray-100 dark:bg-gray-900 rounded-md dark:hover:bg-gray-800 py-2 flex flex-col"
role="button" @click.prevent="addBlock(block.name)" role="button" @click.prevent="addBlock(block.name)"
> >
<div class="mx-auto"> <div class="mx-auto">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-gray-500" fill="none" viewBox="0 0 24 24" <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-gray-500" fill="none" viewBox="0 0 24 24"
stroke="currentColor" stroke-width="2" v-html="block.icon"></svg> stroke="currentColor" stroke-width="2" v-html="block.icon"
></svg>
</div> </div>
<p class="w-full text-xs text-gray-500 uppercase text-center font-semibold mt-1">{{ block.title }}</p> <p class="w-full text-xs text-gray-500 uppercase text-center font-semibold mt-1">
{{ block.title }}
</p>
</div> </div>
</div> </div>
</div> </div>
@ -52,184 +61,178 @@
</template> </template>
<script> <script>
import {mapState} from 'vuex'
import Form from 'vform'
import clonedeep from 'clone-deep' import clonedeep from 'clone-deep'
import { computed } from 'vue'
export default { export default {
name: 'AddFormBlockSidebar', name: 'AddFormBlock',
components: {}, components: {},
props: {}, props: {},
data() {
setup () {
const workingFormStore = useWorkingFormStore()
const {content: form} = storeToRefs(workingFormStore)
return {
form,
workingFormStore,
selectedFieldIndex : computed(() => workingFormStore.selectedFieldIndex)
}
},
data () {
return { return {
blockForm: null, blockForm: null,
inputBlocks: [ inputBlocks: [
{ {
name: 'text', name: 'text',
title: 'Text Input', title: 'Text Input',
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h7"/>', icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h7"/>'
}, },
{ {
name: 'date', name: 'date',
title: 'Date Input', title: 'Date Input',
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>', icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>'
}, },
{ {
name: 'url', name: 'url',
title: 'URL Input', title: 'URL Input',
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/>', icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/>'
}, },
{ {
name: 'phone_number', name: 'phone_number',
title: 'Phone Input', title: 'Phone Input',
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"/>', icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"/>'
}, },
{ {
name: 'email', name: 'email',
title: 'Email Input', title: 'Email Input',
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207"/>', icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207"/>'
}, },
{ {
name: 'checkbox', name: 'checkbox',
title: 'Checkbox Input', title: 'Checkbox Input',
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>', icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>'
}, },
{ {
name: 'select', name: 'select',
title: 'Select Input', title: 'Select Input',
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M8 9l4-4 4 4m0 6l-4 4-4-4"/>', icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M8 9l4-4 4 4m0 6l-4 4-4-4"/>'
}, },
{ {
name: 'multi_select', name: 'multi_select',
title: 'Multi-select Input', title: 'Multi-select Input',
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M8 9l4-4 4 4m0 6l-4 4-4-4"/>', icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M8 9l4-4 4 4m0 6l-4 4-4-4"/>'
}, },
{ {
name: 'number', name: 'number',
title: 'Number Input', title: 'Number Input',
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"/>', icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"/>'
}, },
{ {
name: 'files', name: 'files',
title: 'File Input', title: 'File Input',
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />', icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />'
}, },
{ {
name: 'signature', name: 'signature',
title: 'Signature Input', title: 'Signature Input',
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487zm0 0L19.5 7.125" />', icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487zm0 0L19.5 7.125" />'
} }
], ],
layoutBlocks: [ layoutBlocks: [
{ {
name: 'nf-text', name: 'nf-text',
title: 'Text Block', title: 'Text Block',
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h8m-8 6h16" />', icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h8m-8 6h16" />'
}, },
{ {
name: 'nf-page-break', name: 'nf-page-break',
title: 'Page-break Block', title: 'Page-break Block',
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />', icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />'
}, },
{ {
name: 'nf-divider', name: 'nf-divider',
title: 'Divider Block', title: 'Divider Block',
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M20 12H4" />', icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M20 12H4" />'
}, },
{ {
name: 'nf-image', name: 'nf-image',
title: 'Image Block', title: 'Image Block',
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />', icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />'
}, },
{ {
name: 'nf-code', name: 'nf-code',
title: 'Code Block', title: 'Code Block',
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5" />', icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5" />'
} }
] ]
} }
}, },
computed: { computed: {
...mapState({ defaultBlockNames () {
selectedFieldIndex: state => state['open/working_form'].selectedFieldIndex,
showAddFieldSidebar: state => state['open/working_form'].showAddFieldSidebar
}),
form: {
get() {
return this.$store.state['open/working_form'].content
},
/* We add a setter */
set(value) {
this.$store.commit('open/working_form/set', value)
}
},
showSidebar() {
return (this.form && this.showAddFieldSidebar) ?? false
},
defaultBlockNames() {
return { return {
'text': 'Your name', text: 'Your name',
'date': 'Date', date: 'Date',
'url': 'Link', url: 'Link',
'phone_number': 'Phone Number', phone_number: 'Phone Number',
'number': 'Number', number: 'Number',
'email': 'Email', email: 'Email',
'checkbox': 'Checkbox', checkbox: 'Checkbox',
'select': 'Select', select: 'Select',
'multi_select': 'Multi Select', multi_select: 'Multi Select',
'files': 'Files', files: 'Files',
'signature': 'Signature', signature: 'Signature',
'nf-text': 'Text Block', 'nf-text': 'Text Block',
'nf-page-break': 'Page Break', 'nf-page-break': 'Page Break',
'nf-divider': 'Divider', 'nf-divider': 'Divider',
'nf-image': 'Image', 'nf-image': 'Image',
'nf-code': 'Code Block', 'nf-code': 'Code Block'
} }
} }
}, },
watch: {}, watch: {},
mounted() { mounted () {
this.reset() this.reset()
}, },
methods: { methods: {
closeSidebar() { closeSidebar () {
this.$store.commit('open/working_form/closeAddFieldSidebar') this.workingFormStore.closeAddFieldSidebar()
}, },
reset() { reset () {
this.blockForm = new Form({ this.blockForm = useForm({
type: null, type: null,
name: null name: null
}) })
}, },
addBlock(type) { addBlock (type) {
this.blockForm.type = type this.blockForm.type = type
this.blockForm.name = this.defaultBlockNames[type] this.blockForm.name = this.defaultBlockNames[type]
const newBlock = this.prefillDefault(this.blockForm.data()) const newBlock = this.prefillDefault(this.blockForm.data())
newBlock.id = this.generateUUID() newBlock.id = this.generateUUID()
newBlock.hidden = false newBlock.hidden = false
if (['select', 'multi_select'].includes(this.blockForm.type)) { if (['select', 'multi_select'].includes(this.blockForm.type)) {
newBlock[this.blockForm.type] = {'options': []} newBlock[this.blockForm.type] = { options: [] }
} }
newBlock.help_position = 'below_input' newBlock.help_position = 'below_input'
if(this.selectedFieldIndex === null || this.selectedFieldIndex === undefined){ if (this.selectedFieldIndex === null || this.selectedFieldIndex === undefined) {
const newFields = clonedeep(this.form.properties) const newFields = clonedeep(this.form.properties)
newFields.push(newBlock) newFields.push(newBlock)
this.$set(this.form, 'properties', newFields) this.form.properties = newFields
this.$store.commit('open/working_form/openSettingsForField', this.form.properties.length-1) this.workingFormStore.openSettingsForField(this.form.properties.length - 1)
} else { } else {
const newFields = clonedeep(this.form.properties) const newFields = clonedeep(this.form.properties)
newFields.splice(this.selectedFieldIndex+1, 0, newBlock) newFields.splice(this.selectedFieldIndex + 1, 0, newBlock)
this.$set(this.form, 'properties', newFields) this.form.properties = newFields
this.$store.commit('open/working_form/openSettingsForField', this.selectedFieldIndex+1) this.workingFormStore.openSettingsForField(this.selectedFieldIndex + 1)
} }
this.reset() this.reset()
}, },
generateUUID() { generateUUID () {
let d = new Date().getTime()// Timestamp let d = new Date().getTime()// Timestamp
let d2 = ((typeof performance !== 'undefined') && performance.now && (performance.now() * 1000)) || 0// Time in microseconds since page-load or 0 if unsupported let d2 = ((typeof performance !== 'undefined') && performance.now && (performance.now() * 1000)) || 0// Time in microseconds since page-load or 0 if unsupported
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
@ -244,7 +247,7 @@ export default {
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16) return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16)
}) })
}, },
prefillDefault(data) { prefillDefault (data) {
if (data.type === 'nf-text') { if (data.type === 'nf-text') {
data.content = '<p>This is a text block.</p>' data.content = '<p>This is a text block.</p>'
} else if (data.type === 'nf-page-break') { } else if (data.type === 'nf-page-break') {

View File

@ -15,7 +15,7 @@
/> />
<toggle-switch-input name="editable_submissions" :form="form" class="mt-4" <toggle-switch-input name="editable_submissions" :form="form" class="mt-4"
help="Gives user a unique url to update their submission" help="Gives user a unique url to update their submission"
> >
<template #label> <template #label>
Editable submissions Editable submissions
@ -29,21 +29,21 @@
/> />
<flat-select-input :form="submissionOptions" name="databaseAction" label="Database Submission Action" <flat-select-input :form="submissionOptions" name="databaseAction" label="Database Submission Action"
:options="[ :options="[
{name:'Create new record (default)', value:'create'}, {name:'Create new record (default)', value:'create'},
{name:'Update Record (or create if no match)', value:'update'} {name:'Update Record (or create if no match)', value:'update'}
]" :required="true" help="Create a new record or update an existing one" ]" :required="true" help="Create a new record or update an existing one"
> >
<template #selected="{option,optionName}"> <template #selected="{option,optionName}">
<div class="flex items-center truncate mr-6"> <div class="flex items-center truncate mr-6">
{{ optionName }} {{ optionName }}
<pro-tag v-if="option === 'update'" class="ml-2"/> <pro-tag v-if="option === 'update'" class="ml-2" />
</div> </div>
</template> </template>
<template #option="{option, selected}"> <template #option="{option, selected}">
<span class="flex hover:text-white"> <span class="flex hover:text-white">
<p class="flex-grow hover:text-white"> <p class="flex-grow hover:text-white">
{{ option.name }} <template v-if="option.value === 'update'"><pro-tag/></template> {{ option.name }} <template v-if="option.value === 'update'"><pro-tag /></template>
</p> </p>
<span v-if="selected" class="absolute inset-y-0 right-0 flex items-center pr-4 dark:text-white"> <span v-if="selected" class="absolute inset-y-0 right-0 flex items-center pr-4 dark:text-white">
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"> <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
@ -67,7 +67,8 @@
<small>If the submission has the same value(s) as a previous one for the selected <small>If the submission has the same value(s) as a previous one for the selected
column(s), we will update it, instead of creating a new one. column(s), we will update it, instead of creating a new one.
<a href="#" <a href="#"
@click.prevent="$crisp.push(['do', 'helpdesk:article:open', ['en', 'how-to-update-a-page-on-form-submission-1t1jwmn']])">More @click.prevent="$crisp.push(['do', 'helpdesk:article:open', ['en', 'how-to-update-a-page-on-form-submission-1t1jwmn']])"
>More
info here.</a> info here.</a>
</small> </small>
</div> </div>
@ -83,13 +84,13 @@
<template #selected="{option,optionName}"> <template #selected="{option,optionName}">
<div class="flex items-center truncate mr-6"> <div class="flex items-center truncate mr-6">
{{ optionName }} {{ optionName }}
<pro-tag v-if="option === 'redirect'" class="ml-2"/> <pro-tag v-if="option === 'redirect'" class="ml-2" />
</div> </div>
</template> </template>
<template #option="{option, selected}"> <template #option="{option, selected}">
<span class="flex hover:text-white"> <span class="flex hover:text-white">
<p class="flex-grow hover:text-white"> <p class="flex-grow hover:text-white">
{{ option.name }} <template v-if="option.value === 'redirect'"><pro-tag/></template> {{ option.name }} <template v-if="option.value === 'redirect'"><pro-tag /></template>
</p> </p>
<span v-if="selected" class="absolute inset-y-0 right-0 flex items-center pr-4"> <span v-if="selected" class="absolute inset-y-0 right-0 flex items-center pr-4">
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"> <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
@ -111,7 +112,7 @@
</template> </template>
<template v-else> <template v-else>
<toggle-switch-input name="re_fillable" :form="form" class="mt-4" <toggle-switch-input name="re_fillable" :form="form" class="mt-4"
label="Allow users to fill the form again" label="Allow users to fill the form again"
/> />
<text-input v-if="form.re_fillable" name="re_fill_button_text" <text-input v-if="form.re_fillable" name="re_fill_button_text"
:form="form" :form="form"
@ -128,14 +129,23 @@
</template> </template>
<script> <script>
import { useWorkingFormStore } from '../../../../../stores/working_form'
import EditorOptionsPanel from '../../../editors/EditorOptionsPanel.vue' import EditorOptionsPanel from '../../../editors/EditorOptionsPanel.vue'
import ProTag from '../../../../common/ProTag.vue' import ProTag from '~/components/global/ProTag.vue'
import VTransition from '../../../../common/transitions/VTransition.vue' import VTransition from '~/components/global/transitions/VTransition.vue'
export default { export default {
components: {EditorOptionsPanel, ProTag, VTransition}, components: {EditorOptionsPanel, ProTag, VTransition},
props: {}, props: {},
data() { setup () {
const workingFormStore = useWorkingFormStore()
const {content: form} = storeToRefs(workingFormStore)
return {
form,
workingFormStore
}
},
data () {
return { return {
submissionOptions: {} submissionOptions: {}
} }
@ -143,22 +153,22 @@ export default {
computed: { computed: {
form: { form: {
get() { get () {
return this.$store.state['open/working_form'].content return this.workingFormStore.content
}, },
/* We add a setter */ /* We add a setter */
set(value) { set (value) {
this.$store.commit('open/working_form/set', value) this.workingFormStore.set(value)
} }
}, },
/** /**
* Used for the update record on submission. Lists all visible fields on which you can filter records to update * Used for the update record on submission. Lists all visible fields on which you can filter records to update
* on submission instead of creating * on submission instead of creating
*/ */
filterableFields() { filterableFields () {
if (this.submissionOptions.databaseAction !== 'update') return [] if (this.submissionOptions.databaseAction !== 'update') return []
return this.form.properties.filter((field) => { return this.form.properties.filter((field) => {
return !field.hidden && !['files','signature','multi_select'].includes(field.type) return !field.hidden && !['files', 'signature', 'multi_select'].includes(field.type)
}).map((field) => { }).map((field) => {
return { return {
name: field.name, name: field.name,
@ -170,7 +180,7 @@ export default {
watch: { watch: {
form: { form: {
handler() { handler () {
if (this.form) { if (this.form) {
this.submissionOptions = { this.submissionOptions = {
submissionMode: this.form.redirect_url ? 'redirect' : 'default', submissionMode: this.form.redirect_url ? 'redirect' : 'default',
@ -184,14 +194,14 @@ export default {
deep: true, deep: true,
handler: function (val) { handler: function (val) {
if (val.submissionMode === 'default') { if (val.submissionMode === 'default') {
this.$set(this.form, 'redirect_url', null) this.form.redirect_url = null
} }
if (val.databaseAction === 'create') { if (val.databaseAction === 'create') {
this.$set(this.form, 'database_fields_update', null) this.form.database_fields_update = null
} }
} }
} }
}, }
} }
</script> </script>

View File

@ -38,11 +38,18 @@
</template> </template>
<script> <script>
import { useWorkingFormStore } from '../../../../../stores/working_form'
import EditorOptionsPanel from '../../../editors/EditorOptionsPanel.vue' import EditorOptionsPanel from '../../../editors/EditorOptionsPanel.vue'
export default { export default {
components: { EditorOptionsPanel }, components: { EditorOptionsPanel },
props: {}, props: {},
setup () {
const workingFormStore = useWorkingFormStore()
return {
workingFormStore
}
},
data () { data () {
return { return {
} }
@ -50,11 +57,11 @@ export default {
computed: { computed: {
form: { form: {
get () { get () {
return this.$store.state['open/working_form'].content return this.workingFormStore.content
}, },
/* We add a setter */ /* We add a setter */
set (value) { set (value) {
this.$store.commit('open/working_form/set', value) this.workingFormStore.set(value)
} }
} }
}, },

View File

@ -0,0 +1,51 @@
<template>
<editor-options-panel name="Custom Code" :already-opened="false" :has-pro-tag="true">
<template #icon>
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M14 2.26953V6.40007C14 6.96012 14 7.24015 14.109 7.45406C14.2049 7.64222 14.3578 7.7952 14.546 7.89108C14.7599 8.00007 15.0399 8.00007 15.6 8.00007H19.7305M14 17.5L16.5 15L14 12.5M10 12.5L7.5 15L10 17.5M20 9.98822V17.2C20 18.8802 20 19.7202 19.673 20.362C19.3854 20.9265 18.9265 21.3854 18.362 21.673C17.7202 22 16.8802 22 15.2 22H8.8C7.11984 22 6.27976 22 5.63803 21.673C5.07354 21.3854 4.6146 20.9265 4.32698 20.362C4 19.7202 4 18.8802 4 17.2V6.8C4 5.11984 4 4.27976 4.32698 3.63803C4.6146 3.07354 5.07354 2.6146 5.63803 2.32698C6.27976 2 7.11984 2 8.8 2H12.0118C12.7455 2 13.1124 2 13.4577 2.08289C13.7638 2.15638 14.0564 2.27759 14.3249 2.44208C14.6276 2.6276 14.887 2.88703 15.4059 3.40589L18.5941 6.59411C19.113 7.11297 19.3724 7.3724 19.5579 7.67515C19.7224 7.94356 19.8436 8.2362 19.9171 8.5423C20 8.88757 20 9.25445 20 9.98822Z"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</template>
<p class="mt-4">
The code will be injected in the <span class="font-semibold">head</span> section of your form page.
</p>
<code-input name="custom_code" class="mt-4"
:form="form" help="Custom code cannot be previewed in our editor. Please test your code using
your actual form page (save changes beforehand)."
label="Custom Code"
/>
</editor-options-panel>
</template>
<script>
import EditorOptionsPanel from '../../../editors/EditorOptionsPanel.vue'
export default {
components: {EditorOptionsPanel},
props: {},
setup() {
const workingFormStore = useWorkingFormStore()
const {content: form} = storeToRefs(workingFormStore)
return {
form,
workingFormStore
}
},
data() {
return {}
},
computed: {
form: {
get() {
return this.workingFormStore.content
},
/* We add a setter */
set(value) {
this.workingFormStore.set(value)
}
}
},
}
</script>

View File

@ -26,11 +26,18 @@
</template> </template>
<script> <script>
import { useWorkingFormStore } from '../../../../../stores/working_form'
import EditorOptionsPanel from '../../../editors/EditorOptionsPanel.vue' import EditorOptionsPanel from '../../../editors/EditorOptionsPanel.vue'
export default { export default {
components: { EditorOptionsPanel }, components: { EditorOptionsPanel },
props: {}, props: {},
setup () {
const workingFormStore = useWorkingFormStore()
return {
workingFormStore
}
},
data () { data () {
return { return {
} }
@ -38,20 +45,17 @@ export default {
computed: { computed: {
form: { form: {
get () { get () {
return this.$store.state['open/working_form'].content return this.workingFormStore.content
}, },
/* We add a setter */ /* We add a setter */
set (value) { set (value) {
this.$store.commit('open/working_form/set', value) this.workingFormStore.set(value)
} }
}, },
workspace () { workspace () {
return this.$store.getters['open/workspaces/getCurrent']() return this.$store.getters['open/workspaces/getCurrent']()
}, },
customDomainOptions () { customDomainOptions () {
if (!this.workspace || !this.workspace.custom_domains) {
return []
}
return this.workspace.custom_domains.map((domain) => { return this.workspace.custom_domains.map((domain) => {
return { return {
name: domain, name: domain,
@ -60,7 +64,7 @@ export default {
}) })
}, },
customDomainAllowed () { customDomainAllowed () {
return window.config.custom_domains_enabled return this.$config.custom_domains_enabled
} }
}, },
watch: {}, watch: {},

View File

@ -19,7 +19,7 @@
/> />
<div class="-mt-3 mb-3 text-gray-400 dark:text-gray-500"> <div class="-mt-3 mb-3 text-gray-400 dark:text-gray-500">
<small> <small>
Need another theme? <a href="#" @click.prevent="openChat">Send us some suggestions!</a> Need another theme? <a href="#" @click.prevent="crisp.openAndShowChat">Send us some suggestions!</a>
</small> </small>
</div> </div>
@ -55,7 +55,7 @@
label="Color (for buttons & inputs border)" label="Color (for buttons & inputs border)"
/> />
<toggle-switch-input name="hide_title" :form="form" class="mt-4" <toggle-switch-input name="hide_title" :form="form" class="mt-4"
label="Hide Title" label="Hide Title"
/> />
<toggle-switch-input name="no_branding" :form="form" class="mt-4"> <toggle-switch-input name="no_branding" :form="form" class="mt-4">
<template #label> <template #label>
@ -64,14 +64,14 @@
</template> </template>
</toggle-switch-input> </toggle-switch-input>
<toggle-switch-input name="uppercase_labels" :form="form" class="mt-4" <toggle-switch-input name="uppercase_labels" :form="form" class="mt-4"
label="Uppercase Input Labels" label="Uppercase Input Labels"
/> />
<toggle-switch-input name="transparent_background" :form="form" class="mt-4" <toggle-switch-input name="transparent_background" :form="form" class="mt-4"
label="Transparent Background" help="Only applies when form is embedded" label="Transparent Background" help="Only applies when form is embedded"
/> />
<toggle-switch-input name="confetti_on_submission" :form="form" class="mt-4" <toggle-switch-input name="confetti_on_submission" :form="form" class="mt-4"
label="Confetti on successful submission" label="Confetti on successful submisison"
@input="onChangeConfettiOnSubmission" @update:model-value="onChangeConfettiOnSubmission"
/> />
<toggle-switch-input name="auto_save" :form="form" <toggle-switch-input name="auto_save" :form="form"
label="Auto save form response" label="Auto save form response"
@ -80,49 +80,25 @@
</editor-options-panel> </editor-options-panel>
</template> </template>
<script> <script setup>
import { useWorkingFormStore } from '../../../../../stores/working_form'
import EditorOptionsPanel from '../../../editors/EditorOptionsPanel.vue' import EditorOptionsPanel from '../../../editors/EditorOptionsPanel.vue'
import ProTag from '../../../../common/ProTag.vue' import ProTag from '~/components/global/ProTag.vue'
export default { const workingFormStore = useWorkingFormStore()
components: { EditorOptionsPanel, ProTag }, const form = storeToRefs(workingFormStore).content
props: { const isMounted = ref(false)
}, const crisp = useCrisp()
data () { const confetti = useConfetti()
return {
isMounted: false
}
},
computed: { onMounted(() => {
form: { isMounted.value = true
get () { })
return this.$store.state['open/working_form'].content
},
/* We add a setter */
set (value) {
this.$store.commit('open/working_form/set', value)
}
}
},
watch: {}, const onChangeConfettiOnSubmission = (val) => {
form.confetti_on_submission = val
mounted() { if (isMounted.value && val) {
this.isMounted = true confetti.play()
},
methods: {
onChangeConfettiOnSubmission(val) {
this.$set(this.form, 'confetti_on_submission', val)
if(this.isMounted && val){
this.playConfetti()
}
},
openChat () {
window.$crisp.push(['do', 'chat:show'])
window.$crisp.push(['do', 'chat:open'])
},
} }
} }
</script> </script>

View File

@ -5,10 +5,10 @@
> >
<div class="border rounded-lg bg-white dark:bg-notion-dark w-full block transition-all max-w-5xl"> <div class="border rounded-lg bg-white dark:bg-notion-dark w-full block transition-all max-w-5xl">
<transition enter-active-class="linear duration-100 overflow-hidden" <transition enter-active-class="linear duration-100 overflow-hidden"
enter-class="max-h-0" enter-from-class="max-h-0"
enter-to-class="max-h-56" enter-to-class="max-h-56"
leave-active-class="linear duration-100 overflow-hidden" leave-active-class="linear duration-100 overflow-hidden"
leave-class="max-h-56" leave-from-class="max-h-56"
leave-to-class="max-h-0" leave-to-class="max-h-0"
> >
<div v-if="(form.logo_picture || form.cover_picture)"> <div v-if="(form.logo_picture || form.cover_picture)">
@ -16,7 +16,7 @@
<div id="cover-picture" <div id="cover-picture"
class="max-h-56 rounded-t-lg w-full overflow-hidden flex items-center justify-center" class="max-h-56 rounded-t-lg w-full overflow-hidden flex items-center justify-center"
> >
<img alt="Cover Picture" :src="coverPictureSrc(form.cover_picture)" class="w-full"> <img alt="Cover Picture" :src="coverPictureSrc(form.cover_picture)" class="w-full"/>
</div> </div>
</div> </div>
<div v-if="form.logo_picture" class="w-full mx-auto p-5 relative" <div v-if="form.logo_picture" class="w-full mx-auto p-5 relative"
@ -25,16 +25,16 @@
<img alt="Logo Picture" :src="coverPictureSrc(form.logo_picture)" <img alt="Logo Picture" :src="coverPictureSrc(form.logo_picture)"
:class="{'top-5':!form.cover_picture, '-top-10':form.cover_picture}" :class="{'top-5':!form.cover_picture, '-top-10':form.cover_picture}"
class="w-20 h-20 object-contain absolute left-5 transition-all" class="w-20 h-20 object-contain absolute left-5 transition-all"
> />
</div> </div>
</div> </div>
</transition> </transition>
<open-complete-form ref="form-preview" class="w-full mx-auto py-5 px-3" :class="{'max-w-lg': form && (form.width === 'centered')}" <open-complete-form ref="form-preview" class="w-full mx-auto py-5 px-3" :class="{'max-w-lg': form && (form.width === 'centered')}"
:creating="creating" :creating="creating"
:form="form" :form="form"
:admin-preview="true" :admin-preview="true"
@restarted="previewFormSubmitted=false" @restarted="previewFormSubmitted=false"
@submitted="previewFormSubmitted=true" @submitted="previewFormSubmitted=true"
/> />
</div> </div>
<p class="text-center text-xs text-gray-400 dark:text-gray-600 mt-1"> <p class="text-center text-xs text-gray-400 dark:text-gray-600 mt-1">
@ -59,27 +59,33 @@
</template> </template>
<script> <script>
import { useWorkingFormStore } from '../../../../../stores/working_form'
import VSwitch from '../../../../forms/components/VSwitch.vue' import VSwitch from '../../../../forms/components/VSwitch.vue'
import OpenCompleteForm from '../../OpenCompleteForm.vue' import OpenCompleteForm from '../../OpenCompleteForm.vue'
export default { export default {
components: { OpenCompleteForm, VSwitch }, components: { OpenCompleteForm, VSwitch },
props: { props: {},
setup () {
const workingFormStore = useWorkingFormStore()
return {
workingFormStore
}
}, },
data () { data () {
return { return {
previewFormSubmitted: false, previewFormSubmitted: false
} }
}, },
computed: { computed: {
form: { form: {
get () { get () {
return this.$store.state['open/working_form'].content return this.workingFormStore.content
}, },
/* We add a setter */ /* We add a setter */
set (value) { set (value) {
this.$store.commit('open/working_form/set', value) this.workingFormStore.set(value)
} }
}, },
creating () { // returns true if we are creating a form creating () { // returns true if we are creating a form

View File

@ -0,0 +1,49 @@
<template>
<editor-right-sidebar :show="form && (showEditFieldSidebar || showAddFieldSidebar)">
<transition mode="out-in">
<form-field-edit v-if="showEditFieldSidebar" :key="editFieldIndex" v-motion-fade="'fade'" />
<add-form-block v-else-if="showAddFieldSidebar" v-motion-fade="'fade'" />
</transition>
</editor-right-sidebar>
</template>
<script>
import { computed } from 'vue'
import { useWorkingFormStore } from '../../../../../stores/working_form'
import EditorRightSidebar from '../../../editors/EditorRightSidebar.vue'
import FormFieldEdit from '../../fields/FormFieldEdit.vue'
import AddFormBlock from './AddFormBlock.vue'
export default {
name: 'FormEditorSidebar',
components: { EditorRightSidebar, AddFormBlock, FormFieldEdit },
props: {},
setup () {
const workingFormStore = useWorkingFormStore()
return {
workingFormStore,
editFieldIndex: computed(() => workingFormStore.selectedFieldIndex),
showEditFieldSidebar: computed(() => workingFormStore.showEditFieldSidebar),
showAddFieldSidebar: computed(() => workingFormStore.showAddFieldSidebar)
}
},
data () {
return {}
},
computed: {
form: {
get () {
return this.workingFormStore.content
},
/* We add a setter */
set (value) {
this.workingFormStore.set(value)
}
}
},
watch: {},
mounted () {
},
methods: {}
}
</script>

View File

@ -5,10 +5,10 @@
Error saving your form Error saving your form
</h2> </h2>
<div v-if="validationErrorResponse" class="p-4 border-b border-t"> <div v-if="form.errors" class="p-4 border-b border-t">
<p v-if="validationErrorResponse.message" v-text="validationErrorResponse.message" /> <p v-if="form.errors.message" v-text="form.errors.message" />
<ul class="list-disc list-inside"> <ul class="list-disc list-inside">
<li v-for="err, key in validationErrorResponse.errors" :key="key"> <li v-for="err, key in form.errors.errors" :key="key">
{{ Array.isArray(err)?err[0]:err }} {{ Array.isArray(err)?err[0]:err }}
</li> </li>
</ul> </ul>
@ -29,7 +29,7 @@ export default {
components: {}, components: {},
props: { props: {
show: { type: Boolean, required: true }, show: { type: Boolean, required: true },
validationErrorResponse: { type: Object, required: false } form: { type: Object, required: false }
}, },
data: () => ({}), data: () => ({}),

View File

@ -15,27 +15,27 @@
label="Description" label="Description"
:required="false" :required="false"
/> />
<select-input name="tags" label="Tags" :form="form" class="mt-3 mb-6" <select-input name="tags" label="Tags" :form="form" class="mt-4"
help="To organize your forms (hidden to respondents)" help="To organize your forms (hidden to respondents)"
placeholder="Select Tag(s)" :multiple="true" :allowCreation="true" placeholder="Select Tag(s)" :multiple="true" :allow-creation="true"
:options="allTagsOptions" :options="allTagsOptions"
/> />
<select-input name="visibility" label="Visibility" :form="form" class="mt-3 mb-6" <select-input name="visibility" label="Visibility" :form="form" class="mt-4"
help="Only public form will be accessible" help="Only public form will be accessible"
placeholder="Select Visibility" :required="true" placeholder="Select Visibility" :required="true"
:options="visibilityOptions" :options="visibilityOptions"
/> />
<v-button color="light-gray" class="w-full" v-if="copyFormOptions.length > 0" @click="showCopyFormSettingsModal=true"> <v-button v-if="copyFormOptions.length > 0" color="light-gray" class="w-full mt-4" @click="showCopyFormSettingsModal=true">
<svg class="h-5 w-5 -mt-1 text-nt-blue inline mr-2" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg class="h-5 w-5 -mt-1 text-nt-blue inline mr-2" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.16667 12.4998C3.3901 12.4998 3.00182 12.4998 2.69553 12.373C2.28715 12.2038 1.9627 11.8794 1.79354 11.471C1.66667 11.1647 1.66667 10.7764 1.66667 9.99984V4.33317C1.66667 3.39975 1.66667 2.93304 1.84833 2.57652C2.00812 2.26292 2.26308 2.00795 2.57669 1.84816C2.93321 1.6665 3.39992 1.6665 4.33334 1.6665H10C10.7766 1.6665 11.1649 1.6665 11.4711 1.79337C11.8795 1.96253 12.204 2.28698 12.3731 2.69536C12.5 3.00165 12.5 3.38993 12.5 4.1665M10.1667 18.3332H15.6667C16.6001 18.3332 17.0668 18.3332 17.4233 18.1515C17.7369 17.9917 17.9919 17.7368 18.1517 17.4232C18.3333 17.0666 18.3333 16.5999 18.3333 15.6665V10.1665C18.3333 9.23308 18.3333 8.76637 18.1517 8.40985C17.9919 8.09625 17.7369 7.84128 17.4233 7.68149C17.0668 7.49984 16.6001 7.49984 15.6667 7.49984H10.1667C9.23325 7.49984 8.76654 7.49984 8.41002 7.68149C8.09642 7.84128 7.84145 8.09625 7.68166 8.40985C7.50001 8.76637 7.50001 9.23308 7.50001 10.1665V15.6665C7.50001 16.5999 7.50001 17.0666 7.68166 17.4232C7.84145 17.7368 8.09642 17.9917 8.41002 18.1515C8.76654 18.3332 9.23325 18.3332 10.1667 18.3332Z" stroke="currentColor" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/> <path d="M4.16667 12.4998C3.3901 12.4998 3.00182 12.4998 2.69553 12.373C2.28715 12.2038 1.9627 11.8794 1.79354 11.471C1.66667 11.1647 1.66667 10.7764 1.66667 9.99984V4.33317C1.66667 3.39975 1.66667 2.93304 1.84833 2.57652C2.00812 2.26292 2.26308 2.00795 2.57669 1.84816C2.93321 1.6665 3.39992 1.6665 4.33334 1.6665H10C10.7766 1.6665 11.1649 1.6665 11.4711 1.79337C11.8795 1.96253 12.204 2.28698 12.3731 2.69536C12.5 3.00165 12.5 3.38993 12.5 4.1665M10.1667 18.3332H15.6667C16.6001 18.3332 17.0668 18.3332 17.4233 18.1515C17.7369 17.9917 17.9919 17.7368 18.1517 17.4232C18.3333 17.0666 18.3333 16.5999 18.3333 15.6665V10.1665C18.3333 9.23308 18.3333 8.76637 18.1517 8.40985C17.9919 8.09625 17.7369 7.84128 17.4233 7.68149C17.0668 7.49984 16.6001 7.49984 15.6667 7.49984H10.1667C9.23325 7.49984 8.76654 7.49984 8.41002 7.68149C8.09642 7.84128 7.84145 8.09625 7.68166 8.40985C7.50001 8.76637 7.50001 9.23308 7.50001 10.1665V15.6665C7.50001 16.5999 7.50001 17.0666 7.68166 17.4232C7.84145 17.7368 8.09642 17.9917 8.41002 18.1515C8.76654 18.3332 9.23325 18.3332 10.1667 18.3332Z" stroke="currentColor" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round" />
</svg> </svg>
Copy another form's settings Copy another form's settings
</v-button> </v-button>
<modal :show="showCopyFormSettingsModal" @close="showCopyFormSettingsModal=false" max-width="md"> <modal :show="showCopyFormSettingsModal" max-width="md" @close="showCopyFormSettingsModal=false">
<template #icon> <template #icon>
<svg class="w-10 h-10 text-blue" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg class="w-10 h-10 text-blue" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17 27C16.0681 27 15.6022 27 15.2346 26.8478C14.7446 26.6448 14.3552 26.2554 14.1522 25.7654C14 25.3978 14 24.9319 14 24V17.2C14 16.0799 14 15.5198 14.218 15.092C14.4097 14.7157 14.7157 14.4097 15.092 14.218C15.5198 14 16.0799 14 17.2 14H24C24.9319 14 25.3978 14 25.7654 14.1522C26.2554 14.3552 26.6448 14.7446 26.8478 15.2346C27 15.6022 27 16.0681 27 17M24.2 34H30.8C31.9201 34 32.4802 34 32.908 33.782C33.2843 33.5903 33.5903 33.2843 33.782 32.908C34 32.4802 34 31.9201 34 30.8V24.2C34 23.0799 34 22.5198 33.782 22.092C33.5903 21.7157 33.2843 21.4097 32.908 21.218C32.4802 21 31.9201 21 30.8 21H24.2C23.0799 21 22.5198 21 22.092 21.218C21.7157 21.4097 21.4097 21.7157 21.218 22.092C21 22.5198 21 23.0799 21 24.2V30.8C21 31.9201 21 32.4802 21.218 32.908C21.4097 33.2843 21.7157 33.5903 22.092 33.782C22.5198 34 23.0799 34 24.2 34Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> <path d="M17 27C16.0681 27 15.6022 27 15.2346 26.8478C14.7446 26.6448 14.3552 26.2554 14.1522 25.7654C14 25.3978 14 24.9319 14 24V17.2C14 16.0799 14 15.5198 14.218 15.092C14.4097 14.7157 14.7157 14.4097 15.092 14.218C15.5198 14 16.0799 14 17.2 14H24C24.9319 14 25.3978 14 25.7654 14.1522C26.2554 14.3552 26.6448 14.7446 26.8478 15.2346C27 15.6022 27 16.0681 27 17M24.2 34H30.8C31.9201 34 32.4802 34 32.908 33.782C33.2843 33.5903 33.5903 33.2843 33.782 32.908C34 32.4802 34 31.9201 34 30.8V24.2C34 23.0799 34 22.5198 33.782 22.092C33.5903 21.7157 33.2843 21.4097 32.908 21.218C32.4802 21 31.9201 21 30.8 21H24.2C23.0799 21 22.5198 21 22.092 21.218C21.7157 21.4097 21.4097 21.7157 21.218 22.092C21 22.5198 21 23.0799 21 24.2V30.8C21 31.9201 21 32.4802 21.218 32.908C21.4097 33.2843 21.7157 33.5903 22.092 33.782C22.5198 34 23.0799 34 24.2 34Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg> </svg>
</template> </template>
<template #title> <template #title>
@ -66,14 +66,26 @@
</template> </template>
<script> <script>
import { computed } from 'vue'
import clonedeep from 'clone-deep'
import EditorOptionsPanel from '../../../editors/EditorOptionsPanel.vue' import EditorOptionsPanel from '../../../editors/EditorOptionsPanel.vue'
import SelectInput from '../../../../forms/SelectInput.vue' import SelectInput from '../../../../forms/SelectInput.vue'
import { mapState } from 'vuex'
import clonedeep from 'clone-deep'
export default { export default {
components: { SelectInput, EditorOptionsPanel }, components: { SelectInput, EditorOptionsPanel },
props: {}, props: {},
setup () {
const formsStore = useFormsStore()
const workingFormStore = useWorkingFormStore()
const {getAll: forms} = storeToRefs(formsStore)
return {
forms,
formsStore,
workingFormStore,
}
},
data () { data () {
return { return {
showCopyFormSettingsModal: false, showCopyFormSettingsModal: false,
@ -106,20 +118,17 @@ export default {
} }
}) })
}, },
...mapState({
forms: state => state['open/forms'].content
}),
form: { form: {
get () { get () {
return this.$store.state['open/working_form'].content return this.workingFormStore.content
}, },
/* We add a setter */ /* We add a setter */
set (value) { set (value) {
this.$store.commit('open/working_form/set', value) this.workingFormStore.set(value)
} }
}, },
allTagsOptions () { allTagsOptions () {
return this.$store.getters['open/forms/getAllTags'].map((tagname) => { return this.formsStore.allTags.map((tagname) => {
return { return {
name: tagname, name: tagname,
value: tagname value: tagname

View File

@ -37,33 +37,25 @@ import FormNotificationsSlack from './components/FormNotificationsSlack.vue'
import FormNotificationsDiscord from './components/FormNotificationsDiscord.vue' import FormNotificationsDiscord from './components/FormNotificationsDiscord.vue'
import FormNotificationsSubmissionConfirmation from './components/FormNotificationsSubmissionConfirmation.vue' import FormNotificationsSubmissionConfirmation from './components/FormNotificationsSubmissionConfirmation.vue'
import FormNotificationsWebhook from './components/FormNotificationsWebhook.vue' import FormNotificationsWebhook from './components/FormNotificationsWebhook.vue'
import opnformConfig from "~/opnform.config.js";
export default { export default {
components: { FormNotificationsSubmissionConfirmation, FormNotificationsSlack, FormNotificationsDiscord, FormNotificationsOption, EditorOptionsPanel, FormNotificationsWebhook }, components: { FormNotificationsSubmissionConfirmation, FormNotificationsSlack, FormNotificationsDiscord, FormNotificationsOption, EditorOptionsPanel, FormNotificationsWebhook },
props: { props: {},
}, setup () {
data () { const workingFormStore = useWorkingFormStore()
const {content: form} = storeToRefs(workingFormStore)
return { return {
workingFormStore,
form,
opnformConfig
} }
}, },
computed: { computed: {
form: { zapierUrl () {
get () { opnformConfig.links.zapier_integration
return this.$store.state['open/working_form'].content }
},
/* We add a setter */
set (value) {
this.$store.commit('open/working_form/set', value)
}
},
zapierUrl: () => window.config.links.zapier_integration
},
watch: {
},
mounted () {
}, },
} }
</script> </script>

Some files were not shown because too many files have changed in this diff Show More