Notification & Integrations refactoring (#346)

* Integrations Refactoring - WIP

* integrations list & edit - WIP

* Fix integration store binding issue

* integrations refactor - WIP

* Form integration - WIP

* Form integration Edit - WIP

* Integration Refactor - Slack - WIP

* Integration Refactor - Discord - WIP

* Integration Refactor - Webhook - WIP

* Integration Refactor - Send Submission Confirmation - WIP

* Integration Refactor - Backend handler - WIP

* Form Integration Status field

* Integration Refactor - Backend SubmissionConfirmation - WIP

* IntegrationMigration Command

* skip confirmation email test case

* Small refactoring

* FormIntegration status active/inactive

* formIntegrationData to integrationData

* Rename file name with Integration suffix for integration realted files

* Loader on form integrations

* WIP

* form integration test case

* WIP

* Added Integration card - working on refactoring

* change location for IntegrationCard and update package file

* Form Integration Create/Edit in single Modal

* Remove integration extra pages

* crisp_help_page_slug for integration json

* integration logic as collapse

* UI improvements

* WIP

* Trying to debug vue devtools

* WIP for integrations

* getIntegrationHandler change namespace name

* useForm for integration fields + validation structure

* Integration Test case & apply validation rules

* Apply useform changes to integration other files

* validation rules for FormNotificationsMessageActions fields

* Zapier integration as coming soon

* Update FormCleaner

* set default settings for confirmation integration

* WIP

* Finish validation for all integrations

* Updated purify, added integration formatData

* Fix testcase

* Ran pint; working on integration errors

* Handle integration events

* command for Delete Old Integration Events

* Display Past Events in Modal

* on Integration event create with status error send email to form creator

* Polish styling

* Minor improvements

* Finish badge and integration status

* Fix tests and add an integration event test

* Lint

---------

Co-authored-by: Julien Nahum <julien@nahum.net>
This commit is contained in:
formsdev 2024-03-28 22:44:30 +05:30 committed by GitHub
parent d9996e0d9d
commit 6f61faa9ef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
84 changed files with 6121 additions and 2205 deletions

View File

@ -1,6 +1,7 @@
<?php
// @formatter:off
// phpcs:ignoreFile
/**
* A helper file for your Eloquent Models
* Copy the phpDocs from this file to the correct Model,
@ -10,6 +11,57 @@
*/
namespace App\Models\Billing{
/**
* App\Models\Billing\Subscription
*
* @property int $id
* @property int $user_id
* @property string $name
* @property string $stripe_id
* @property string $stripe_status
* @property string|null $stripe_price
* @property int|null $quantity
* @property \Illuminate\Support\Carbon|null $trial_ends_at
* @property \Illuminate\Support\Carbon|null $ends_at
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \Illuminate\Database\Eloquent\Collection<int, \Laravel\Cashier\SubscriptionItem> $items
* @property-read int|null $items_count
* @property-read \App\Models\User|null $owner
* @property-read \App\Models\User|null $user
* @method static \Illuminate\Database\Eloquent\Builder|Subscription active()
* @method static \Illuminate\Database\Eloquent\Builder|Subscription canceled()
* @method static \Illuminate\Database\Eloquent\Builder|Subscription cancelled()
* @method static \Illuminate\Database\Eloquent\Builder|Subscription ended()
* @method static \Illuminate\Database\Eloquent\Builder|Subscription expiredTrial()
* @method static \Illuminate\Database\Eloquent\Builder|Subscription incomplete()
* @method static \Illuminate\Database\Eloquent\Builder|Subscription newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|Subscription newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|Subscription notCanceled()
* @method static \Illuminate\Database\Eloquent\Builder|Subscription notCancelled()
* @method static \Illuminate\Database\Eloquent\Builder|Subscription notOnGracePeriod()
* @method static \Illuminate\Database\Eloquent\Builder|Subscription notOnTrial()
* @method static \Illuminate\Database\Eloquent\Builder|Subscription onGracePeriod()
* @method static \Illuminate\Database\Eloquent\Builder|Subscription onTrial()
* @method static \Illuminate\Database\Eloquent\Builder|Subscription pastDue()
* @method static \Illuminate\Database\Eloquent\Builder|Subscription query()
* @method static \Illuminate\Database\Eloquent\Builder|Subscription recurring()
* @method static \Illuminate\Database\Eloquent\Builder|Subscription whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|Subscription whereEndsAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|Subscription whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|Subscription whereName($value)
* @method static \Illuminate\Database\Eloquent\Builder|Subscription whereQuantity($value)
* @method static \Illuminate\Database\Eloquent\Builder|Subscription whereStripeId($value)
* @method static \Illuminate\Database\Eloquent\Builder|Subscription whereStripePrice($value)
* @method static \Illuminate\Database\Eloquent\Builder|Subscription whereStripeStatus($value)
* @method static \Illuminate\Database\Eloquent\Builder|Subscription whereTrialEndsAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|Subscription whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|Subscription whereUserId($value)
*/
class Subscription extends \Eloquent {}
}
namespace App\Models\Forms\AI{
/**
* App\Models\Forms\AI\AiFormCompletion
@ -77,10 +129,10 @@ namespace App\Models\Forms{
* @property bool $can_be_indexed
* @property string|null $password
* @property string $notification_sender
* @property array $tags
* @property array|null $tags
* @property \Illuminate\Support\Carbon|null $deleted_at
* @property int $creator_id
* @property array $removed_properties
* @property-read array|null $removed_properties
* @property int|null $max_submissions_count
* @property string|null $max_submissions_reached_text
* @property string|null $slack_webhook_url
@ -92,12 +144,15 @@ namespace App\Models\Forms{
* @property object $seo_meta
* @property object|null $notification_settings
* @property bool $auto_save
* @property string|null $custom_domain
* @property bool $show_progress_bar
* @property-read \App\Models\User $creator
* @property-read mixed $edit_url
* @property-read mixed $form_pending_submission_key
* @property-read mixed $has_password
* @property-read mixed $is_closed
* @property-read mixed $is_pro
* @property-read mixed $max_file_size
* @property-read mixed $max_number_of_submissions_reached
* @property-read mixed $notifies_discord
* @property-read mixed $notifies_slack
@ -127,6 +182,7 @@ namespace App\Models\Forms{
* @method static \Illuminate\Database\Eloquent\Builder|Form whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereCreatorId($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereCustomCode($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereCustomDomain($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereDarkMode($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereDatabaseFieldsUpdate($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereDeletedAt($value)
@ -155,6 +211,7 @@ namespace App\Models\Forms{
* @method static \Illuminate\Database\Eloquent\Builder|Form whereRemovedProperties($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereSendSubmissionConfirmation($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereSeoMeta($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereShowProgressBar($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereSlackWebhookUrl($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereSlug($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereSubmitButtonText($value)
@ -173,7 +230,7 @@ namespace App\Models\Forms{
* @method static \Illuminate\Database\Eloquent\Builder|Form withTrashed()
* @method static \Illuminate\Database\Eloquent\Builder|Form withoutTrashed()
*/
class Form extends \Eloquent {}
class Form extends \Eloquent implements \App\Models\Traits\CachableAttributes {}
}
namespace App\Models\Forms{
@ -238,6 +295,62 @@ namespace App\Models\Forms{
class FormView extends \Eloquent {}
}
namespace App\Models\Integration{
/**
* App\Models\Integration\FormIntegration
*
* @property int $id
* @property int $form_id
* @property string $status
* @property string $integration_id
* @property object $logic
* @property object $data
* @property string|null $oauth_id
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Integration\FormIntegrationsEvent> $events
* @property-read int|null $events_count
* @property-read \App\Models\Forms\Form|null $form
* @method static \Illuminate\Database\Eloquent\Builder|FormIntegration newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|FormIntegration newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|FormIntegration query()
* @method static \Illuminate\Database\Eloquent\Builder|FormIntegration whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|FormIntegration whereData($value)
* @method static \Illuminate\Database\Eloquent\Builder|FormIntegration whereFormId($value)
* @method static \Illuminate\Database\Eloquent\Builder|FormIntegration whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|FormIntegration whereIntegrationId($value)
* @method static \Illuminate\Database\Eloquent\Builder|FormIntegration whereLogic($value)
* @method static \Illuminate\Database\Eloquent\Builder|FormIntegration whereOauthId($value)
* @method static \Illuminate\Database\Eloquent\Builder|FormIntegration whereStatus($value)
* @method static \Illuminate\Database\Eloquent\Builder|FormIntegration whereUpdatedAt($value)
*/
class FormIntegration extends \Eloquent {}
}
namespace App\Models\Integration{
/**
* App\Models\Integration\FormIntegrationsEvent
*
* @property int $id
* @property int $integration_id
* @property string $status
* @property object $data
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \App\Models\Integration\FormIntegration|null $integration
* @method static \Illuminate\Database\Eloquent\Builder|FormIntegrationsEvent newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|FormIntegrationsEvent newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|FormIntegrationsEvent query()
* @method static \Illuminate\Database\Eloquent\Builder|FormIntegrationsEvent whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|FormIntegrationsEvent whereData($value)
* @method static \Illuminate\Database\Eloquent\Builder|FormIntegrationsEvent whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|FormIntegrationsEvent whereIntegrationId($value)
* @method static \Illuminate\Database\Eloquent\Builder|FormIntegrationsEvent whereStatus($value)
* @method static \Illuminate\Database\Eloquent\Builder|FormIntegrationsEvent whereUpdatedAt($value)
*/
class FormIntegrationsEvent extends \Eloquent {}
}
namespace App\Models\Integration{
/**
* App\Models\Integration\FormZapierWebhook
@ -277,8 +390,8 @@ namespace App\Models{
* @property array $meta
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read mixed $custom_domain_limit_count
* @property-read mixed $max_file_size
* @property-read int|null $custom_domain_limit_count
* @property-read int $max_file_size
* @property-read \App\Models\User|null $user
* @method static \Illuminate\Database\Eloquent\Builder|License active()
* @method static \Illuminate\Database\Eloquent\Builder|License newModelQuery()
@ -393,6 +506,7 @@ namespace App\Models{
* @property-read mixed $has_forms
* @property-read mixed $is_risky
* @property-read mixed $is_subscribed
* @property-read mixed $moderator
* @property-read string $photo_url
* @property-read mixed $template_editor
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\License> $licenses
@ -401,7 +515,7 @@ namespace App\Models{
* @property-read int|null $notifications_count
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\OAuthProvider> $oauthProviders
* @property-read int|null $oauth_providers_count
* @property-read \Illuminate\Database\Eloquent\Collection<int, \Laravel\Cashier\Subscription> $subscriptions
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Billing\Subscription> $subscriptions
* @property-read int|null $subscriptions_count
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Workspace> $workspaces
* @property-read int|null $workspaces_count
@ -436,7 +550,7 @@ namespace App\Models{
* @property \Illuminate\Support\Carbon|null $updated_at
* @property string $name
* @property string|null $icon
* @property mixed|null $custom_domains
* @property array|null $custom_domains
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Forms\Form> $forms
* @property-read int|null $forms_count
* @property-read mixed $custom_domain_count_limit
@ -457,6 +571,6 @@ namespace App\Models{
* @method static \Illuminate\Database\Eloquent\Builder|Workspace whereName($value)
* @method static \Illuminate\Database\Eloquent\Builder|Workspace whereUpdatedAt($value)
*/
class Workspace extends \Eloquent {}
class Workspace extends \Eloquent implements \App\Models\Traits\CachableAttributes {}
}

View File

@ -0,0 +1,34 @@
<?php
namespace App\Console\Commands;
use App\Models\Integration\FormIntegrationsEvent;
use Illuminate\Console\Command;
class CleanIntegrationEvents extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'forms:integration-events-cleanup';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Delete Old Integration Events';
/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
$response = FormIntegrationsEvent::where('created_at', '<', now()->subDays(14))->delete();
$this->line($response . ' Events Deleted');
}
}

View File

@ -0,0 +1,107 @@
<?php
namespace App\Console\Commands;
use App\Models\Forms\Form;
use App\Models\Integration\FormIntegration;
use Illuminate\Console\Command;
class IntegrationMigration extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'forms:integration-migration';
/**
* The console command description.
*
* @var string
*/
protected $description = 'One Time Only -- Refactor integration';
/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
Form::chunk(
100,
function ($forms) {
foreach ($forms as $form) {
$this->line('Process For Form: ' . $form->id . ' - ' . $form->slug);
// Email
if ($form->notifies && $form->notification_emails) {
$this->createFormIntegration($form, 'email', [
'notification_reply_to' => $form->notification_settings->notification_reply_to,
'notification_emails' => $form->notification_emails
]);
}
// Submission Confirmation
if ($form->send_submission_confirmation) {
$this->createFormIntegration($form, 'submission_confirmation', [
'confirmation_reply_to' => $form->notification_settings->confirmation_reply_to,
'notification_sender' => $form->notification_sender,
'notification_subject' => $form->notification_subject,
'notification_body' => $form->notification_body,
'notifications_include_submission' => $form->notifications_include_submission,
]);
}
// Slack
if ($form->slack_webhook_url) {
$slackData = $form->notification_settings->slack;
$this->createFormIntegration($form, 'slack', [
'slack_webhook_url' => $form->slack_webhook_url,
'include_submission_data' => $slackData->include_submission_data ?? true,
'link_open_form' => $slackData->link_open_form ?? true,
'link_edit_form' => $slackData->link_edit_form ?? true,
'views_submissions_count' => $slackData->views_submissions_count ?? true,
'link_edit_submission' => $slackData->link_edit_submission ?? true
]);
}
// Discord
if ($form->discord_webhook_url) {
$discordData = $form->notification_settings->discord;
$this->createFormIntegration($form, 'discord', [
'discord_webhook_url' => $form->discord_webhook_url,
'include_submission_data' => $discordData->include_submission_data ?? true,
'link_open_form' => $discordData->link_open_form ?? true,
'link_edit_form' => $discordData->link_edit_form ?? true,
'views_submissions_count' => $discordData->views_submissions_count ?? true,
'link_edit_submission' => $discordData->link_edit_submission ?? true
]);
}
// Webhook
if ($form->webhook_url) {
$this->createFormIntegration($form, 'webhook', [
'webhook_url' => $form->webhook_url
]);
}
}
}
);
$this->line('Migration Done');
}
private function createFormIntegration(Form $form, $integration_id, $data = [])
{
$this->line('Form Integration Create: ' . $integration_id);
return FormIntegration::create([
'form_id' => $form->id,
'status' => FormIntegration::STATUS_ACTIVE,
'integration_id' => $integration_id,
'data' => $data,
'logic' => []
]);
}
}

View File

@ -24,6 +24,7 @@ class Kernel extends ConsoleKernel
protected function schedule(Schedule $schedule)
{
$schedule->command('forms:database-cleanup')->hourly();
$schedule->command('forms:integration-events-cleanup')->daily();
}
/**
@ -33,7 +34,7 @@ class Kernel extends ConsoleKernel
*/
protected function commands()
{
$this->load(__DIR__.'/Commands');
$this->load(__DIR__ . '/Commands');
require base_path('routes/console.php');
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Events\Models;
use App\Models\Integration\FormIntegrationsEvent;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class FormIntegrationsEventCreated
{
use Dispatchable;
use InteractsWithSockets;
use SerializesModels;
/**
* Create a new event instance.
*
* @return void
*/
public function __construct(public FormIntegrationsEvent $formIntegrationsEvent)
{
//
}
}

View File

@ -0,0 +1,68 @@
<?php
namespace App\Http\Controllers\Forms\Integration;
use App\Http\Controllers\Controller;
use App\Http\Requests\Integration\FormIntegrationsRequest;
use App\Models\Forms\Form;
use App\Models\Integration\FormIntegration;
class FormIntegrationsController extends Controller
{
public function __construct()
{
$this->middleware('auth');
}
public function index(string $id)
{
$form = Form::findOrFail((int)$id);
$this->authorize('view', $form);
return FormIntegration::where('form_id', $form->id)->get();
}
public function create(FormIntegrationsRequest $request, string $id)
{
$form = Form::findOrFail((int)$id);
$this->authorize('update', $form);
$formIntegration = FormIntegration::create(
array_merge([
'form_id' => $form->id,
], $request->toIntegrationData())
);
return $this->success([
'message' => 'Form Integration was created.',
'form_integration' => $formIntegration
]);
}
public function update(FormIntegrationsRequest $request, string $id, string $integrationid)
{
$form = Form::findOrFail((int)$id);
$this->authorize('update', $form);
$formIntegration = FormIntegration::findOrFail((int)$integrationid);
$formIntegration->update($request->toIntegrationData());
return $this->success([
'message' => 'Form Integration was updated.',
'form_integration' => $formIntegration
]);
}
public function destroy(string $id, string $integrationid)
{
$form = Form::findOrFail((int)$id);
$this->authorize('update', $form);
$formIntegration = FormIntegration::findOrFail((int)$integrationid);
$formIntegration->delete();
return $this->success([
'message' => 'Form Integration was deleted.'
]);
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Http\Controllers\Forms\Integration;
use App\Http\Controllers\Controller;
use App\Http\Resources\FormIntegrationsEventResource;
use App\Models\Forms\Form;
use App\Models\Integration\FormIntegrationsEvent;
class FormIntegrationsEventController extends Controller
{
public function __construct()
{
$this->middleware('auth');
}
public function index(string $id, string $integrationid)
{
$form = Form::findOrFail((int)$id);
$this->authorize('view', $form);
return FormIntegrationsEventResource::collection(
FormIntegrationsEvent::where('integration_id', (int)$integrationid)->orderByDesc('created_at')->get()
);
}
}

View File

@ -0,0 +1,84 @@
<?php
namespace App\Http\Requests\Integration;
use App\Models\Integration\FormIntegration;
use App\Rules\IntegrationLogicRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
class FormIntegrationsRequest extends FormRequest
{
public array $integrationRules = [];
private ?string $integrationClassName = null;
public function __construct(Request $request)
{
if ($request->integration_id) {
// Load integration class, and get rules
$integration = FormIntegration::getIntegration($request->integration_id);
if ($integration && isset($integration['file_name']) && class_exists(
'App\Service\Forms\Integrations\\' . $integration['file_name']
)) {
$this->integrationClassName = 'App\Service\Forms\Integrations\\' . $integration['file_name'];
$this->loadIntegrationRules();
return;
}
throw new \Exception('Unknown Integration!');
}
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
return array_merge([
'integration_id' => ['required', Rule::in(array_keys(FormIntegration::getAllIntegrations()))],
'settings' => 'present|array',
'status' => 'required|boolean',
'logic' => [new IntegrationLogicRule()]
], $this->integrationRules);
}
/**
* Give the validated fields a better "human-readable" name
*
* @return array
*/
public function attributes()
{
$fields = [];
foreach ($this->rules() as $key => $value) {
$fields[$key] = Str::of($key)
->replace('settings.', '')
->headline();
}
return $fields;
}
private function loadIntegrationRules()
{
foreach ($this->integrationClassName::getValidationRules() as $key => $value) {
$this->integrationRules['settings.' . $key] = $value;
}
}
public function toIntegrationData(): array
{
return $this->integrationClassName::formatData([
'status' => ($this->validated(
'status'
)) ? FormIntegration::STATUS_ACTIVE : FormIntegration::STATUS_INACTIVE,
'integration_id' => $this->validated('integration_id'),
'data' => $this->validated('settings') ?? [],
'logic' => $this->validated('logic') ?? []
]);
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
class FormIntegrationsEventResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @param \Illuminate\Http\Request $request
* @return array|\Illuminate\Contracts\Support\Arrayable|\JsonSerializable
*/
public function toArray($request)
{
return [
'date' => date('Y-m-d H:i', strtotime($this->created_at)),
'status' => ucfirst($this->status),
'data' => $this->data
];
}
}

View File

@ -15,6 +15,7 @@ class FailedWebhookListener
*/
public function handle(WebhookCallFailedEvent $event)
{
ray('in faieled', $event);
// Notify form owner
if ($event->meta['type'] == 'form_submission') {
$event->meta['form']->creator->notify(new FailedWebhookNotification($event));

View File

@ -0,0 +1,26 @@
<?php
namespace App\Listeners\Forms;
use App\Events\Models\FormIntegrationsEventCreated;
use App\Mail\Forms\FormIntegrationsEventCreationConfirmationMail;
use App\Models\Integration\FormIntegrationsEvent;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Mail;
class FormIntegrationsEventListener implements ShouldQueue
{
/**
* Handle the event.
*
* @param object $event
* @return void
*/
public function handle(FormIntegrationsEventCreated $event)
{
if ($event->formIntegrationsEvent->status === FormIntegrationsEvent::STATUS_ERROR) {
$form = $event->formIntegrationsEvent->integration->form;
Mail::to($form->creator)->send(new FormIntegrationsEventCreationConfirmationMail($event->formIntegrationsEvent));
}
}
}

View File

@ -3,12 +3,10 @@
namespace App\Listeners\Forms;
use App\Events\Forms\FormSubmitted;
use App\Models\Forms\Form;
use App\Notifications\Forms\FormSubmissionNotification;
use App\Service\Forms\Webhooks\WebhookHandlerProvider;
use App\Models\Integration\FormIntegration;
use App\Service\Forms\Integrations\AbstractIntegrationHandler;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Notification;
class NotifyFormSubmission implements ShouldQueue
{
@ -22,48 +20,32 @@ class NotifyFormSubmission implements ShouldQueue
*/
public function handle(FormSubmitted $event)
{
$this->sendEmailNotifications($event);
$formIntegrations = FormIntegration::where([['form_id', $event->form->id], ['status', FormIntegration::STATUS_ACTIVE]])->get();
foreach ($formIntegrations as $formIntegration) {
ray($formIntegration, $formIntegration->integration_id);
$this->getIntegrationHandler(
$event,
$formIntegration
)->run();
}
/* $this->sendEmailNotifications($event);
$this->sendWebhookNotification($event, WebhookHandlerProvider::SIMPLE_WEBHOOK_PROVIDER);
$this->sendWebhookNotification($event, WebhookHandlerProvider::SLACK_PROVIDER);
$this->sendWebhookNotification($event, WebhookHandlerProvider::DISCORD_PROVIDER);
foreach ($event->form->zappierHooks as $hook) {
$hook->triggerHook($event->data);
}
*/
}
private function sendWebhookNotification(FormSubmitted $event, string $provider)
public static function getIntegrationHandler(FormSubmitted $event, FormIntegration $formIntegration): AbstractIntegrationHandler
{
WebhookHandlerProvider::getProvider(
$event->form,
$event->data,
$provider
)->handle();
}
/**
* Sends an email to each email address in the form's notification_emails field
*
* @return void
*/
private function sendEmailNotifications(FormSubmitted $event)
{
if (! $event->form->is_pro || ! $event->form->notifies) {
return;
$integration = FormIntegration::getIntegration($formIntegration->integration_id);
if ($integration && isset($integration['file_name']) && class_exists('App\Service\Forms\Integrations\\' . $integration['file_name'])) {
$className = 'App\Service\Forms\Integrations\\' . $integration['file_name'];
return new $className($event, $formIntegration, $integration);
}
$subscribers = collect(preg_split("/\r\n|\n|\r/", $event->form->notification_emails))->filter(function (
$email
) {
return filter_var($email, FILTER_VALIDATE_EMAIL);
});
\Log::debug('Sending email notification', [
'recipients' => $subscribers->toArray(),
'form_id' => $event->form->id,
'form_slug' => $event->form->slug,
]);
$subscribers->each(function ($subscriber) use ($event) {
Notification::route('mail', $subscriber)->notify(new FormSubmissionNotification($event));
});
throw new \Exception('Unknown Integration!');
}
}

View File

@ -1,94 +0,0 @@
<?php
namespace App\Listeners\Forms;
use App\Events\Forms\FormSubmitted;
use App\Mail\Forms\SubmissionConfirmationMail;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Mail;
/**
* Sends a confirmation to form respondant that form was submitted
*
* Class SubmissionConfirmation
*/
class SubmissionConfirmation implements ShouldQueue
{
use InteractsWithQueue;
public const RISKY_USERS_LIMIT = 120;
/**
* Handle the event.
*
* @param object $event
* @return void
*/
public function handle(FormSubmitted $event)
{
if (
! $event->form->is_pro ||
! $event->form->send_submission_confirmation ||
$this->riskLimitReached($event) // To avoid phishing abuse we limit this feature for risky users
) {
return;
}
$email = $this->getRespondentEmail($event);
if (! $email) {
return;
}
\Log::info('Sending submission confirmation', [
'recipient' => $email,
'form_id' => $event->form->id,
'form_slug' => $event->form->slug,
]);
Mail::to($email)->send(new SubmissionConfirmationMail($event));
}
private function getRespondentEmail(FormSubmitted $event)
{
// Make sure we only have one email field in the form
$emailFields = collect($event->form->properties)->filter(function ($field) {
$hidden = $field['hidden'] ?? false;
return ! $hidden && $field['type'] == 'email';
});
if ($emailFields->count() != 1) {
return null;
}
if (isset($event->data[$emailFields->first()['id']])) {
$email = $event->data[$emailFields->first()['id']];
if ($this->validateEmail($email)) {
return $email;
}
}
return null;
}
private function riskLimitReached(FormSubmitted $event): bool
{
// This is a per-workspace limit for risky workspaces
if ($event->form->workspace->is_risky) {
if ($event->form->workspace->submissions_count >= self::RISKY_USERS_LIMIT) {
\Log::error('!!!DANGER!!! Dangerous user detected! Attempting many email sending.', [
'form_id' => $event->form->id,
'workspace_id' => $event->form->workspace->id,
]);
return true;
}
}
return false;
}
public static function validateEmail($email): bool
{
return (bool) filter_var($email, FILTER_VALIDATE_EMAIL);
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Mail\Forms;
use App\Mail\OpenFormMail;
use App\Models\Integration\FormIntegration;
use App\Models\Integration\FormIntegrationsEvent;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\SerializesModels;
class FormIntegrationsEventCreationConfirmationMail extends OpenFormMail implements ShouldQueue
{
use Queueable;
use SerializesModels;
public $formIntegration;
public $form;
/**
* Create a new message instance.
*
* @return void
*/
public function __construct(public FormIntegrationsEvent $formIntegrationsEvent)
{
$this->formIntegration = $formIntegrationsEvent->integration;
$this->form = $this->formIntegration->form;
}
/**
* Build the message.
*
* @return $this
*/
public function build()
{
$integration = FormIntegration::getIntegration($this->formIntegration->integration_id);
return $this
->markdown('mail.form.integrations-event-created', [
'form' => $this->form,
'integration_name' => $integration['name'] ?? '',
'error' => json_encode($this->formIntegrationsEvent->data)
])->subject("Integration issue with your form: '" . $this->form->title . "'");
}
}

View File

@ -8,7 +8,6 @@ use App\Service\Forms\FormSubmissionFormatter;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Vinkla\Hashids\Facades\Hashids;
@ -22,7 +21,7 @@ class SubmissionConfirmationMail extends OpenFormMail implements ShouldQueue
*
* @return void
*/
public function __construct(private FormSubmitted $event)
public function __construct(private FormSubmitted $event, private $integrationData)
{
}
@ -42,11 +41,12 @@ class SubmissionConfirmationMail extends OpenFormMail implements ShouldQueue
return $this
->replyTo($this->getReplyToEmail($form->creator->email))
->from($this->getFromEmail(), $form->notification_sender)
->subject($form->notification_subject)
->from($this->getFromEmail(), $this->integrationData->notification_sender)
->subject($this->integrationData->notification_subject)
->markdown('mail.form.confirmation-submission-notification', [
'fields' => $formatter->getFieldsWithValue(),
'form' => $form,
'integrationData' => $this->integrationData,
'noBranding' => $form->no_branding,
'submission_id' => (isset($this->event->data['submission_id']) && $this->event->data['submission_id']) ? Hashids::encode($this->event->data['submission_id']) : null,
]);
@ -56,12 +56,12 @@ class SubmissionConfirmationMail extends OpenFormMail implements ShouldQueue
{
$originalFromAddress = Str::of(config('mail.from.address'))->explode('@');
return $originalFromAddress->first().'+'.time().'@'.$originalFromAddress->last();
return $originalFromAddress->first() . '+' . time() . '@' . $originalFromAddress->last();
}
private function getReplyToEmail($default)
{
$replyTo = Arr::get((array) $this->event->form->notification_settings, 'confirmation_reply_to', null);
$replyTo = $this->integrationData->confirmation_reply_to ?? null;
if ($replyTo && filter_var($replyTo, FILTER_VALIDATE_EMAIL)) {
return $replyTo;

View File

@ -0,0 +1,51 @@
<?php
namespace App\Models\Integration;
use App\Models\Forms\Form;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class FormIntegration extends Model
{
use HasFactory;
public const STATUS_ACTIVE = 'active';
public const STATUS_INACTIVE = 'inactive';
protected $fillable = [
'form_id',
'status',
'integration_id',
'logic',
'data',
'oauth_id'
];
protected $casts = [
'data' => 'object',
'logic' => 'object'
];
/**
* Relationships
*/
public function form()
{
return $this->belongsTo(Form::class);
}
public function events()
{
return $this->hasMany(FormIntegrationsEvent::class, 'integration_id');
}
public static function getAllIntegrations()
{
return json_decode(file_get_contents(resource_path('data/forms/integrations.json')), true);
}
public static function getIntegration($key)
{
return self::getAllIntegrations()[$key] ?? null;
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Models\Integration;
use App\Events\Models\FormIntegrationsEventCreated;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class FormIntegrationsEvent extends Model
{
use HasFactory;
public const STATUS_SUCCESS = 'success';
public const STATUS_ERROR = 'error';
protected $fillable = [
'integration_id',
'status',
'data'
];
protected $casts = [
'data' => 'object'
];
/**
* The event map for the model.
*
* @var array
*/
protected $dispatchesEvents = [
'created' => FormIntegrationsEventCreated::class,
];
public function integration()
{
return $this->belongsTo(FormIntegration::class, 'integration_id');
}
}

View File

@ -3,7 +3,7 @@
namespace App\Models\Integration;
use App\Models\Forms\Form;
use App\Service\Forms\Webhooks\WebhookHandlerProvider;
use App\Service\Forms\Integrations\WebhookHandlerProvider;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;

View File

@ -8,7 +8,6 @@ use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
class FormSubmissionNotification extends Notification implements ShouldQueue
@ -22,7 +21,7 @@ class FormSubmissionNotification extends Notification implements ShouldQueue
*
* @return void
*/
public function __construct(FormSubmitted $event)
public function __construct(FormSubmitted $event, private $integrationData)
{
$this->event = $event;
}
@ -55,7 +54,7 @@ class FormSubmissionNotification extends Notification implements ShouldQueue
return (new MailMessage())
->replyTo($this->getReplyToEmail($notifiable->routes['mail']))
->from($this->getFromEmail(), config('app.name'))
->subject('New form submission for "'.$this->event->form->title.'"')
->subject('New form submission for "' . $this->event->form->title . '"')
->markdown('mail.form.submission-notification', [
'fields' => $formatter->getFieldsWithValue(),
'form' => $this->event->form,
@ -66,12 +65,12 @@ class FormSubmissionNotification extends Notification implements ShouldQueue
{
$originalFromAddress = Str::of(config('mail.from.address'))->explode('@');
return $originalFromAddress->first().'+'.time().'@'.$originalFromAddress->last();
return $originalFromAddress->first() . '+' . time() . '@' . $originalFromAddress->last();
}
private function getReplyToEmail($default)
{
$replyTo = Arr::get((array) $this->event->form->notification_settings, 'notification_reply_to', null);
$replyTo = $this->integrationData->notification_reply_to ?? null;
if ($replyTo && $this->validateEmail($replyTo)) {
return $replyTo;
}
@ -85,7 +84,7 @@ class FormSubmissionNotification extends Notification implements ShouldQueue
$emailFields = collect($this->event->form->properties)->filter(function ($field) {
$hidden = $field['hidden'] ?? false;
return ! $hidden && $field['type'] == 'email';
return !$hidden && $field['type'] == 'email';
});
if ($emailFields->count() != 1) {
return null;

View File

@ -4,14 +4,14 @@ namespace App\Providers;
use App\Events\Forms\FormSubmitted;
use App\Events\Models\FormCreated;
use App\Events\Models\FormIntegrationsEventCreated;
use App\Listeners\FailedWebhookListener;
use App\Listeners\Forms\FormCreationConfirmation;
use App\Listeners\Forms\FormIntegrationsEventListener;
use App\Listeners\Forms\NotifyFormSubmission;
use App\Listeners\Forms\SubmissionConfirmation;
use Illuminate\Auth\Events\Registered;
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Event;
use Spatie\WebhookServer\Events\WebhookCallFailedEvent;
class EventServiceProvider extends ServiceProvider
@ -29,12 +29,14 @@ class EventServiceProvider extends ServiceProvider
FormCreationConfirmation::class,
],
FormSubmitted::class => [
NotifyFormSubmission::class,
SubmissionConfirmation::class,
NotifyFormSubmission::class
],
WebhookCallFailedEvent::class => [
FailedWebhookListener::class,
],
FormIntegrationsEventCreated::class => [
FormIntegrationsEventListener::class,
],
];
/**

View File

@ -0,0 +1,186 @@
<?php
namespace App\Rules;
use Illuminate\Contracts\Validation\DataAwareRule;
use Illuminate\Contracts\Validation\Rule;
class IntegrationLogicRule implements DataAwareRule, Rule
{
private $isConditionCorrect = true;
private $conditionErrors = [];
private $field = [];
private $data = [];
private function checkBaseCondition($condition)
{
if (!isset($condition['value'])) {
$this->isConditionCorrect = false;
$this->conditionErrors[] = 'missing condition body';
return;
}
if (!isset($condition['value']['property_meta'])) {
$this->isConditionCorrect = false;
$this->conditionErrors[] = 'missing condition property';
return;
}
if (!isset($condition['value']['property_meta']['type'])) {
$this->isConditionCorrect = false;
$this->conditionErrors[] = 'missing condition property type';
return;
}
if (!isset($condition['value']['operator'])) {
$this->isConditionCorrect = false;
$this->conditionErrors[] = 'missing condition operator';
return;
}
if (!isset($condition['value']['value'])) {
$this->isConditionCorrect = false;
$this->conditionErrors[] = 'missing condition value';
return;
}
$typeField = $condition['value']['property_meta']['type'];
$operator = $condition['value']['operator'];
$value = $condition['value']['value'];
if (!isset(FormPropertyLogicRule::CONDITION_MAPPING[$typeField])) {
$this->isConditionCorrect = false;
$this->conditionErrors[] = 'configuration not found for condition type';
return;
}
if (!isset(FormPropertyLogicRule::CONDITION_MAPPING[$typeField]['comparators'][$operator])) {
$this->isConditionCorrect = false;
$this->conditionErrors[] = 'configuration not found for condition operator';
return;
}
$type = FormPropertyLogicRule::CONDITION_MAPPING[$typeField]['comparators'][$operator]['expected_type'];
if (is_array($type)) {
$foundCorrectType = false;
foreach ($type as $subtype) {
if ($this->valueHasCorrectType($subtype, $value)) {
$foundCorrectType = true;
}
}
if (!$foundCorrectType) {
$this->isConditionCorrect = false;
}
} else {
if (!$this->valueHasCorrectType($type, $value)) {
$this->isConditionCorrect = false;
$this->conditionErrors[] = 'wrong type of condition value';
}
}
}
private function valueHasCorrectType($type, $value)
{
if (
($type === 'string' && gettype($value) !== 'string') ||
($type === 'boolean' && !is_bool($value)) ||
($type === 'number' && !is_numeric($value)) ||
($type === 'object' && !is_array($value))
) {
return false;
}
return true;
}
private function checkConditions($conditions)
{
if (array_key_exists('operatorIdentifier', $conditions)) {
if (($conditions['operatorIdentifier'] !== 'and') && ($conditions['operatorIdentifier'] !== 'or')) {
$this->conditionErrors[] = 'missing operator';
$this->isConditionCorrect = false;
return;
}
if (isset($conditions['operatorIdentifier']['children'])) {
$this->conditionErrors[] = 'extra condition';
$this->isConditionCorrect = false;
return;
}
if (!is_array($conditions['children'])) {
$this->conditionErrors[] = 'wrong sub-condition type';
$this->isConditionCorrect = false;
return;
}
foreach ($conditions['children'] as &$child) {
$this->checkConditions($child);
}
} elseif (isset($conditions['identifier'])) {
$this->checkBaseCondition($conditions);
}
}
/**
* Determine if the validation rule passes.
*
* @param string $attribute
* @param mixed $value
* @return bool
*/
public function passes($attribute, $value)
{
if (isset($value)) {
$this->checkConditions($value);
}
return $this->isConditionCorrect;
}
/**
* Get the validation error message.
*/
public function message()
{
$message = null;
if (!$this->isConditionCorrect) {
$message = 'The logic conditions are not complete.';
}
if (count($this->conditionErrors) > 0) {
return $message . ' Error detail(s): ' . implode(', ', $this->conditionErrors);
}
return $message;
}
/**
* Set the data under validation.
*
* @param array $data
* @return $this
*/
public function setData($data)
{
$this->data = $data;
$this->isConditionCorrect = true;
$this->conditionErrors = [];
return $this;
}
}

View File

@ -28,12 +28,8 @@ class FormCleaner
private array $customKeys = ['seo_meta'];
private array $formDefaults = [
'notifies' => false,
'no_branding' => false,
'webhook_url' => null,
'database_fields_update' => null,
'slack_webhook_url' => null,
'discord_webhook_url' => null,
'editable_submissions' => false,
'custom_code' => null,
'seo_meta' => [],
@ -46,12 +42,8 @@ class FormCleaner
private array $cleaningMessages = [
// For form
'notifies' => 'Email notification were disabled.',
'no_branding' => 'OpenForm branding is not hidden.',
'webhook_url' => 'Webhook disabled.',
'database_fields_update' => 'Form submission will only create new records (no updates).',
'slack_webhook_url' => 'Slack webhook disabled.',
'discord_webhook_url' => 'Discord webhook disabled.',
'editable_submissions' => 'Users will not be able to edit their submissions.',
'custom_code' => 'Custom code was disabled',
'seo_meta' => 'Custom SEO was disabled',
@ -126,7 +118,7 @@ class FormCleaner
*/
public function simulateCleaning(Workspace $workspace): FormCleaner
{
if (! $this->isPro($workspace)) {
if (!$this->isPro($workspace)) {
$this->data = $this->removeProFeatures($this->data, true);
}
@ -141,7 +133,7 @@ class FormCleaner
*/
public function performCleaning(Workspace $workspace): FormCleaner
{
if (! $this->isPro($workspace)) {
if (!$this->isPro($workspace)) {
$this->data = $this->removeProFeatures($this->data);
}
@ -217,14 +209,14 @@ class FormCleaner
$formVal = (($formVal === 0 || $formVal === '0') ? false : $formVal);
$formVal = (($formVal === 1 || $formVal === '1') ? true : $formVal);
if (! is_null($formVal) && $formVal !== $value) {
if (! isset($this->cleanings['form'])) {
if (!is_null($formVal) && $formVal !== $value) {
if (!isset($this->cleanings['form'])) {
$this->cleanings['form'] = [];
}
$this->cleanings['form'][] = $key;
// If not a simulation, do the cleaning
if (! $simulation) {
if (!$simulation) {
Arr::set($data, $key, $value);
}
}
@ -236,7 +228,7 @@ class FormCleaner
foreach ($defaults as $key => $value) {
if (isset($data[$key]) && Arr::get($data, $key) !== $value) {
$this->cleanings[$data['name']][] = $key;
if (! $simulation) {
if (!$simulation) {
Arr::set($data, $key, $value);
}
}

View File

@ -0,0 +1,131 @@
<?php
namespace App\Service\Forms\Integrations;
use App\Models\Integration\FormIntegration;
use App\Events\Forms\FormSubmitted;
use App\Models\Integration\FormIntegrationsEvent;
use App\Service\Forms\FormSubmissionFormatter;
use App\Service\Forms\FormLogicConditionChecker;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Vinkla\Hashids\Facades\Hashids;
abstract class AbstractIntegrationHandler
{
protected $form = null;
protected $submissionData = null;
protected $integrationData = null;
public function __construct(
protected FormSubmitted $event,
protected FormIntegration $formIntegration,
protected array $integration
) {
$this->form = $event->form;
$this->submissionData = $event->data;
$this->integrationData = $formIntegration->data;
}
protected function getProviderName(): string
{
return $this->integration['name'] ?? '';
}
protected function logicConditionsMet(): bool
{
if (!$this->formIntegration->logic) {
return true;
}
return FormLogicConditionChecker::conditionsMet(
json_decode(json_encode($this->formIntegration->logic), true),
$this->submissionData
);
}
protected function shouldRun(): bool
{
return $this->logicConditionsMet();
}
protected function getWebhookUrl(): ?string
{
return '';
}
/**
* Default webhook payload. Can be changed in child classes.
*/
protected function getWebhookData(): array
{
$formatter = (new FormSubmissionFormatter($this->form, $this->submissionData))
->useSignedUrlForFiles()
->showHiddenFields();
$formattedData = [];
foreach ($formatter->getFieldsWithValue() as $field) {
$formattedData[$field['name']] = $field['value'];
}
$data = [
'form_title' => $this->form->title,
'form_slug' => $this->form->slug,
'submission' => $formattedData,
];
if ($this->form->is_pro && $this->form->editable_submissions) {
$data['edit_link'] = $this->form->share_url . '?submission_id=' . Hashids::encode(
$this->submissionData['submission_id']
);
}
return $data;
}
final public function run(): void
{
try {
$this->handle();
$this->formIntegration->events()->create([
'status' => FormIntegrationsEvent::STATUS_SUCCESS,
]);
} catch (\Exception $e) {
$this->formIntegration->events()->create([
'status' => FormIntegrationsEvent::STATUS_ERROR,
'data' => $this->extractEventDataFromException($e),
]);
}
}
/**
* Default handle. Can be changed in child classes.
*/
public function handle(): void
{
if (!$this->shouldRun()) {
return;
}
Http::throw()->post($this->getWebhookUrl(), $this->getWebhookData());
}
abstract public static function getValidationRules(): array;
public static function formatData(array $data): array
{
return $data;
}
public function extractEventDataFromException(\Exception $e): array
{
if ($e instanceof RequestException) {
return [
'message' => $e->getMessage(),
'response' => $e->response->json(),
'status' => $e->response->status(),
];
}
return [
'message' => $e->getMessage()
];
}
}

View File

@ -1,6 +1,6 @@
<?php
namespace App\Service\Forms\Webhooks;
namespace App\Service\Forms\Integrations;
use App\Models\Forms\Form;
use App\Service\Forms\FormSubmissionFormatter;

View File

@ -1,6 +1,6 @@
<?php
namespace App\Service\Forms\Webhooks;
namespace App\Service\Forms\Integrations;
use App\Service\Forms\FormSubmissionFormatter;
use Illuminate\Support\Arr;

View File

@ -0,0 +1,91 @@
<?php
namespace App\Service\Forms\Integrations;
use App\Service\Forms\FormSubmissionFormatter;
use Illuminate\Support\Arr;
use Vinkla\Hashids\Facades\Hashids;
class DiscordIntegration extends AbstractIntegrationHandler
{
public static function getValidationRules(): array
{
return [
'discord_webhook_url' => 'required|url|starts_with:https://discord.com/api/webhooks',
'include_submission_data' => 'boolean',
'link_open_form' => 'boolean',
'link_edit_form' => 'boolean',
'views_submissions_count' => 'boolean',
'link_edit_submission' => 'boolean'
];
}
protected function getWebhookUrl(): ?string
{
return $this->integrationData->discord_webhook_url;
}
protected function shouldRun(): bool
{
return !is_null($this->getWebhookUrl()) && $this->form->is_pro && parent::shouldRun();
}
protected function getWebhookData(): array
{
$settings = (array) $this->integrationData ?? [];
$externalLinks = [];
if (Arr::get($settings, 'link_open_form', true)) {
$externalLinks[] = '[**🔗 Open Form**](' . $this->form->share_url . ')';
}
if (Arr::get($settings, 'link_edit_form', true)) {
$editFormURL = front_url('forms/' . $this->form->slug . '/show');
$externalLinks[] = '[**✍️ Edit Form**](' . $editFormURL . ')';
}
if (Arr::get($settings, 'link_edit_submission', true) && $this->form->editable_submissions) {
$submissionId = Hashids::encode($this->submissionData['submission_id']);
$externalLinks[] = '[**✍️ ' . $this->form->editable_submissions_button_text . '**](' . $this->form->share_url . '?submission_id=' . $submissionId . ')';
}
$color = hexdec(str_replace('#', '', $this->form->color));
$blocks = [];
if (Arr::get($settings, 'include_submission_data', true)) {
$submissionString = '';
$formatter = (new FormSubmissionFormatter($this->form, $this->submissionData))->outputStringsOnly();
foreach ($formatter->getFieldsWithValue() as $field) {
$tmpVal = is_array($field['value']) ? implode(',', $field['value']) : $field['value'];
$submissionString .= '**' . ucfirst($field['name']) . '**: ' . $tmpVal . "\n";
}
$blocks[] = [
'type' => 'rich',
'color' => $color,
'description' => $submissionString,
];
}
if (Arr::get($settings, 'views_submissions_count', true)) {
$countString = '**👀 Views**: ' . (string) $this->form->views_count . " \n";
$countString .= '**🖊️ Submissions**: ' . (string) $this->form->submissions_count;
$blocks[] = [
'type' => 'rich',
'color' => $color,
'description' => $countString,
];
}
if (count($externalLinks) > 0) {
$blocks[] = [
'type' => 'rich',
'color' => $color,
'description' => implode(' - ', $externalLinks),
];
}
return [
'content' => 'New submission for your form **' . $this->form->title . '**',
'tts' => false,
'username' => config('app.name'),
'avatar_url' => asset('img/logo.png'),
'embeds' => $blocks,
];
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Service\Forms\Integrations;
use App\Rules\OneEmailPerLine;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Notification;
use App\Notifications\Forms\FormSubmissionNotification;
class EmailIntegration extends AbstractIntegrationHandler
{
public static function getValidationRules(): array
{
return [
'notification_emails' => ['required', new OneEmailPerLine()],
'notification_reply_to' => 'email|nullable',
];
}
protected function shouldRun(): bool
{
return !(!$this->form->is_pro || !$this->integrationData->notification_emails) && parent::shouldRun();
}
public function handle(): void
{
if (!$this->shouldRun()) {
return;
}
$subscribers = collect(preg_split("/\r\n|\n|\r/", $this->integrationData->notification_emails))
->filter(function ($email) {
return filter_var($email, FILTER_VALIDATE_EMAIL);
});
Log::debug('Sending email notification', [
'recipients' => $subscribers->toArray(),
'form_id' => $this->form->id,
'form_slug' => $this->form->slug,
]);
$subscribers->each(function ($subscriber) {
Notification::route('mail', $subscriber)->notify(
new FormSubmissionNotification($this->event, $this->integrationData)
);
});
}
}

View File

@ -1,6 +1,6 @@
<?php
namespace App\Service\Forms\Webhooks;
namespace App\Service\Forms\Integrations;
class SimpleWebhookHandler extends AbstractWebhookHandler
{

View File

@ -1,6 +1,6 @@
<?php
namespace App\Service\Forms\Webhooks;
namespace App\Service\Forms\Integrations;
use App\Service\Forms\FormSubmissionFormatter;
use Illuminate\Support\Arr;

View File

@ -0,0 +1,101 @@
<?php
namespace App\Service\Forms\Integrations;
use App\Service\Forms\FormSubmissionFormatter;
use Illuminate\Support\Arr;
use Vinkla\Hashids\Facades\Hashids;
class SlackIntegration extends AbstractIntegrationHandler
{
public static function getValidationRules(): array
{
return [
'slack_webhook_url' => 'required|url|starts_with:https://hooks.slack.com/',
'include_submission_data' => 'boolean',
'link_open_form' => 'boolean',
'link_edit_form' => 'boolean',
'views_submissions_count' => 'boolean',
'link_edit_submission' => 'boolean'
];
}
protected function getWebhookUrl(): ?string
{
return $this->integrationData->slack_webhook_url;
}
protected function shouldRun(): bool
{
return !is_null($this->getWebhookUrl()) && $this->form->is_pro && parent::shouldRun();
}
protected function getWebhookData(): array
{
$settings = (array) $this->integrationData ?? [];
$externalLinks = [];
if (Arr::get($settings, 'link_open_form', true)) {
$externalLinks[] = '*<' . $this->form->share_url . '|🔗 Open Form>*';
}
if (Arr::get($settings, 'link_edit_form', true)) {
$editFormURL = front_url('forms/' . $this->form->slug . '/show');
$externalLinks[] = '*<' . $editFormURL . '|✍️ Edit Form>*';
}
if (Arr::get($settings, 'link_edit_submission', true) && $this->form->editable_submissions) {
$submissionId = Hashids::encode($this->submissionData['submission_id']);
$externalLinks[] = '*<' . $this->form->share_url . '?submission_id=' . $submissionId . '|✍️ ' . $this->form->editable_submissions_button_text . '>*';
}
$blocks = [
[
'type' => 'section',
'text' => [
'type' => 'mrkdwn',
'text' => 'New submission for your form *' . $this->form->title . '*',
],
],
];
if (Arr::get($settings, 'include_submission_data', true)) {
$submissionString = '';
$formatter = (new FormSubmissionFormatter($this->form, $this->submissionData))->outputStringsOnly();
foreach ($formatter->getFieldsWithValue() as $field) {
$tmpVal = is_array($field['value']) ? implode(',', $field['value']) : $field['value'];
$submissionString .= '>*' . ucfirst($field['name']) . '*: ' . $tmpVal . " \n";
}
$blocks[] = [
'type' => 'section',
'text' => [
'type' => 'mrkdwn',
'text' => $submissionString,
],
];
}
if (Arr::get($settings, 'views_submissions_count', true)) {
$countString = '*👀 Views*: ' . (string) $this->form->views_count . " \n";
$countString .= '*🖊️ Submissions*: ' . (string) $this->form->submissions_count;
$blocks[] = [
'type' => 'section',
'text' => [
'type' => 'mrkdwn',
'text' => $countString,
],
];
}
if (count($externalLinks) > 0) {
$blocks[] = [
'type' => 'section',
'text' => [
'type' => 'mrkdwn',
'text' => implode(' ', $externalLinks),
],
];
}
return [
'blocks' => $blocks,
];
}
}

View File

@ -0,0 +1,103 @@
<?php
namespace App\Service\Forms\Integrations;
use App\Mail\Forms\SubmissionConfirmationMail;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Log;
use Stevebauman\Purify\Facades\Purify;
/**
* Sends a confirmation to form respondant that form was submitted
*/
class SubmissionConfirmationIntegration extends AbstractIntegrationHandler
{
public const RISKY_USERS_LIMIT = 120;
public static function getValidationRules(): array
{
return [
'confirmation_reply_to' => 'email|nullable',
'notification_sender' => 'required',
'notification_subject' => 'required',
'notification_body' => 'required',
'notifications_include_submission' => 'boolean'
];
}
protected function shouldRun(): bool
{
return !(!$this->form->is_pro) && parent::shouldRun() && !$this->riskLimitReached();
}
public function handle(): void
{
if (!$this->shouldRun()) {
return;
}
$email = $this->getRespondentEmail();
if (!$email) {
return;
}
Log::info('Sending submission confirmation', [
'recipient' => $email,
'form_id' => $this->form->id,
'form_slug' => $this->form->slug,
]);
Mail::to($email)->send(new SubmissionConfirmationMail($this->event, $this->integrationData));
}
private function getRespondentEmail()
{
// Make sure we only have one email field in the form
$emailFields = collect($this->form->properties)->filter(function ($field) {
$hidden = $field['hidden'] ?? false;
return !$hidden && $field['type'] == 'email';
});
if ($emailFields->count() != 1) {
return null;
}
if (isset($this->submissionData[$emailFields->first()['id']])) {
$email = $this->submissionData[$emailFields->first()['id']];
if ($this->validateEmail($email)) {
return $email;
}
}
return null;
}
// To avoid phishing abuse we limit this feature for risky users
private function riskLimitReached(): bool
{
// This is a per-workspace limit for risky workspaces
if ($this->form->workspace->is_risky) {
if ($this->form->workspace->submissions_count >= self::RISKY_USERS_LIMIT) {
Log::error('!!!DANGER!!! Dangerous user detected! Attempting many email sending.', [
'form_id' => $this->form->id,
'workspace_id' => $this->form->workspace->id,
]);
return true;
}
}
return false;
}
public static function validateEmail($email): bool
{
return (bool)filter_var($email, FILTER_VALIDATE_EMAIL);
}
public static function formatData(array $data): array
{
return array_merge(parent::formatData($data), [
'notification_body' => Purify::clean($data['notification_body'] ?? ''),
]);
}
}

View File

@ -1,6 +1,6 @@
<?php
namespace App\Service\Forms\Webhooks;
namespace App\Service\Forms\Integrations;
use App\Models\Forms\Form;

View File

@ -0,0 +1,23 @@
<?php
namespace App\Service\Forms\Integrations;
class WebhookIntegration extends AbstractIntegrationHandler
{
public static function getValidationRules(): array
{
return [
'webhook_url' => 'required|url'
];
}
protected function getWebhookUrl(): ?string
{
return $this->integrationData->webhook_url;
}
protected function shouldRun(): bool
{
return !is_null($this->getWebhookUrl()) && parent::shouldRun();
}
}

View File

@ -1,6 +1,6 @@
<?php
namespace App\Service\Forms\Webhooks;
namespace App\Service\Forms\Integrations;
use App\Models\Forms\Form;

6
client/app.config.ts Normal file
View File

@ -0,0 +1,6 @@
export default defineAppConfig({
ui: {
primary: 'blue',
gray: 'slate'
}
})

View File

@ -16,7 +16,14 @@
@change="onChange" @keydown.enter.prevent="onEnterPress"
>
<template v-if="maxCharLimit && showCharLimit" #bottom_after_help>
<template #help>
<slot name="help" />
</template>
<template
v-if="maxCharLimit && showCharLimit"
#bottom_after_help
>
<small :class="theme.default.help">
{{ charCount }}/{{ maxCharLimit }}
</small>

View File

@ -96,7 +96,7 @@ export default {
this.cameraPermissionStatus = 'allowed';
})
.catch(err => {
console.log(err)
console.error(err)
if(err.toString() === 'NotAllowedError: Permission denied'){
this.cameraPermissionStatus = 'blocked';
return;

View File

@ -1,21 +1,26 @@
<template>
<div class="flex mb-1 input-help">
<small :class="theme.default.help" class="grow flex">
<slot name="help"><span v-if="help" class="field-help" v-html="help" /></slot>
<small
:class="helpClasses"
class="grow flex"
>
<slot name="help">
<span
v-if="help"
class="field-help"
v-html="help"/>
</slot>
</small>
<slot name="after-help">
<small class="flex-grow" />
<small class="flex-grow"/>
</slot>
</div>
</template>
<script>
export default {
name: 'InputHelp',
<script setup>
props: {
theme: { type: Object, required: true },
help: { type: String, required: false }
}
}
defineProps({
helpClasses: {type: String, default: 'text-gray-400 dark:text-gray-500'},
help: {type: String, required: false}
})
</script>

View File

@ -1,11 +1,18 @@
<template>
<label :for="nativeFor"
class="input-label"
:class="[theme.default.label,{'uppercase text-xs': uppercaseLabels, 'text-sm': !uppercaseLabels}]"
<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>
<span
v-if="required"
class="text-red-500 required-dot"
>*</span>
</slot>
</label>
</template>
@ -19,7 +26,7 @@ export default {
theme: { type: Object, required: true },
uppercaseLabels: { type: Boolean, default: false },
required: { type: Boolean, default: false },
label: { type: String, required: true }
}
label: { type: String, required: true },
},
}
</script>

View File

@ -1,54 +1,71 @@
<template>
<div :class="wrapperClass" :style="inputStyle">
<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"
<InputLabel
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
v-if="helpPosition === 'above_input'"
name="help"
>
<InputHelp
v-if="help"
:help="help"
:help-classes="theme.default.help"
/>
</slot>
<slot />
<slot v-if="(help && helpPosition==='below_input') || $slots.bottom_after_help" name="help">
<input-help :help="help" :theme="theme">
<slot
v-if="helpPosition === 'below_input'"
name="help"
>
<InputHelp
v-if="help"
:help="help"
:help-classes="theme.default.help"
>
<template #after-help>
<slot name="bottom_after_help" />
</template>
</input-help>
</InputHelp>
</slot>
<slot name="error">
<has-error v-if="hasValidation && form" :form="form" :field="name" />
<has-error
v-if="hasValidation && form"
:form="form"
:field="name"
/>
</slot>
</div>
</template>
<script>
<script setup>
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 }
}
}
defineProps({
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

@ -2,24 +2,35 @@
<div class="flex items-center">
<input
:id="id || name"
:name="name"
v-model="internalValue"
:name="name"
type="checkbox"
:class="sizeClasses"
class="rounded border-gray-500 cursor-pointer"
:disabled="disabled?true:null"
:disabled="disabled ? true : null"
>
<label
:for="id || name"
class="text-gray-700 dark:text-gray-300 ml-2"
:class="{ '!cursor-not-allowed': disabled }"
>
<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'
import {
defineEmits,
defineOptions,
defineProps,
onMounted,
ref,
watch,
} from 'vue'
defineOptions({
name: 'VCheckbox'
name: 'VCheckbox',
})
const props = defineProps({
@ -27,33 +38,42 @@ const props = defineProps({
name: { type: String, default: 'checkbox' },
modelValue: { type: [Boolean, String], default: false },
disabled: { type: Boolean, default: false },
sizeClasses: { type: String, default: 'w-4 h-4' }
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.modelValue,
(val) => {
internalValue.value = val
},
)
watch(() => props.checked, 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
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 (val !== oldVal)
emit('update:modelValue', val)
},
)
onMounted(() => {
if (internalValue.value === null) {
if (internalValue.value === null)
internalValue.value = false
}
})
</script>

View File

@ -1,19 +1,48 @@
<template>
<div class="v-select relative" :class="[{ 'w-0': multiple, 'min-w-full': multiple }]" ref="select">
<div
ref="select"
class="v-select relative"
:class="[{ 'w-0': multiple, 'min-w-full': multiple }]"
>
<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"
<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 !border-transparent': hasError, '!cursor-not-allowed !bg-gray-200': disabled }, inputClass]"
@click="toggleDropdown">
@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" />
<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">
<div
v-else
key="placeholder"
>
<slot name="placeholder">
<div class="text-gray-400 dark:text-gray-500 w-full text-left truncate pr-3"
<div
class="text-gray-400 dark:text-gray-500 w-full text-left truncate pr-3"
:class="{ 'py-1': multiple && !loading }">
{{ placeholder }}
</div>
@ -22,8 +51,18 @@
</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
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>
@ -36,23 +75,40 @@
<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">
<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"
<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)" />
<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">
<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"
<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"
class="text-gray-900 cursor-default select-none relative py-2 cursor-pointer group hover:text-white dark: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>
@ -63,9 +119,9 @@
<script>
import Collapsible from '~/components/global/transitions/Collapsible.vue'
import { themes } from '../../../lib/forms/form-themes.js'
import { themes} from "~/lib/forms/form-themes.js"
import TextInput from '../TextInput.vue'
import debounce from 'debounce'
import debounce from 'lodash/debounce'
import Fuse from 'fuse.js'
export default {
@ -74,7 +130,7 @@ export default {
directives: {},
props: {
data: Array,
modelValue: { default: null },
modelValue: { default: null, type: [String, Number, Array, Object] },
inputClass: { type: String, default: null },
dropdownClass: { type: String, default: 'w-full' },
loading: { type: Boolean, default: false },
@ -93,6 +149,7 @@ export default {
allowCreation: { type: Boolean, default: false },
disabled: { type: Boolean, default: false }
},
emits: ['update:modelValue', 'update-options'],
data() {
return {
isOpen: false,
@ -210,10 +267,12 @@ export default {
if (newOption) {
const newItem = {
name: newOption,
value: newOption
value: newOption,
id: newOption
}
this.$emit('update-options', newItem)
this.select(newItem)
this.searchTerm = ''
}
}
}

View File

@ -1,22 +1,32 @@
<template>
<div role="button" @click.stop="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': props.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': props.modelValue}" />
<div
role="button"
@click.stop="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': props.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': props.modelValue }"
/>
</div>
</div>
</template>
<script setup>
import { defineProps, defineEmits } from 'vue'
import { defineEmits, defineProps } from 'vue'
const props = defineProps({
modelValue: { type: Boolean, default: false },
disabled: { type: Boolean, default: false }
disabled: { type: Boolean, default: false },
})
const emit = defineEmits(['update:modelValue'])
const onClick = () => {
if (props.disabled) return
function onClick() {
if (props.disabled)
return
emit('update:modelValue', !props.modelValue)
}
</script>

View File

@ -1,5 +1,7 @@
import { ref, computed, watch } from 'vue'
import { themes } from '~/lib/forms/form-themes.js'
import {default as _get} from 'lodash/get'
import {default as _set} from 'lodash/set'
export const inputProps = {
id: { type: String, default: null },
@ -39,13 +41,13 @@ export function useFormInput (props, context, formPrefixKey = null) {
const compVal = computed({
get: () => {
if (props.form) {
return props.form[(formPrefixKey || '') + props.name]
return _get(props.form, (formPrefixKey || '') + props.name)
}
return content.value
},
set: (val) => {
if (props.form) {
props.form[(formPrefixKey || '') + props.name] = val
_set(props.form, (formPrefixKey || '') + props.name, val)
} else {
content.value = val
}

View File

@ -0,0 +1,50 @@
<template>
<div :class="classes">
<Icon v-if="beforeIcon" :name="beforeIcon" :class="iconClasses"/>
<slot></slot>
<Icon v-if="afterIcon" :name="afterIcon" :class="iconClasses"/>
</div>
</template>
<script setup>
const props = defineProps({
color: {
type: String,
default: 'green'
},
beforeIcon: {
type: String,
default: null
},
afterIcon: {
type: String,
default: null
}
})
const baseClasses = {
'green': ['bg-green-100', 'border', 'border-green-300', 'text-green-700'],
'red': ['bg-red-100', 'border', 'border-red-300', 'text-red-700'],
'gray': ['bg-gray-100', 'border', 'border-gray-300', 'text-gray-700'],
}
const iconBaseClasses = {
'green': ['text-green-500'],
'red': ['text-red-500'],
'gray': ['text-gray-500'],
}
const activeColor = computed(() => {
return Object.hasOwn(baseClasses, props.color) ? props.color : 'gray'
})
const classes = computed(() => {
const classes = ['border', 'text-xs', 'px-2', 'inline-flex', 'items-center', 'rounded-full'].concat(baseClasses[activeColor.value])
return classes.join(' ')
})
const iconClasses = computed(() => {
return iconBaseClasses[activeColor.value].concat(['w-2 h-2 mr-1']).join(' ')
})
</script>

View File

@ -7,7 +7,7 @@
@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="self-start bg-white dark:bg-notion-dark w-full relative my-6 rounded-xl shadow-xl"
:class="maxWidthClass"
>
<div v-if="closeable" class="absolute top-4 right-4">
@ -19,15 +19,17 @@
</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="flex border-b pb-4"
v-if="$slots.hasOwnProperty('icon') || $slots.hasOwnProperty('title')"
:class="[{'flex-col sm:items-start':!compactHeader, 'items-center justify-center py-6 gap-x-4':compactHeader},headerInnerPadding]">
<div v-if="$slots.hasOwnProperty('icon')" :class="{'w-full mb-4 flex justify-center':!compactHeader}">
<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">
<div class="mt-3 text-center sm:mt-0" :class="{'w-full':!compactHeader}">
<h2 v-if="$slots.hasOwnProperty('title')"
class="text-2xl font-semibold text-center text-gray-900"
>
@ -36,11 +38,11 @@
</div>
</div>
<div class="w-full">
<div class="w-full" :class="innerPadding">
<slot/>
</div>
<div v-if="$slots.hasOwnProperty('footer')" class="px-6 py-4 bg-gray-100 text-right">
<div v-if="$slots.hasOwnProperty('footer')" class="bg-gray-50 border-t rounded-b-xl text-right" :class="footerInnerPadding">
<slot name="footer"/>
</div>
</div>
@ -66,19 +68,34 @@ const props = defineProps({
maxWidth: {
default: '2xl'
},
innerPadding: {
default: 'p-6'
},
headerInnerPadding: {
default: 'p-6'
},
footerInnerPadding: {
default: 'p-6'
},
closeable: {
default: true
}
},
compactHeader: {
default: false,
type: Boolean
},
})
const emit = defineEmits(['close'])
useHead({
bodyAttrs: {
class: {
'overflow-hidden': props.show
bodyAttrs: computed(() => {
return {
class: {
'overflow-hidden': props.show
}
}
}
})
})
const closeOnEscape = (e) => {
@ -146,7 +163,8 @@ const motionSlideBottom = {
}
const onLeave = (el, done) => {
contentMotion.value.leave(()=>{})
contentMotion.value.leave(() => {
})
backdropMotion.value.leave(done)
}

View File

@ -1,43 +1,45 @@
<template>
<div class="inline" v-if="shouldDisplayProTag">
<div class="bg-nt-blue text-white px-2 text-xs uppercase inline rounded-full font-semibold cursor-pointer"
@click.prevent="showPremiumModal=true"
>
PRO
</div>
<modal :show="showPremiumModal" @close="showPremiumModal=false">
<h2 class="text-nt-blue">
OpnForm PRO
</h2>
<h4 v-if="user && user.is_subscribed" class="text-center mt-5">
We're happy to have you as a Pro customer. If you're having any issue with OpnForm, or if you have a
feature request, please <a href="mailto:contact@opnform.com">contact us</a>.
</h4>
<div v-if="!user || !user.is_subscribed" class="mt-4">
<p>
All the features with a<span
class="bg-nt-blue text-white px-2 text-xs uppercase inline rounded-full font-semibold mx-1"
>
<UTooltip text="Upgrade to use this feature">
<div role="button" class="bg-nt-blue text-white px-2 text-xs uppercase inline rounded-full font-semibold cursor-pointer"
@click="showPremiumModal=true">
PRO
</div>
<modal :show="showPremiumModal" @close="showPremiumModal=false">
<h2 class="text-nt-blue">
OpnForm PRO
</h2>
<h4 v-if="user && user.is_subscribed" class="text-center mt-5">
We're happy to have you as a Pro customer. If you're having any issue with OpnForm, or if you have a
feature request, please <a href="mailto:contact@opnform.com">contact us</a>.
</h4>
<div v-if="!user || !user.is_subscribed" class="mt-4">
<p>
All the features with a<span
class="bg-nt-blue text-white px-2 text-xs uppercase inline rounded-full font-semibold mx-1"
>
PRO
</span> tag are available in the Pro plan of OpnForm. <b>You can play around and try all Pro features
within
the form editor, but you can't use them in your real forms</b>. You can subscribe now to gain unlimited access
to
all our pro features!
</p>
</div>
within
the form editor, but you can't use them in your real forms</b>. You can subscribe now to gain unlimited
access
to
all our pro features!
</p>
</div>
<div class="my-4 text-center">
<v-button color="white" @click="showPremiumModal=false">
Close
</v-button>
</div>
</modal>
<div class="my-4 text-center">
<v-button color="white" @click="showPremiumModal=false">
Close
</v-button>
</div>
</modal>
</UTooltip>
</div>
</template>
<script setup>
import { computed } from 'vue'
import {computed} from 'vue'
const authStore = useAuthStore()
const workspacesStore = useWorkspacesStore()

View File

@ -19,7 +19,7 @@
</template>
<script>
import throttle from 'lodash.throttle'
import throttle from 'lodash/throttle'
function newResizeObserver (callback) {
// Skip this feature for browsers which
// do not support ResizeObserver.

View File

@ -5,18 +5,18 @@
help="With form submission answers"
/>
<toggle-switch-input name="link_open_form" v-model="compVal.link_open_form" class="mt-4"
label="Open Form"
label="'Open Form' Link"
help="Link to the form public page"
/>
<toggle-switch-input name="link_edit_form" v-model="compVal.link_edit_form" class="mt-4"
label="Edit Form"
label="'Edit Form' Link"
help="Link to the form admin page"
/>
<toggle-switch-input name="views_submissions_count" v-model="compVal.views_submissions_count" class="mt-4"
label="Analytics (views & submissions)"
label="Form Analytics" help="Form views and submissions count"
/>
<toggle-switch-input name="link_edit_submission" v-model="compVal.link_edit_submission" class="mt-4"
label="Link to the Edit Submission Record"
label="Edit Submission Link"
/>
</div>
</template>

View File

@ -0,0 +1,33 @@
<template>
<IntegrationWrapper :integration="props.integration" :form="form" v-model="props.integrationData">
<text-input :form="integrationData" name="settings.discord_webhook_url"
label="Discord webhook url" help="help" required>
<template #help>
<InputHelp>
<template #help>
<span>
Receive a discord message on each form submission.
<a href="https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks" target="_blank"> Click
here </a> to learn how to get a discord webhook url.
</span>
</template>
</InputHelp>
</template>
</text-input>
<h4 class="font-bold mt-4">Discord message options</h4>
<form-notifications-message-actions v-model="integrationData.settings"/>
</IntegrationWrapper>
</template>
<script setup>
import IntegrationWrapper from "./components/IntegrationWrapper.vue"
import FormNotificationsMessageActions
from "~/components/open/forms/components/form-components/components/FormNotificationsMessageActions.vue"
const props = defineProps({
integration: {type: Object, required: true},
form: {type: Object, required: true},
integrationData: {type: Object, required: true},
formIntegrationId: {type: Number, required: false, default: null}
})
</script>

View File

@ -0,0 +1,34 @@
<template>
<IntegrationWrapper :integration="props.integration" :form="form" v-model="props.integrationData">
<text-area-input :form="integrationData" name="settings.notification_emails" required
label="Notification Emails" help="Add one email per line" />
<text-input :form="integrationData" name="settings.notification_reply_to"
label="Notification Reply To" :help="notifiesHelp" />
</IntegrationWrapper>
</template>
<script setup>
import IntegrationWrapper from './components/IntegrationWrapper.vue'
const props = defineProps({
integration: { type: Object, required: true },
form: { type: Object, required: true },
integrationData: { type: Object, required: true },
formIntegrationId: { type: Number, required: false, default: null }
})
const replayToEmailField = computed(() => {
const emailFields = props.form.properties.filter((field) => {
return field.type === 'email' && !field.hidden
})
if (emailFields.length === 1) return emailFields[0]
return null
})
const notifiesHelp = computed(() => {
if (replayToEmailField.value) {
return 'If empty, Reply-to for this notification will be the email filled in the field "' + replayToEmailField.value.name + '".'
}
return 'If empty, Reply-to for this notification will be your own email. Add a single email field to your form, and it will automatically become the reply to value.'
})
</script>

View File

@ -0,0 +1,18 @@
<template>
<IntegrationWrapper :integration="props.integration" :form="form" v-model="props.integrationData">
<div class="my-5">
Coming Soon...
</div>
</IntegrationWrapper>
</template>
<script setup>
import IntegrationWrapper from './components/IntegrationWrapper.vue'
const props = defineProps({
integration: { type: Object, required: true },
form: { type: Object, required: true },
integrationData: { type: Object, required: true },
formIntegrationId: { type: Number, required: false, default: null }
})
</script>

View File

@ -0,0 +1,32 @@
<template>
<IntegrationWrapper :integration="props.integration" :form="form" v-model="props.integrationData">
<text-input :form="integrationData" name="settings.slack_webhook_url"
label="Slack webhook url" help="help" required>
<template #help>
<InputHelp>
<template #help>
<span>
Receive slack message on each form submission. <a href="https://api.slack.com/messaging/webhooks" target="_blank"> Click here </a>
to learn how to get a slack webhook url
</span>
</template>
</InputHelp>
</template>
</text-input>
<h4 class="font-bold mt-4">Slack message actions</h4>
<form-notifications-message-actions v-model="integrationData.settings"/>
</IntegrationWrapper>
</template>
<script setup>
import IntegrationWrapper from "./components/IntegrationWrapper.vue"
import FormNotificationsMessageActions
from "~/components/open/forms/components/form-components/components/FormNotificationsMessageActions.vue"
const props = defineProps({
integration: {type: Object, required: true},
form: {type: Object, required: true},
integrationData: {type: Object, required: true},
formIntegrationId: {type: Number, required: false, default: null}
})
</script>

View File

@ -0,0 +1,58 @@
<template>
<IntegrationWrapper :integration="props.integration" :form="form" v-model="props.integrationData">
<div>{{ emailSubmissionConfirmationHelp }}</div>
<div v-if="emailSubmissionConfirmationField">
<text-input :form="integrationData" name="settings.notification_sender" class="mt-4" required
label="Confirmation Email Sender Name"
help="Emails will be sent from our email address but you can customize the name of the Sender" />
<text-input :form="integrationData" name="settings.notification_subject" class="mt-4" required
label="Confirmation email subject" help="Subject of the confirmation email that will be sent" />
<rich-text-area-input :form="integrationData" name="settings.notification_body" class="mt-4" required
label="Confirmation email content" help="Content of the confirmation email that will be sent" />
<text-input :form="integrationData" name="settings.confirmation_reply_to" class="mt-4"
label="Confirmation Reply To" help="If empty, Reply-to will be your own email."/>
<toggle-switch-input :form="integrationData" name="settings.notifications_include_submission" class="mt-4"
label="Include submission data" help="If enabled the confirmation email will contain form submission answers" />
</div>
</IntegrationWrapper>
</template>
<script setup>
import IntegrationWrapper from "./components/IntegrationWrapper.vue"
const props = defineProps({
integration: { type: Object, required: true },
form: { type: Object, required: true },
integrationData: { type: Object, required: true },
formIntegrationId: { type: Number, required: false, default: null }
})
const emailSubmissionConfirmationField = computed(() => {
if (!props.form.properties || !Array.isArray(props.form.properties)) return null
const emailFields = props.form.properties.filter((field) => {
return field.type === 'email' && !field.hidden
})
if (emailFields.length === 1) return emailFields[0]
return null
})
const emailSubmissionConfirmationHelp = computed(() => {
if (emailSubmissionConfirmationField.value) {
return 'Confirmation will be sent to the email in the "' + emailSubmissionConfirmationField.value.name + '" field.'
}
return 'Only available if your form contains 1 email field.'
})
onBeforeMount(() => {
for (const [keyname, defaultValue] of Object.entries({
'notification_sender': 'OpnForm',
'notification_subject': 'We saved your answers',
'notification_body': 'Hello there 👋 <br>This is a confirmation that your submission was successfully saved.',
'notifications_include_submission': true,
})) {
if (props.integrationData.settings[keyname] === undefined) {
props.integrationData.settings[keyname] = defaultValue
}
}
})
</script>

View File

@ -0,0 +1,17 @@
<template>
<IntegrationWrapper :integration="props.integration" :form="form" v-model="props.integrationData">
<text-input :form="integrationData" name="settings.webhook_url" class="mt-4" label="Webhook url"
help="We will post form submissions to this endpoint" required />
</IntegrationWrapper>
</template>
<script setup>
import IntegrationWrapper from "./components/IntegrationWrapper.vue"
const props = defineProps({
integration: { type: Object, required: true },
form: { type: Object, required: true },
integrationData: { type: Object, required: true },
formIntegrationId: { type: Number, required: false, default: null }
})
</script>

View File

@ -0,0 +1,18 @@
<template>
<IntegrationWrapper :integration="props.integration" :form="form" v-model="props.integrationData">
<div class="my-5">
Coming Soon...
</div>
</IntegrationWrapper>
</template>
<script setup>
import IntegrationWrapper from './components/IntegrationWrapper.vue'
const props = defineProps({
integration: { type: Object, required: true },
form: { type: Object, required: true },
integrationData: { type: Object, required: true },
formIntegrationId: { type: Number, required: false, default: null }
})
</script>

View File

@ -0,0 +1,106 @@
<template>
<div class="text-gray-500 border shadow rounded-md p-5 mt-4 relative flex items-center">
<div class="flex-grow flex items-center">
<div class="mr-4"
:class="{ 'text-blue-500': integration.status === 'active', 'text-gray-400': integration.status !== 'active' }">
<Icon :name="integrationTypeInfo.icon" size="32px"/>
</div>
<div>
<div class="flex space-x-3 font-semibold mr-2">{{ integrationTypeInfo.name }}</div>
<Badge :color="integration.status === 'active' ? 'green' : 'gray'"
:before-icon="integration.status === 'active' ? 'solar:play-bold' : 'solar:pause-bold'"
>
{{ integration.status === 'active' ? 'Active' : 'Paused' }}
</Badge>
</div>
</div>
<div v-if="loadingDelete" class="pr-4 pt-2">
<Loader class="h-6 w-6 mx-auto"/>
</div>
<dropdown v-else class="inline">
<template #trigger="{ toggle }">
<v-button color="white" @click="toggle">
<svg class="w-4 h-4 inline -mt-1" viewBox="0 0 16 4" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M8.00016 2.83366C8.4604 2.83366 8.8335 2.46056 8.8335 2.00033C8.8335 1.54009 8.4604 1.16699 8.00016 1.16699C7.53993 1.16699 7.16683 1.54009 7.16683 2.00033C7.16683 2.46056 7.53993 2.83366 8.00016 2.83366Z"
stroke="#344054" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
<path
d="M13.8335 2.83366C14.2937 2.83366 14.6668 2.46056 14.6668 2.00033C14.6668 1.54009 14.2937 1.16699 13.8335 1.16699C13.3733 1.16699 13.0002 1.54009 13.0002 2.00033C13.0002 2.46056 13.3733 2.83366 13.8335 2.83366Z"
stroke="#344054" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
<path
d="M2.16683 2.83366C2.62707 2.83366 3.00016 2.46056 3.00016 2.00033C3.00016 1.54009 2.62707 1.16699 2.16683 1.16699C1.70659 1.16699 1.3335 1.54009 1.3335 2.00033C1.3335 2.46056 1.70659 2.83366 2.16683 2.83366Z"
stroke="#344054" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</v-button>
</template>
<a v-track.edit_form_integration_click="{ form_slug: form.slug, form_integration_id: integration.id }" href="#"
@click.prevent="showIntegrationModal = true"
class="flex px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:no-underline hover:text-gray-900 items-center">
<Icon name="heroicons:pencil" class="w-5 h-5 mr-2"/>
Edit
</a>
<a v-track.past_events_form_integration_click="{ form_slug: form.slug, form_integration_id: integration.id }"
href="#"
@click.prevent="showIntegrationEventsModal = true"
class="flex px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:no-underline hover:text-gray-900 items-center">
<Icon name="heroicons:clock" class="w-5 h-5 mr-2"/>
Past Events
</a>
<a v-track.delete_form_integration_click="{ form_integration_id: integration.id }" href="#"
class="flex px-4 py-2 text-md text-red-600 hover:bg-red-50 hover:no-underline items-center"
@click.prevent="deleteFormIntegration(integration.id)">
<Icon name="heroicons:trash" class="w-5 h-5 mr-2"/>
Delete Integration
</a>
</dropdown>
<IntegrationModal v-if="form && integration && integrationTypeInfo" :form="form" :integration="integrationTypeInfo"
:integrationKey="integration.integration_id" :formIntegrationId="integration.id"
:show="showIntegrationModal"
@close="showIntegrationModal = false"/>
<IntegrationEventsModal v-if="form && integration" :form="form" :formIntegrationId="integration.id"
:show="showIntegrationEventsModal"
@close="showIntegrationEventsModal = false"/>
</div>
</template>
<script setup>
import {computed} from "vue";
const props = defineProps({
integration: {
type: Object,
required: true
},
form: {
type: Object,
required: true
}
})
const alert = useAlert()
const formIntegrationsStore = useFormIntegrationsStore()
const integrations = computed(() => formIntegrationsStore.availableIntegrations)
const integrationTypeInfo = computed(() => integrations.value.get(props.integration.integration_id))
let showIntegrationModal = ref(false)
let showIntegrationEventsModal = ref(false)
let loadingDelete = ref(false)
const deleteFormIntegration = (integrationid) => {
alert.confirm('Do you really want to delete this form integration?', () => {
opnFetch('/open/forms/{formid}/integration/{integrationid}'.replace('{formid}', props.form.id).replace('{integrationid}', integrationid), {method: 'DELETE'}).then((data) => {
if (data.type === 'success') {
alert.success(data.message)
formIntegrationsStore.remove(integrationid)
} else {
alert.error('Something went wrong!')
}
}).catch((error) => {
alert.error(error.data.message)
})
})
}
</script>

View File

@ -0,0 +1,68 @@
<template>
<modal :show="show" @close="emit('close')" compact-header inner-padding="">
<template #icon>
<Icon name="heroicons:clock" size="40px"/>
</template>
<template #title>
Past Events
</template>
<UTable :loading="integrationEventsLoading" :columns="columns" :rows="integrationEvents">
<template #status-data="{ row }">
<Badge :color="(row.status==='Success') ? 'green' : 'red'">
{{row.status}}
</Badge>
</template>
<template #data-data="{ row }">
<vue-json-pretty v-if="row.data && Object.keys(row.data).length > 0" :data="row.data" :collapsedNodeLength="0" :showLength="true" :showIcon="true" />
<span v-else>-</span>
</template>
</UTable>
<template #footer>
<div class="flex justify-center gap-x-2">
<v-button color="white" @click.prevent="emit('close')">
Close
</v-button>
</div>
</template>
</modal>
</template>
<script setup>
import VueJsonPretty from 'vue-json-pretty'
import 'vue-json-pretty/lib/styles.css'
const props = defineProps({
show: { type: Boolean, required: true },
form: {type: Object, required: true},
formIntegrationId: {type: Number, required: true}
})
const emit = defineEmits(['close'])
const formIntegrationEventEndpoint = '/open/forms/{formid}/integration/{integrationid}/events'
const columns = [
{ key: 'date', label: 'Date', sortable: true },
{ key: 'status', label: 'Status', sortable: true },
{ key: 'data', label: 'Info'}
]
let integrationEvents = ref([])
let integrationEventsLoading = ref(false)
watch(() => props.show, () => {
fetchEvents()
})
const fetchEvents = () => {
if (props.show) {
nextTick(() => {
integrationEventsLoading.value = true
integrationEvents.value = []
opnFetch(formIntegrationEventEndpoint.replace('{formid}', props.form.id).replace('{integrationid}', props.formIntegrationId)).then((data) => {
integrationEvents.value = data
integrationEventsLoading.value = false
})
})
}
}
</script>

View File

@ -0,0 +1,49 @@
<template>
<UTooltip :text="tooltipText" :prevent="!unavailable">
<div role="button" @click="onClick"
v-track.new_integration_click="{ name: integration.id }"
:class="{'hover:bg-blue-50 group cursor-pointer': !unavailable, 'cursor-not-allowed': unavailable}"
class="bg-gray-50 border border-gray-200 rounded-md transition-colors p-4 pb-2 items-center justify-center w-[170px] h-[110px] flex flex-col relative">
<div class="flex justify-center">
<div class="h-10 w-10 text-gray-500 group-hover:text-blue-500 transition-colors flex items-center">
<Icon :name="integration.icon" size="40px"/>
</div>
</div>
<div class="flex-grow flex items-center">
<div class="text-gray-400 font-medium text-sm text-center">
{{ integration.name }}<span class="text-xs" v-if="integration.coming_soon"> (coming soon)</span>
</div>
</div>
<pro-tag v-if="integration?.is_pro === true" class="absolute top-0 right-1"/>
</div>
</UTooltip>
</template>
<script setup>
const emit = defineEmits(['select'])
const props = defineProps({
integration: {
type: Object,
required: true
}
})
const unavailable = computed(() => {
return props.integration.coming_soon || props.integration.requires_subscription
})
const tooltipText = computed(() => {
if (props.integration.coming_soon) return 'This integration is coming soon'
if (props.integration.requires_subscription) return 'You need a subscription to use this integration.'
return ''
})
const onClick = () => {
if (props.integration.coming_soon || props.integration.requires_subscription) return
emit('select', props.integration.id)
}
</script>

View File

@ -0,0 +1,82 @@
<template>
<modal :show="show" @close="emit('close')" compact-header>
<template #icon>
<Icon :name="integration?.icon" size="40px"/>
</template>
<template #title>
{{ integration?.name }}
<pro-tag v-if="integration?.is_pro === true"/>
</template>
<component v-if="integration && component" :is="component" :form="form" :integration="integration"
:integrationData="integrationData"/>
<template #footer>
<div class="flex justify-center gap-x-2">
<v-button class="px-8" @click.prevent="save">
Save
</v-button>
<v-button color="white" @click.prevent="emit('close')">
Close
</v-button>
</div>
</template>
</modal>
</template>
<script setup>
import {computed} from 'vue'
const props = defineProps({
show: {type: Boolean, required: true},
form: {type: Object, required: true},
integrationKey: {type: String, required: true},
integration: {type: Object, required: true},
formIntegrationId: {type: Number, required: false, default: null}
})
const alert = useAlert()
const emit = defineEmits(['close'])
const formIntegrationsStore = useFormIntegrationsStore()
const formIntegration = computed(() => (props.formIntegrationId) ? formIntegrationsStore.getByKey(props.formIntegrationId) : null)
const component = computed(() => {
if (!props.integration) return null
return resolveComponent(props.integration.file_name)
})
const integrationData = ref(null)
watch(() => props.integrationKey, () => {
initIntegrationData()
})
const initIntegrationData = () => {
integrationData.value = useForm({
integration_id: (props.formIntegrationId) ? formIntegration.value.integration_id : props.integrationKey,
status: (props.formIntegrationId) ? formIntegration.value.status === 'active' : true,
settings: (props.formIntegrationId) ? formIntegration.value.data ?? {} : {},
logic: (props.formIntegrationId) ? (!Array.isArray(formIntegration.value.logic) && formIntegration.value.logic) ? formIntegration.value.logic : null : null
})
}
initIntegrationData()
const save = () => {
if (!integrationData.value) return
integrationData.value.submit(
(props.formIntegrationId) ? 'PUT' : 'POST',
'/open/forms/{formid}/integration'.replace('{formid}', props.form.id) + ((props.formIntegrationId) ? '/' + props.formIntegrationId : '')
).then(data => {
alert.success(data.message)
formIntegrationsStore.save(data.form_integration)
emit('close')
}).catch((error) => {
try {
alert.error(error.data.message)
} catch (e) {
alert.error('An error occurred while saving the integration')
}
})
}
</script>

View File

@ -0,0 +1,75 @@
<template>
<div :class="wrapperClass" :style="inputStyle">
<div class="flex justify-between">
<slot name="status">
<toggle-switch-input name="status" v-model="modelValue.status" label="Enabled"/>
</slot>
<slot name="help">
<v-button class="flex" color="white" size="small" @click="openHelp">
<Icon name="heroicons:question-mark-circle-16-solid" class="w-4 h-4 text-gray-500 -mt-[3px]"/>
<span class="text-gray-500">
Help
</span>
</v-button>
</slot>
</div>
<slot/>
<slot name="logic">
<div class="-mx-6 px-6 border-t pt-6">
<collapse class="w-full" v-model="showLogic">
<template #title>
<div class="flex gap-x-3 items-start pr-8">
<div class="transition-colors" :class="{ 'text-blue-600': showLogic, 'text-gray-300': !showLogic }">
<Icon name="material-symbols:settings" size="30px"/>
</div>
<div class="flex-grow">
<h3 class="font-semibold">
Logic
</h3>
<p class="text-gray-400 text-xs">
Only run integration when a condition is met
</p>
</div>
</div>
</template>
<condition-editor ref="filter-editor" v-model="modelValue.logic" class="mt-4 border-t border rounded-md integration-logic"
:form="form"/>
</collapse>
</div>
</slot>
</div>
</template>
<script setup>
import ConditionEditor from '~/components/open/forms/components/form-logic-components/ConditionEditor.client.vue'
const props = defineProps({
integration: {type: Object, required: true},
modelValue: {required: false},
wrapperClass: {type: String, required: false},
inputStyle: {type: Object, required: false},
form: {type: Object, required: false}
})
const crisp = useCrisp()
const emit = defineEmits(['close'])
const showLogic = ref(!!props.modelValue.logic)
const openHelp = () => {
if (props.integration && props.integration?.crisp_help_page_slug) {
crisp.openHelpdeskArticle(props.integration?.crisp_help_page_slug)
return
}
crisp.openHelpdesk()
}
</script>
<style lang="scss">
.integration-logic {
.query-builder-group__group-children {
margin: 4px 0px 0px 0px !important;
}
}
</style>

View File

@ -1,35 +1,32 @@
<template>
<Modal :show="show" :closeable="!aiForm.busy" @close="$emit('close')">
<template #icon>
<template v-if="state=='default'">
<template v-if="state == 'default'">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-10 h-10 text-blue">
<path fill-rule="evenodd"
d="M12 3.75a.75.75 0 01.75.75v6.75h6.75a.75.75 0 010 1.5h-6.75v6.75a.75.75 0 01-1.5 0v-6.75H4.5a.75.75 0 010-1.5h6.75V4.5a.75.75 0 01.75-.75z"
clip-rule="evenodd"
/>
d="M12 3.75a.75.75 0 01.75.75v6.75h6.75a.75.75 0 010 1.5h-6.75v6.75a.75.75 0 01-1.5 0v-6.75H4.5a.75.75 0 010-1.5h6.75V4.5a.75.75 0 01.75-.75z"
clip-rule="evenodd" />
</svg>
</template>
<template v-else-if="state=='ai'">
<template v-else-if="state == 'ai'">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-8 h-8 text-blue-500">
<path fill-rule="evenodd"
d="M14.615 1.595a.75.75 0 01.359.852L12.982 9.75h7.268a.75.75 0 01.548 1.262l-10.5 11.25a.75.75 0 01-1.272-.71l1.992-7.302H3.75a.75.75 0 01-.548-1.262l10.5-11.25a.75.75 0 01.913-.143z"
clip-rule="evenodd"
/>
d="M14.615 1.595a.75.75 0 01.359.852L12.982 9.75h7.268a.75.75 0 01.548 1.262l-10.5 11.25a.75.75 0 01-1.272-.71l1.992-7.302H3.75a.75.75 0 01-.548-1.262l10.5-11.25a.75.75 0 01.913-.143z"
clip-rule="evenodd" />
</svg>
</template>
</template>
<template #title>
<template v-if="state=='default'">
<template v-if="state == 'default'">
Choose a base for your form
</template>
<template v-else-if="state=='ai'">
<template v-else-if="state == 'ai'">
AI-powered form generator
</template>
</template>
<div v-if="state=='default'" class="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-8">
<div v-track.select_form_base="{base:'contact-form'}" role="button"
class="rounded-md border p-4 flex flex-col items-center cursor-pointer hover:bg-gray-50" @click="$emit('close')"
>
<div v-if="state == 'default'" class="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-8">
<div v-track.select_form_base="{ base: 'contact-form' }" role="button"
class="rounded-md border p-4 flex flex-col items-center cursor-pointer hover:bg-gray-50" @click="$emit('close')">
<div class="p-4">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-8 h-8 text-blue-500">
<path d="M1.5 8.67v8.58a3 3 0 003 3h15a3 3 0 003-3V8.67l-8.928 5.493a3 3 0 01-3.144 0L1.5 8.67z" />
@ -40,15 +37,14 @@
Start from a simple contact form
</p>
</div>
<div v-if="aiFeaturesEnabled" v-track.select_form_base="{base:'ai'}"
class="rounded-md border p-4 flex flex-col items-center cursor-pointer hover:bg-gray-50" role="button" @click="state='ai'"
>
<div v-if="aiFeaturesEnabled" v-track.select_form_base="{ base: 'ai' }"
class="rounded-md border p-4 flex flex-col items-center cursor-pointer hover:bg-gray-50" role="button"
@click="state = 'ai'">
<div class="p-4 relative">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-8 h-8 text-blue-500">
<path fill-rule="evenodd"
d="M14.615 1.595a.75.75 0 01.359.852L12.982 9.75h7.268a.75.75 0 01.548 1.262l-10.5 11.25a.75.75 0 01-1.272-.71l1.992-7.302H3.75a.75.75 0 01-.548-1.262l10.5-11.25a.75.75 0 01.913-.143z"
clip-rule="evenodd"
/>
d="M14.615 1.595a.75.75 0 01.359.852L12.982 9.75h7.268a.75.75 0 01.548 1.262l-10.5 11.25a.75.75 0 01-1.272-.71l1.992-7.302H3.75a.75.75 0 01-.548-1.262l10.5-11.25a.75.75 0 01.913-.143z"
clip-rule="evenodd" />
</svg>
</div>
<p class="font-medium text-blue-700">
@ -60,26 +56,27 @@
<div class="p-4 relative">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-8 h-8 text-blue-500">
<path
d="M11.25 5.337c0-.355-.186-.676-.401-.959a1.647 1.647 0 01-.349-1.003c0-1.036 1.007-1.875 2.25-1.875S15 2.34 15 3.375c0 .369-.128.713-.349 1.003-.215.283-.401.604-.401.959 0 .332.278.598.61.578 1.91-.114 3.79-.342 5.632-.676a.75.75 0 01.878.645 49.17 49.17 0 01.376 5.452.657.657 0 01-.66.664c-.354 0-.675-.186-.958-.401a1.647 1.647 0 00-1.003-.349c-1.035 0-1.875 1.007-1.875 2.25s.84 2.25 1.875 2.25c.369 0 .713-.128 1.003-.349.283-.215.604-.401.959-.401.31 0 .557.262.534.571a48.774 48.774 0 01-.595 4.845.75.75 0 01-.61.61c-1.82.317-3.673.533-5.555.642a.58.58 0 01-.611-.581c0-.355.186-.676.401-.959.221-.29.349-.634.349-1.003 0-1.035-1.007-1.875-2.25-1.875s-2.25.84-2.25 1.875c0 .369.128.713.349 1.003.215.283.401.604.401.959a.641.641 0 01-.658.643 49.118 49.118 0 01-4.708-.36.75.75 0 01-.645-.878c.293-1.614.504-3.257.629-4.924A.53.53 0 005.337 15c-.355 0-.676.186-.959.401-.29.221-.634.349-1.003.349-1.036 0-1.875-1.007-1.875-2.25s.84-2.25 1.875-2.25c.369 0 .713.128 1.003.349.283.215.604.401.959.401a.656.656 0 00.659-.663 47.703 47.703 0 00-.31-4.82.75.75 0 01.83-.832c1.343.155 2.703.254 4.077.294a.64.64 0 00.657-.642z"
/>
d="M11.25 5.337c0-.355-.186-.676-.401-.959a1.647 1.647 0 01-.349-1.003c0-1.036 1.007-1.875 2.25-1.875S15 2.34 15 3.375c0 .369-.128.713-.349 1.003-.215.283-.401.604-.401.959 0 .332.278.598.61.578 1.91-.114 3.79-.342 5.632-.676a.75.75 0 01.878.645 49.17 49.17 0 01.376 5.452.657.657 0 01-.66.664c-.354 0-.675-.186-.958-.401a1.647 1.647 0 00-1.003-.349c-1.035 0-1.875 1.007-1.875 2.25s.84 2.25 1.875 2.25c.369 0 .713-.128 1.003-.349.283-.215.604-.401.959-.401.31 0 .557.262.534.571a48.774 48.774 0 01-.595 4.845.75.75 0 01-.61.61c-1.82.317-3.673.533-5.555.642a.58.58 0 01-.611-.581c0-.355.186-.676.401-.959.221-.29.349-.634.349-1.003 0-1.035-1.007-1.875-2.25-1.875s-2.25.84-2.25 1.875c0 .369.128.713.349 1.003.215.283.401.604.401.959a.641.641 0 01-.658.643 49.118 49.118 0 01-4.708-.36.75.75 0 01-.645-.878c.293-1.614.504-3.257.629-4.924A.53.53 0 005.337 15c-.355 0-.676.186-.959.401-.29.221-.634.349-1.003.349-1.036 0-1.875-1.007-1.875-2.25s.84-2.25 1.875-2.25c.369 0 .713.128 1.003.349.283.215.604.401.959.401a.656.656 0 00.659-.663 47.703 47.703 0 00-.31-4.82.75.75 0 01.83-.832c1.343.155 2.703.254 4.077.294a.64.64 0 00.657-.642z" />
</svg>
</div>
<p class="font-medium">
Start from a template
</p>
<NuxtLink v-track.select_form_base="{base:'template'}" :to="{name:'templates'}" class="absolute inset-0" />
<NuxtLink v-track.select_form_base="{ base: 'template' }" :to="{ name: 'templates' }" class="absolute inset-0" />
</div>
</div>
<div v-else-if="state=='ai'">
<a class="absolute top-4 left-4" href="#" role="button" @click.prevent="state='default'">
<div v-else-if="state == 'ai'">
<a class="absolute top-4 left-4" href="#" role="button" @click.prevent="state = 'default'">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-4 h-4 inline -mt-1">
<path fill-rule="evenodd" d="M7.72 12.53a.75.75 0 010-1.06l7.5-7.5a.75.75 0 111.06 1.06L9.31 12l6.97 6.97a.75.75 0 11-1.06 1.06l-7.5-7.5z" clip-rule="evenodd" />
<path fill-rule="evenodd"
d="M7.72 12.53a.75.75 0 010-1.06l7.5-7.5a.75.75 0 111.06 1.06L9.31 12l6.97 6.97a.75.75 0 11-1.06 1.06l-7.5-7.5z"
clip-rule="evenodd" />
</svg>
Back
</a>
<text-area-input label="Form Description" :disabled="loading?true:null" :form="aiForm" name="form_prompt" help="Give us a description of the form you want to build (the more details the better)"
placeholder="A simple contact form, with a name, email and message field"
/>
<text-area-input label="Form Description" :disabled="loading ? true : null" :form="aiForm" name="form_prompt"
help="Give us a description of the form you want to build (the more details the better)"
placeholder="A simple contact form, with a name, email and message field" />
<v-button class="w-full" :loading="loading" @click.prevent="generateForm">
Generate a form
</v-button>
@ -92,10 +89,11 @@
<script>
export default {
emits: ['close', 'form-generated'],
props: {
show: { type: Boolean, required: true }
},
setup () {
setup() {
return {
useAlert: useAlert(),
runtimeConfig: useRuntimeConfig()
@ -110,13 +108,13 @@ export default {
}),
computed: {
aiFeaturesEnabled () {
aiFeaturesEnabled() {
return this.runtimeConfig.public.aiFeaturesEnabled
}
},
methods: {
generateForm () {
generateForm() {
if (this.loading) return
this.loading = true
@ -126,10 +124,10 @@ export default {
}).catch(error => {
console.error(error)
this.loading = false
this.state = 'default'
this.state = 'ai'
})
},
fetchGeneratedForm (generationId) {
fetchGeneratedForm(generationId) {
// check every 4 seconds if form is generated
setTimeout(() => {
opnFetch('/forms/ai/' + generationId).then(data => {
@ -145,7 +143,7 @@ export default {
this.fetchGeneratedForm(generationId)
}
}).catch(error => {
if (error?.data?.message){
if (error?.data?.message) {
this.useAlert.error(error.data.message)
}
this.state = 'default'

View File

@ -0,0 +1,54 @@
{
"email": {
"name": "Email Notification",
"icon": "heroicons:envelope-20-solid",
"section_name": "Notifications",
"file_name": "EmailIntegration",
"is_pro": false,
"crisp_help_page_slug": "can-i-receive-notifications-on-form-submissions-134svqv"
},
"submission_confirmation": {
"name": "Submission Confirmation",
"icon": "heroicons:paper-airplane-20-solid",
"section_name": "Notifications",
"file_name": "SubmissionConfirmationIntegration",
"is_pro": true
},
"slack": {
"name": "Slack Notification",
"icon": "mdi:slack",
"section_name": "Notifications",
"file_name": "SlackIntegration",
"is_pro": false
},
"discord": {
"name": "Discord Notification",
"icon": "ic:baseline-discord",
"section_name": "Notifications",
"file_name": "DiscordIntegration",
"is_pro": true
},
"webhook": {
"name": "Webhook Notification",
"icon": "material-symbols:webhook",
"section_name": "Notifications",
"file_name": "WebhookIntegration",
"is_pro": false
},
"zapier": {
"name": "Zapier Integration",
"icon": "cib:zapier",
"section_name": "Notifications",
"file_name": "ZapierIntegration",
"is_pro": true,
"coming_soon": true
},
"google_sheets": {
"name": "Google Sheets",
"icon": "mdi:google-spreadsheet",
"section_name": "Databases",
"file_name": "GoogleSheetsIntegration",
"is_pro": true,
"coming_soon": true
}
}

View File

@ -1,6 +1,6 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
import runtimeConfig from "./runtimeConfig";
import { sentryVitePlugin } from "@sentry/vite-plugin";
import {sentryVitePlugin} from "@sentry/vite-plugin";
import sitemap from "./sitemap";
export default defineNuxtConfig({
@ -13,18 +13,11 @@ export default defineNuxtConfig({
'@vueuse/motion/nuxt',
'nuxt3-notifications',
'nuxt-simple-sitemap',
... process.env.NUXT_PUBLIC_GOOGLE_ANALYTICS_CODE ? ['nuxt-gtag'] : [],
'@nuxt/ui',
...process.env.NUXT_PUBLIC_GOOGLE_ANALYTICS_CODE ? ['nuxt-gtag'] : [],
],
build: {
transpile: ["vue-notion", "query-builder-vue-3","vue-signature-pad"],
},
postcss: {
plugins: {
'postcss-import': {},
'tailwindcss/nesting': {},
tailwindcss: {},
autoprefixer: {},
},
transpile: process.env.NODE_ENV === "development" ? [] : ["vue-notion", "query-builder-vue-3", "vue-signature-pad"],
},
experimental: {
inlineRouteRules: true
@ -50,6 +43,11 @@ export default defineNuxtConfig({
path: '~/components/pages',
pathPrefix: false,
},
{
path: '~/components/open/integrations',
pathPrefix: false,
global: true,
},
'~/components',
],
sourcemap: true,

3187
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -10,24 +10,24 @@
"postinstall": "nuxt prepare"
},
"devDependencies": {
"@nuxt/devtools": "latest",
"@nuxt/devtools": "~1.0.0",
"autoprefixer": "^10.4.16",
"nuxt": "^3.9.1",
"nuxt-gtag": "^1.1.2",
"nuxt-icon": "^0.6.10",
"nuxt-simple-sitemap": "^4.2.3",
"postcss": "^8.4.32",
"sass": "^1.69.6",
"tailwindcss": "^3.3.6",
"vue": "^3.3.10",
"vue-router": "^4.2.5"
},
"dependencies": {
"@codemirror/lang-html": "^6.4.7",
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
"@nuxt/ui": "^2.14.2",
"@pinia/nuxt": "^0.5.1",
"@sentry/vite-plugin": "^2.10.2",
"@sentry/vue": "^7.93.0",
"@tailwindcss/aspect-ratio": "^0.4.2",
"@vueuse/components": "^10.5.0",
"@vueuse/core": "^10.5.0",
"@vueuse/motion": "^2.0.0",
@ -38,11 +38,9 @@
"codemirror": "^6.0.1",
"crisp-sdk-web": "^1.0.21",
"date-fns": "^2.28.0",
"debounce": "^1.2.1",
"fuse.js": "^6.4.6",
"js-sha256": "^0.10.0",
"libphonenumber-js": "^1.10.44",
"lodash.throttle": "^4.1.1",
"nuxt3-notifications": "^1.1.9",
"object-to-formdata": "^4.5.1",
"pinia": "^2.1.7",
@ -55,6 +53,7 @@
"vue-codemirror": "^6.1.1",
"vue-confetti": "^2.3.0",
"vue-country-flag-next": "^2.3.2",
"vue-json-pretty": "^2.4.0",
"vue-notion": "^3.0.0-beta.1",
"vue-signature-pad": "^3.0.2",
"vue3-editor": "^0.1.1",

View File

@ -76,7 +76,6 @@ const passwordEntered = function (password) {
})
cookie.value = sha256(password)
nextTick(() => {
console.log('cookie value:',cookie.value)
loadForm().then(() => {
if (form.value?.is_password_protected) {
openCompleteForm.value.addPasswordError('Invalid password.')

View File

@ -5,12 +5,9 @@
<div class="w-full md:w-4/5 lg:w-3/5 md:mx-auto md:max-w-4xl px-4">
<div class="pt-4 pb-0">
<a href="#" class="flex text-blue mb-2 font-semibold text-sm" @click.prevent="goBack">
<svg class="w-3 h-3 text-blue mt-1 mr-1" viewBox="0 0 6 10" fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<svg class="w-3 h-3 text-blue mt-1 mr-1" viewBox="0 0 6 10" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 9L1 5L5 1" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"
stroke-linejoin="round"
/>
stroke-linejoin="round" />
</svg>
Go back
</a>
@ -20,47 +17,34 @@
{{ form.title }}
</h2>
<div class="flex">
<extra-menu class="mr-2" :form="form"/>
<extra-menu class="mr-2" :form="form" />
<v-button v-if="form.visibility === 'draft'" color="white"
class="mr-2 text-blue-600 hidden sm:block" @click="showDraftFormWarningNotification"
>
<svg class="w-6 h-6 inline -mt-1" 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"
/>
<v-button v-if="form.visibility === 'draft'" color="white" class="mr-2 text-blue-600 hidden sm:block"
@click="showDraftFormWarningNotification">
<svg class="w-6 h-6 inline -mt-1" 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"
/>
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</v-button>
<v-button v-else v-track.view_form_click="{form_id:form.id, form_slug:form.slug}" target="_blank"
:href="form.share_url" color="white"
class="mr-2 text-blue-600 hidden sm:block"
>
<svg class="w-6 h-6 inline -mt-1" 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"
/>
<v-button v-else v-track.view_form_click="{ form_id: form.id, form_slug: form.slug }" target="_blank"
:href="form.share_url" color="white" class="mr-2 text-blue-600 hidden sm:block">
<svg class="w-6 h-6 inline -mt-1" 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"
/>
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</v-button>
<v-button class="text-white" :to="{name: 'forms-slug-edit', params: {slug: slug}}">
<v-button class="text-white" :to="{ name: 'forms-slug-edit', params: { slug: slug } }">
<svg class="inline mr-1 -mt-1" width="18" height="17" viewBox="0 0 18 17" fill="none"
xmlns="http://www.w3.org/2000/svg"
>
xmlns="http://www.w3.org/2000/svg">
<path
d="M8.99998 15.6662H16.5M1.5 15.6662H2.89545C3.3031 15.6662 3.50693 15.6662 3.69874 15.6202C3.8688 15.5793 4.03138 15.512 4.1805 15.4206C4.34869 15.3175 4.49282 15.1734 4.78107 14.8852L15.25 4.4162C15.9404 3.72585 15.9404 2.60656 15.25 1.9162C14.5597 1.22585 13.4404 1.22585 12.75 1.9162L2.28105 12.3852C1.9928 12.6734 1.84867 12.8175 1.7456 12.9857C1.65422 13.1348 1.58688 13.2974 1.54605 13.4675C1.5 13.6593 1.5 13.8631 1.5 14.2708V15.6662Z"
stroke="currentColor" stroke-width="1.67" stroke-linecap="round" stroke-linejoin="round"
/>
stroke="currentColor" stroke-width="1.67" stroke-linecap="round" stroke-linejoin="round" />
</svg>
Edit form
</v-button>
@ -74,48 +58,45 @@
</span>
<span>- Edited {{ form.last_edited_human }}</span>
</p>
<div v-if="['draft','closed'].includes(form.visibility) || (form.tags && form.tags.length > 0)"
class="mt-2 flex items-center flex-wrap gap-3"
>
<span v-if="form.visibility=='draft'"
class="inline-flex items-center rounded-full bg-yellow-100 px-2 py-1 text-xs font-medium text-yellow-600 ring-1 ring-inset ring-gray-500/10 dark:text-white dark:bg-gray-700"
>
<div v-if="['draft', 'closed'].includes(form.visibility) || (form.tags && form.tags.length > 0)"
class="mt-2 flex items-center flex-wrap gap-3">
<span v-if="form.visibility == 'draft'"
class="inline-flex items-center rounded-full bg-yellow-100 px-2 py-1 text-xs font-medium text-yellow-600 ring-1 ring-inset ring-gray-500/10 dark:text-white dark:bg-gray-700">
Draft - not publicly accessible
</span>
<span v-else-if="form.visibility=='closed'"
class="inline-flex items-center rounded-full bg-yellow-100 px-2 py-1 text-xs font-medium text-yellow-600 ring-1 ring-inset ring-gray-500/10 dark:text-white dark:bg-gray-700"
>
<span v-else-if="form.visibility == 'closed'"
class="inline-flex items-center rounded-full bg-yellow-100 px-2 py-1 text-xs font-medium text-yellow-600 ring-1 ring-inset ring-gray-500/10 dark:text-white dark:bg-gray-700">
Closed - won't accept new submissions
</span>
<span v-for="(tag,i) in form.tags" :key="tag"
class="inline-flex items-center rounded-full bg-gray-50 px-2 py-1 text-xs font-medium text-gray-600 ring-1 ring-inset ring-gray-500/10 dark:text-white dark:bg-gray-700"
>
<span v-for="(tag, i) in form.tags" :key="tag"
class="inline-flex items-center rounded-full bg-gray-50 px-2 py-1 text-xs font-medium text-gray-600 ring-1 ring-inset ring-gray-500/10 dark:text-white dark:bg-gray-700">
{{ tag }}
</span>
</div>
<p v-if="form.closes_at" class="text-yellow-500">
<span v-if="form.is_closed"> This form stopped accepting submissions on the {{
displayClosesDate
}} </span>
<span v-if="form.is_closed"> This form stopped accepting submissions on the {{
displayClosesDate
}} </span>
<span v-else> This form will stop accepting submissions on the {{ displayClosesDate }} </span>
</p>
<p v-if="form.max_submissions_count > 0" class="text-yellow-500">
<span v-if="form.max_number_of_submissions_reached"> The form is now closed because it reached its limit of {{
<span v-if="form.max_number_of_submissions_reached"> The form is now closed because it reached its limit of
{{
form.max_submissions_count
}} submissions. </span>
<span v-else> This form will stop accepting submissions after {{ form.max_submissions_count }} submissions. </span>
<span v-else> This form will stop accepting submissions after {{ form.max_submissions_count }} submissions.
</span>
</p>
<form-cleanings class="mt-4" :form="form"/>
<form-cleanings class="mt-4" :form="form" />
<div class="border-b border-gray-200 dark:border-gray-700">
<ul class="flex flex-wrap -mb-px text-sm font-medium text-center">
<li v-for="(tab, i) in tabsList" :key="i+1" class="mr-6">
<li v-for="(tab, i) in tabsList" :key="i + 1" class="mr-6">
<nuxt-link :to="{ name: tab.route }"
class="hover:no-underline inline-block py-4 rounded-t-lg border-b-2 text-gray-500 hover:text-gray-600"
active-class="text-blue-600 hover:text-blue-900 dark:text-blue-500 dark:hover:text-blue-500 border-blue-600 dark:border-blue-500"
>
class="hover:no-underline inline-block py-4 rounded-t-lg border-b-2 text-gray-500 hover:text-gray-600"
active-class="text-blue-600 hover:text-blue-900 dark:text-blue-500 dark:hover:text-blue-500 border-blue-600 dark:border-blue-500">
{{ tab.name }}
</nuxt-link>
</li>
@ -125,11 +106,11 @@
</div>
</div>
<div class="flex flex-col bg-white">
<NuxtPage :form="form"/>
<NuxtPage :form="form" />
</div>
</template>
<div v-else-if="loading" class="text-center w-full p-5">
<Loader class="h-6 w-6 mx-auto"/>
<Loader class="h-6 w-6 mx-auto" />
</div>
<div v-else class="text-center w-full p-5">
Form not found.
@ -138,7 +119,7 @@
</template>
<script setup>
import {computed} from 'vue'
import { computed } from 'vue'
import ProTag from '~/components/global/ProTag.vue'
import VButton from '~/components/global/VButton.vue'
import ExtraMenu from '../../../components/pages/forms/show/ExtraMenu.vue'
@ -181,6 +162,10 @@ const tabsList = [
name: 'Submissions',
route: 'forms-slug-show-submissions'
},
{
name: 'Integrations',
route: 'forms-slug-show-integrations'
},
{
name: 'Analytics',
route: 'forms-slug-show-stats'
@ -207,7 +192,7 @@ watch(() => form?.value?.id, (id) => {
})
const goBack = () => {
useRouter().push({name: 'home'})
useRouter().push({ name: 'home' })
}
const showDraftFormWarningNotification = () => {

View File

@ -0,0 +1,91 @@
<template>
<div class="w-full md:w-4/5 lg:w-3/5 md:mx-auto md:max-w-4xl p-4">
<div class="mb-20">
<h1 class="font-semibold mt-4 text-xl">
Your integrations
</h1>
<div class="text-sm text-gray-500">
Read, update and create data with dozens of 3rd-party integrations
</div>
<div v-if="integrationsLoading" class="my-6">
<Loader class="h-6 w-6 mx-auto"/>
</div>
<div v-else-if="formIntegrationsList.length" class="my-6">
<IntegrationCard v-for="(row) in formIntegrationsList" :key="row.id" :integration="row" :form="form"/>
</div>
<div class="text-gray-500 border shadow rounded-md p-5 mt-4" v-else>
No integration yet form this form.
</div>
<h1 class="font-semibold mt-8 text-xl">
Add a new integration
</h1>
<div v-for="(section, sectionName) in sectionsList" :key="sectionName" class="mb-8">
<h3 class="text-gray-500">
{{ sectionName }}
</h3>
<div class="flex flex-wrap mt-2 gap-4">
<IntegrationListOption v-for="(sectionItem, sectionItemKey) in section"
@select="openIntegrationModal"
:key="sectionItemKey" :integration="sectionItem"/>
</div>
</div>
<IntegrationModal v-if="form && selectedIntegrationKey && selectedIntegration" :form="form"
:integration="selectedIntegration" :integrationKey="selectedIntegrationKey"
:show="showIntegrationModal"
@close="closeIntegrationModal"/>
</div>
</div>
</template>
<script setup>
import {computed} from 'vue'
import IntegrationModal from '~/components/open/integrations/components/IntegrationModal.vue'
const props = defineProps({
form: {type: Object, required: true}
})
definePageMeta({
middleware: "auth"
})
useOpnSeoMeta({
title: (props.form) ? 'Form Integrations - ' + props.form.title : 'Form Integrations'
})
const alert = useAlert()
const crisp = useCrisp()
const route = useRoute()
const formIntegrationsStore = useFormIntegrationsStore()
const integrationsLoading = computed(() => formIntegrationsStore.loading)
const integrations = computed(() => formIntegrationsStore.availableIntegrations)
const sectionsList = computed(() => formIntegrationsStore.integrationsBySection)
const formIntegrationsList = computed(() => formIntegrationsStore.getAllByFormId(props.form.id))
let showIntegrationModal = ref(false)
let selectedIntegrationKey = ref(null)
let selectedIntegration = ref(null)
onMounted(() => {
formIntegrationsStore.fetchFormIntegrations(props.form.id)
})
const openIntegrationModal = (itemKey) => {
if (!itemKey || !integrations.value.has(itemKey)) return alert.error('Integration not found')
if (integrations.value.get(itemKey).coming_soon) return alert.warning('This integration is not available yet')
selectedIntegrationKey.value = itemKey
selectedIntegration.value = integrations.value.get(selectedIntegrationKey.value)
showIntegrationModal.value = true
}
const closeIntegrationModal = () => {
showIntegrationModal.value = false
nextTick(() => {
selectedIntegrationKey.value = null
selectedIntegration.value = null
})
}
</script>

View File

@ -17,7 +17,6 @@ useOpnSeoMeta({
})
onBeforeRouteLeave(() => {
console.log('Clearing store state')
useRecordsStore().resetState()
})

71
client/stores/form_integrations.js vendored Normal file
View File

@ -0,0 +1,71 @@
import {defineStore} from 'pinia'
import {useContentStore} from "~/composables/stores/useContentStore.js";
import integrationsList from '~/data/forms/integrations.json'
export const formIntegrationsEndpoint = '/open/forms/{formid}/integrations'
export const useFormIntegrationsStore = defineStore('form_integrations', () => {
const contentStore = useContentStore()
const integrations = ref(new Map)
const availableIntegrations = computed(() => {
const user = useAuthStore().user
if (!user) return integrations.value
const enrichedIntegrations = new Map()
for (const [key, integration] of integrations.value.entries()) {
enrichedIntegrations.set(key, {
...integration,
id: key,
requires_subscription: !user.is_subscribed && integration.is_pro
})
}
return enrichedIntegrations
})
const integrationsBySection = computed(() => {
const groupedObject = {};
for (const [key, integration] of availableIntegrations.value.entries()) {
const sectionName = integration.section_name;
if (!groupedObject[sectionName]) {
groupedObject[sectionName] = {};
}
groupedObject[sectionName][key] = integration
}
return groupedObject;
})
const fetchFormIntegrations = (formId) => {
contentStore.resetState()
contentStore.startLoading()
return useOpnApi(formIntegrationsEndpoint.replace('{formid}', formId)).then((response) => {
contentStore.save(response.data.value)
contentStore.stopLoading()
})
}
const getAllByFormId = (formId) => {
return contentStore.getAll.value.filter((item) => {
return (item.form_id) ? item.form_id === formId : false
})
}
const initIntegrations = () => {
if (integrations.value.size === 0) {
integrations.value = new Map(Object.entries(integrationsList))
}
}
initIntegrations()
return {
...contentStore,
initIntegrations,
availableIntegrations,
integrationsBySection,
fetchFormIntegrations,
getAllByFormId,
}
})

View File

@ -36,7 +36,7 @@
"spatie/laravel-sitemap": "^6.0",
"spatie/laravel-sluggable": "^3.0",
"spatie/laravel-webhook-server": "^3.1.2",
"stevebauman/purify": "^4.0",
"stevebauman/purify": "^v6.2.0",
"tymon/jwt-auth": "^1.0.2",
"vinkla/hashids": "^10.0"
},

1495
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,156 +1,133 @@
<?php
use Stevebauman\Purify\Definitions\Html5Definition;
return [
/*
|--------------------------------------------------------------------------
| Settings
| Default Config
|--------------------------------------------------------------------------
|
| The configuration settings array is passed directly to HTMLPurifier.
|
| Feel free to add / remove / customize these attributes as you wish.
|
| Documentation: http://htmlpurifier.org/live/configdoc/plain.html
| This option defines the default config that is provided to HTMLPurifier.
|
*/
'settings' => [
'default' => 'default',
/*
|--------------------------------------------------------------------------
| Core.Encoding
|--------------------------------------------------------------------------
|
| The encoding to convert input to.
|
| http://htmlpurifier.org/live/configdoc/plain.html#Core.Encoding
|
*/
/*
|--------------------------------------------------------------------------
| Config sets
|--------------------------------------------------------------------------
|
| Here you may configure various sets of configuration for differentiated use of HTMLPurifier.
| A specific set of configuration can be applied by calling the "config($name)" method on
| a Purify instance. Feel free to add/remove/customize these attributes as you wish.
|
| Documentation: http://htmlpurifier.org/live/configdoc/plain.html
|
| Core.Encoding The encoding to convert input to.
| HTML.Doctype Doctype to use during filtering.
| HTML.Allowed The allowed HTML Elements with their allowed attributes.
| HTML.ForbiddenElements The forbidden HTML elements. Elements that are listed in this
| string will be removed, however their content will remain.
| CSS.AllowedProperties The Allowed CSS properties.
| AutoFormat.AutoParagraph Newlines are converted in to paragraphs whenever possible.
| AutoFormat.RemoveEmpty Remove empty elements that contribute no semantic information to the document.
|
*/
'Core.Encoding' => 'utf-8',
'configs' => [
/*
|--------------------------------------------------------------------------
| Core.SerializerPath
|--------------------------------------------------------------------------
|
| The HTML purifier serializer cache path.
|
| http://htmlpurifier.org/live/configdoc/plain.html#Cache.SerializerPath
|
*/
'default' => [
'HTML.Allowed' => 'h1,h2,b,strong,i,em,a[href|title],ul,ol,li,p,br,span,*[style]',
'HTML.ForbiddenElements' => '',
'CSS.AllowedProperties' => 'font,font-size,font-weight,font-style,text-decoration,color,text-align',
// Purifier config cache disable to run on vapor (no tmp on lambda)
'Cache.DefinitionImpl' => null,
// 'Cache.SerializerPath' => storage_path('purify'),
'AutoFormat.AutoParagraph' => false,
'AutoFormat.RemoveEmpty' => false,
/*
|--------------------------------------------------------------------------
| HTML.Doctype
|--------------------------------------------------------------------------
|
| Doctype to use during filtering.
|
| http://htmlpurifier.org/live/configdoc/plain.html#HTML.Doctype
|
*/
/*
* Protect Links
*/
'HTML.Nofollow' => true,
'HTML.TargetBlank' => true,
'HTML.TargetNoreferrer' => true,
'HTML.TargetNoopener' => true,
'HTML.Doctype' => 'XHTML 1.0 Strict',
/*
|--------------------------------------------------------------------------
| HTML.Allowed
|--------------------------------------------------------------------------
|
| The allowed HTML Elements with their allowed attributes.
|
| http://htmlpurifier.org/live/configdoc/plain.html#HTML.Allowed
|
*/
'HTML.Allowed' => 'h1,h2,b,strong,i,em,a[href|title],ul,ol,li,p[style],br,span',
/*
|--------------------------------------------------------------------------
| HTML.ForbiddenElements
|--------------------------------------------------------------------------
|
| The forbidden HTML elements. Elements that are listed in
| this string will be removed, however their content will remain.
|
| For example if 'p' is inside the string, the string: '<p>Test</p>',
|
| Will be cleaned to: 'Test'
|
| http://htmlpurifier.org/live/configdoc/plain.html#HTML.ForbiddenElements
|
*/
'HTML.ForbiddenElements' => '',
/*
|--------------------------------------------------------------------------
| CSS.AllowedProperties
|--------------------------------------------------------------------------
|
| The Allowed CSS properties.
|
| http://htmlpurifier.org/live/configdoc/plain.html#CSS.AllowedProperties
|
*/
'CSS.AllowedProperties' => 'font,font-size,font-weight,font-style,font-family,text-decoration,padding-left,color,background-color,text-align',
/*
|--------------------------------------------------------------------------
| AutoFormat.AutoParagraph
|--------------------------------------------------------------------------
|
| The Allowed CSS properties.
|
| This directive turns on auto-paragraphing, where double
| newlines are converted in to paragraphs whenever possible.
|
| http://htmlpurifier.org/live/configdoc/plain.html#AutoFormat.AutoParagraph
|
*/
'AutoFormat.AutoParagraph' => false,
/*
|--------------------------------------------------------------------------
| AutoFormat.RemoveEmpty
|--------------------------------------------------------------------------
|
| When enabled, HTML Purifier will attempt to remove empty
| elements that contribute no semantic information to the document.
|
| http://htmlpurifier.org/live/configdoc/plain.html#AutoFormat.RemoveEmpty
|
*/
'AutoFormat.RemoveEmpty' => false,
/*
* Protect Links
*/
'HTML.Nofollow' => true,
'HTML.TargetBlank' => true,
'HTML.TargetNoreferrer' => true,
'HTML.TargetNoopener' => true,
/*
* Allow notion link: allow Notion custom protocol
*/
'URI.AllowedSchemes' => [
'http' => true,
'https' => true,
'mailto' => true,
'tel' => true,
'notion' => true,
/*
* Allow Notion links: allow Notion custom protocol
*/
'URI.AllowedSchemes' => [
'http' => true,
'https' => true,
'mailto' => true,
'tel' => true,
'notion' => true,
],
],
],
/*
|--------------------------------------------------------------------------
| HTMLPurifier definitions
|--------------------------------------------------------------------------
|
| Here you may specify a class that augments the HTML definitions used by
| HTMLPurifier. Additional HTML5 definitions are provided out of the box.
| When specifying a custom class, make sure it implements the interface:
|
| \Stevebauman\Purify\Definitions\Definition
|
| Note that these definitions are applied to every Purifier instance.
|
| Documentation: http://htmlpurifier.org/docs/enduser-customize.html
|
*/
'definitions' => Html5Definition::class,
/*
|--------------------------------------------------------------------------
| HTMLPurifier CSS definitions
|--------------------------------------------------------------------------
|
| Here you may specify a class that augments the CSS definitions used by
| HTMLPurifier. When specifying a custom class, make sure it implements
| the interface:
|
| \Stevebauman\Purify\Definitions\CssDefinition
|
| Note that these definitions are applied to every Purifier instance.
|
| CSS should be extending $definition->info['css-attribute'] = values
| See HTMLPurifier_CSSDefinition for further explanation
|
*/
'css-definitions' => null,
/*
|--------------------------------------------------------------------------
| Serializer
|--------------------------------------------------------------------------
|
| The storage implementation where HTMLPurifier can store its serializer files.
| If the filesystem cache is in use, the path must be writable through the
| storage disk by the web server, otherwise an exception will be thrown.
|
*/
'serializer' => [
'driver' => env('CACHE_DRIVER', 'file'),
'cache' => \Stevebauman\Purify\Cache\CacheDefinitionCache::class,
],
// 'serializer' => [
// 'disk' => env('FILESYSTEM_DISK', 'local'),
// 'path' => 'purify',
// 'cache' => \Stevebauman\Purify\Cache\FilesystemDefinitionCache::class,
// ],
];

View File

@ -0,0 +1,45 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Query\Expression;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class () extends Migration {
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
$driver = DB::getDriverName();
Schema::create('form_integrations', function (Blueprint $table) use ($driver) {
$table->id();
$table->foreignIdFor(\App\Models\Forms\Form::class, 'form_id');
$table->string('status');
$table->string('integration_id');
if ($driver === 'mysql') {
$table->jsonb('logic')->default(new Expression("(JSON_OBJECT())"));
$table->jsonb('data')->default(new Expression("(JSON_OBJECT())"));
} else {
$table->jsonb('logic')->default('{}');
$table->jsonb('data')->default('{}');
}
$table->string('oauth_id')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('form_integrations');
}
};

View File

@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Query\Expression;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class () extends Migration {
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
$driver = DB::getDriverName();
Schema::create('form_integrations_events', function (Blueprint $table) use ($driver) {
$table->id();
$table->foreignIdFor(\App\Models\Integration\FormIntegration::class, 'integration_id');
$table->string('status');
if ($driver === 'mysql') {
$table->jsonb('data')->default(new Expression("(JSON_OBJECT())"));
} else {
$table->jsonb('data')->default('{}');
}
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('form_integrations_events');
}
};

View File

@ -0,0 +1,54 @@
{
"email": {
"name": "Email Notification",
"icon": "heroicons:envelope-20-solid",
"section_name": "Notifications",
"file_name": "EmailIntegration",
"is_pro": false,
"crisp_help_page_slug": "can-i-receive-notifications-on-form-submissions-134svqv"
},
"submission_confirmation": {
"name": "Submission Confirmation",
"icon": "heroicons:paper-airplane-20-solid",
"section_name": "Notifications",
"file_name": "SubmissionConfirmationIntegration",
"is_pro": true
},
"slack": {
"name": "Slack Notification",
"icon": "mdi:slack",
"section_name": "Notifications",
"file_name": "SlackIntegration",
"is_pro": false
},
"discord": {
"name": "Discord Notification",
"icon": "ic:baseline-discord",
"section_name": "Notifications",
"file_name": "DiscordIntegration",
"is_pro": true
},
"webhook": {
"name": "Webhook Notification",
"icon": "material-symbols:webhook",
"section_name": "Notifications",
"file_name": "WebhookIntegration",
"is_pro": false
},
"zapier": {
"name": "Zapier Integration",
"icon": "logos:zapier",
"section_name": "Notifications",
"file_name": "ZapierIntegration",
"is_pro": true,
"coming_soon": true
},
"google_sheets": {
"name": "Google Sheets",
"icon": "mdi:google-spreadsheet",
"section_name": "Databases",
"file_name": "GoogleSheetsIntegration",
"is_pro": true,
"coming_soon": true
}
}

View File

@ -1,6 +1,6 @@
@component('mail::message', ['noBranding' => $noBranding])
{!! $form->notification_body !!}
{!! $integrationData->notification_body !!}
@if($form->editable_submissions)
@component('mail::button', ['url' => $form->share_url.'?submission_id='.$submission_id])
@ -8,7 +8,7 @@
@endcomponent
@endif
@if($form->notifications_include_submission)
@if($integrationData->notifications_include_submission)
As a reminder, here are your answers:
@foreach($fields as $field)
@ -18,9 +18,9 @@ As a reminder, here are your answers:
**{{$field['name']}}**
@if($field['type'] == 'files')
<br/>
<br />
@foreach($field['email_data'] as $link)
<a href="{{$link['signed_url']}}">{{$link['label']}}</a> <br/>
<a href="{{$link['signed_url']}}">{{$link['label']}}</a> <br />
@endforeach
@else
{!! is_array($field['value'])?implode(',',$field['value']):$field['value']!!}

View File

@ -0,0 +1,13 @@
@component('mail::message')
Hello,
We tried to trigger a **{{$integration_name}}** for your form "{{$form->title}}", but it failed. Here is the error that we got:
@component('mail::panel')
{{$error}}
@endcomponent
Contact us via the website live chat if you need any help.
@endcomponent

View File

@ -10,6 +10,8 @@ use App\Http\Controllers\Auth\VerificationController;
use App\Http\Controllers\Forms\FormController;
use App\Http\Controllers\Forms\FormStatsController;
use App\Http\Controllers\Forms\FormSubmissionController;
use App\Http\Controllers\Forms\Integration\FormIntegrationsController;
use App\Http\Controllers\Forms\Integration\FormIntegrationsEventController;
use App\Http\Controllers\Forms\Integration\FormZapierWebhookController;
use App\Http\Controllers\Forms\PublicFormController;
use App\Http\Controllers\Forms\RecordController;
@ -47,8 +49,8 @@ Route::group(['middleware' => 'auth:api'], function () {
Route::put('/update-customer-details', [SubscriptionController::class, 'updateStripeDetails'])->name('update-stripe-details');
Route::get('/new/{subscription}/{plan}/checkout/{trial?}', [SubscriptionController::class, 'checkout'])
->name('checkout')
->where('subscription', '('.implode('|', SubscriptionController::SUBSCRIPTION_NAMES).')')
->where('plan', '('.implode('|', SubscriptionController::SUBSCRIPTION_PLANS).')');
->where('subscription', '(' . implode('|', SubscriptionController::SUBSCRIPTION_NAMES) . ')')
->where('plan', '(' . implode('|', SubscriptionController::SUBSCRIPTION_PLANS) . ')');
Route::get('/billing-portal', [SubscriptionController::class, 'billingPortal'])->name('billing-portal');
});
@ -135,7 +137,26 @@ Route::group(['middleware' => 'auth:api'], function () {
'/webhooks/zapier/{id}',
[FormZapierWebhookController::class, 'delete']
)->name('integrations.zapier-hooks.delete');
Route::get(
'/{id}/integrations',
[FormIntegrationsController::class, 'index']
)->name('integrations');
Route::post(
'/{id}/integration',
[FormIntegrationsController::class, 'create']
)->name('integration.create');
Route::put(
'/{id}/integration/{integrationid}',
[FormIntegrationsController::class, 'update']
)->name('integration.update');
Route::delete(
'/{id}/integration/{integrationid}',
[FormIntegrationsController::class, 'destroy']
)->name('integration.destroy');
Route::get(
'/{id}/integration/{integrationid}/events',
[FormIntegrationsEventController::class, 'index']
)->name('integrations.events');
});
});
@ -225,7 +246,7 @@ Route::post(
)->middleware([]);
Route::get('local/temp/{path}', function (Request $request, string $path) {
if (! $request->hasValidSignature()) {
if (!$request->hasValidSignature()) {
abort(401);
}

View File

@ -6,8 +6,8 @@ use Illuminate\Support\Facades\Mail;
it('creates confirmation emails with the submitted data', function () {
$user = $this->actingAsUser();
$workspace = $this->createUserWorkspace($user);
$form = $this->createForm($user, $workspace, [
'send_submission_confirmation' => true,
$form = $this->createForm($user, $workspace);
$integrationData = $this->createFormIntegration('submission_confirmation', $form->id, [
'notifications_include_submission' => true,
'notification_sender' => 'Custom Sender',
'notification_subject' => 'Test subject',
@ -20,7 +20,7 @@ it('creates confirmation emails with the submitted data', function () {
})['id'] => 'test@test.com',
];
$event = new \App\Events\Forms\FormSubmitted($form, $formData);
$mailable = new SubmissionConfirmationMail($event);
$mailable = new SubmissionConfirmationMail($event, $integrationData);
$mailable->assertSeeInHtml('Test body')
->assertSeeInHtml('As a reminder, here are your answers:')
->assertSeeInHtml('You are receiving this email because you answered the form:');
@ -29,8 +29,8 @@ it('creates confirmation emails with the submitted data', function () {
it('creates confirmation emails without the submitted data', function () {
$user = $this->actingAsUser();
$workspace = $this->createUserWorkspace($user);
$form = $this->createForm($user, $workspace, [
'send_submission_confirmation' => true,
$form = $this->createForm($user, $workspace);
$integrationData = $this->createFormIntegration('submission_confirmation', $form->id, [
'notifications_include_submission' => false,
'notification_sender' => 'Custom Sender',
'notification_subject' => 'Test subject',
@ -43,7 +43,7 @@ it('creates confirmation emails without the submitted data', function () {
})['id'] => 'test@test.com',
];
$event = new \App\Events\Forms\FormSubmitted($form, $formData);
$mailable = new SubmissionConfirmationMail($event);
$mailable = new SubmissionConfirmationMail($event, $integrationData);
$mailable->assertSeeInHtml('Test body')
->assertDontSeeInHtml('As a reminder, here are your answers:')
->assertSeeInHtml('You are receiving this email because you answered the form:');
@ -52,11 +52,15 @@ it('creates confirmation emails without the submitted data', function () {
it('sends a confirmation email if needed', function () {
$user = $this->actingAsProUser();
$workspace = $this->createUserWorkspace($user);
$form = $this->createForm($user, $workspace, [
'send_submission_confirmation' => true,
$form = $this->createForm($user, $workspace);
$this->createFormIntegration('submission_confirmation', $form->id, [
'notifications_include_submission' => true,
'notification_sender' => 'Custom Sender',
'notification_subject' => 'Test subject',
'notification_body' => 'Test body',
]);
$emailProperty = collect($form->properties)->first(function ($property) {
return $property['type'] == 'email';
});
@ -84,9 +88,7 @@ it('sends a confirmation email if needed', function () {
it('does not send a confirmation email if not needed', function () {
$user = $this->actingAsUser();
$workspace = $this->createUserWorkspace($user);
$form = $this->createForm($user, $workspace, [
'send_submission_confirmation' => false,
]);
$form = $this->createForm($user, $workspace);
$emailProperty = collect($form->properties)->first(function ($property) {
return $property['type'] == 'email';
});
@ -114,15 +116,13 @@ it('does not send a confirmation email if not needed', function () {
it('does send a confirmation email even when reply to is broken', function () {
$user = $this->actingAsProUser();
$workspace = $this->createUserWorkspace($user);
$form = $this->createForm($user, $workspace, [
'send_submission_confirmation' => true,
$form = $this->createForm($user, $workspace);
$integrationData = $this->createFormIntegration('submission_confirmation', $form->id, [
'notifications_include_submission' => true,
'notification_sender' => 'Custom Sender',
'notification_subject' => 'Test subject',
'notification_body' => 'Test body',
'notification_settings' => [
'confirmation_reply_to' => 'invalid-email',
]
'confirmation_reply_to' => ''
]);
$emailProperty = collect($form->properties)->first(function ($property) {
@ -132,7 +132,7 @@ it('does send a confirmation email even when reply to is broken', function () {
$emailProperty['id'] => 'test@test.com',
];
$event = new \App\Events\Forms\FormSubmitted($form, $formData);
$mailable = new SubmissionConfirmationMail($event);
$mailable = new SubmissionConfirmationMail($event, $integrationData);
$mailable->assertSeeInHtml('Test body')
->assertSeeInHtml('As a reminder, here are your answers:')
->assertSeeInHtml('You are receiving this email because you answered the form:')

View File

@ -0,0 +1,28 @@
<?php
it('can fetch form integration events', function () {
$user = $this->actingAsProUser();
$workspace = $this->createUserWorkspace($user);
$form = $this->createForm($user, $workspace);
$data = [
'status' => true,
'integration_id' => 'email',
'logic' => null,
'settings' => [
'notification_emails' => 'test@test.com',
'notification_reply_to' => null
]
];
$response = $this->postJson(route('open.forms.integration.create', $form->id), $data)
->assertSuccessful()
->assertJson([
'type' => 'success',
'message' => 'Form Integration was created.'
]);
$this->getJson(route('open.forms.integrations.events', [$form->id, $response->json('form_integration.id')]))
->assertSuccessful()
->assertJsonCount(0);
});

View File

@ -0,0 +1,44 @@
<?php
it('can CRUD form integration', function () {
$user = $this->actingAsProUser();
$workspace = $this->createUserWorkspace($user);
$form = $this->createForm($user, $workspace);
$data = [
'status' => true,
'integration_id' => 'email',
'logic' => null,
'settings' => [
'notification_emails' => 'test@test.com',
'notification_reply_to' => null
]
];
$response = $this->postJson(route('open.forms.integration.create', $form->id), $data)
->assertSuccessful()
->assertJson([
'type' => 'success',
'message' => 'Form Integration was created.'
]);
$this->getJson(route('open.forms.integrations', $form->id))
->assertSuccessful()
->assertJsonCount(1);
ray($response->json('form_integration.id'), $response->json());
$this->putJson(route('open.forms.integration.update', [$form->id, $response->json('form_integration.id')]), $data)
->assertSuccessful()
->assertJson([
'type' => 'success',
'message' => 'Form Integration was updated.'
]);
$this->deleteJson(route('open.forms.integration.destroy', [$form->id, $response->json('form_integration.id')]), $data)
->assertSuccessful()
->assertJson([
'type' => 'success',
'message' => 'Form Integration was deleted.'
]);
});

View File

@ -214,4 +214,23 @@ trait TestHelpers
}
$this->assertGuest();
}
public function createFormIntegration($integrationId, $formId, $settings = [])
{
$data = [
'status' => true,
'integration_id' => $integrationId,
'logic' => null,
'settings' => $settings
];
$response = $this->postJson(route('open.forms.integration.create', $formId), $data)
->assertSuccessful()
->assertJson([
'type' => 'success',
'message' => 'Form Integration was created.'
]);
return (object) $response->json('form_integration.data');
}
}