Apply Mentions everywhere (#595)
* variables and mentions * fix lint * add missing changes * fix tests * update quilly, fix bugs * fix lint * apply fixes * apply fixes * Fix MentionParser * Apply Mentions everywhere * Fix MentionParserTest * Small refactoring * Fixing quill import issues * Polished email integration, added customer sender mail * Add missing changes * improve migration command --------- Co-authored-by: Frank <csskfaves@gmail.com> Co-authored-by: Julien Nahum <julien@nahum.net>
This commit is contained in:
parent
2fdf2a439b
commit
dad5c825b1
|
|
@ -0,0 +1,108 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Forms\Form;
|
||||
use App\Models\Integration\FormIntegration;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class EmailNotificationMigration extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'forms:email-notification-migration';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'One Time Only -- Migrate Email & Submission Notifications to new Email Integration';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
if (app()->environment('production')) {
|
||||
if (!$this->confirm('Are you sure you want to run this migration in production?')) {
|
||||
$this->info('Migration aborted.');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
$query = FormIntegration::whereIn('integration_id', ['email', 'submission_confirmation'])
|
||||
->whereHas('form');
|
||||
$totalCount = $query->count();
|
||||
$progressBar = $this->output->createProgressBar($totalCount);
|
||||
$progressBar->start();
|
||||
|
||||
$query->with('form')->chunk(100, function ($integrations) use ($progressBar) {
|
||||
foreach ($integrations as $integration) {
|
||||
try {
|
||||
$this->updateIntegration($integration);
|
||||
} catch (\Exception $e) {
|
||||
$this->error('Error updating integration ' . $integration->id . '. Error: ' . $e->getMessage());
|
||||
ray($e);
|
||||
}
|
||||
$progressBar->advance();
|
||||
}
|
||||
});
|
||||
|
||||
$progressBar->finish();
|
||||
$this->newLine();
|
||||
|
||||
$this->line('Migration Done');
|
||||
}
|
||||
|
||||
public function updateIntegration(FormIntegration $integration)
|
||||
{
|
||||
if (!$integration->form) {
|
||||
return;
|
||||
}
|
||||
$existingData = $integration->data;
|
||||
if ($integration->integration_id === 'email') {
|
||||
$integration->data = [
|
||||
'send_to' => $existingData->notification_emails ?? null,
|
||||
'sender_name' => 'OpnForm',
|
||||
'subject' => 'New form submission',
|
||||
'email_content' => 'Hello there 👋 <br>New form submission received.',
|
||||
'include_submission_data' => true,
|
||||
'include_hidden_fields_submission_data' => false,
|
||||
'reply_to' => $existingData->notification_reply_to ?? null
|
||||
];
|
||||
} elseif ($integration->integration_id === 'submission_confirmation') {
|
||||
$integration->integration_id = 'email';
|
||||
$integration->data = [
|
||||
'send_to' => $this->getMentionHtml($integration->form),
|
||||
'sender_name' => $existingData->notification_sender,
|
||||
'subject' => $existingData->notification_subject,
|
||||
'email_content' => $existingData->notification_body,
|
||||
'include_submission_data' => $existingData->notifications_include_submission,
|
||||
'include_hidden_fields_submission_data' => false,
|
||||
'reply_to' => $existingData->confirmation_reply_to ?? null
|
||||
];
|
||||
}
|
||||
return $integration->save();
|
||||
}
|
||||
|
||||
private function getMentionHtml(Form $form)
|
||||
{
|
||||
$emailField = $this->getRespondentEmail($form);
|
||||
return $emailField ? '<span mention-field-id="' . $emailField['id'] . '" mention-field-name="' . $emailField['name'] . '" mention-fallback="" contenteditable="false" mention="true">' . $emailField['name'] . '</span>' : '';
|
||||
}
|
||||
|
||||
private function getRespondentEmail(Form $form)
|
||||
{
|
||||
$emailFields = collect($form->properties)->filter(function ($field) {
|
||||
$hidden = $field['hidden'] ?? false;
|
||||
return !$hidden && $field['type'] == 'email';
|
||||
});
|
||||
|
||||
return $emailFields->count() > 0 ? $emailFields->first() : null;
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ use App\Http\Resources\FormSubmissionResource;
|
|||
use App\Jobs\Form\StoreFormSubmissionJob;
|
||||
use App\Models\Forms\Form;
|
||||
use App\Models\Forms\FormSubmission;
|
||||
use App\Open\MentionParser;
|
||||
use App\Service\Forms\FormCleaner;
|
||||
use App\Service\WorkspaceHelper;
|
||||
use Illuminate\Http\Request;
|
||||
|
|
@ -105,13 +106,28 @@ class PublicFormController extends Controller
|
|||
return $this->success(array_merge([
|
||||
'message' => 'Form submission saved.',
|
||||
'submission_id' => $submissionId,
|
||||
'is_first_submission' => $isFirstSubmission
|
||||
], $request->form->is_pro && $request->form->redirect_url ? [
|
||||
'is_first_submission' => $isFirstSubmission,
|
||||
], $this->getRedirectData($request->form, $submissionData)));
|
||||
}
|
||||
|
||||
private function getRedirectData($form, $submissionData)
|
||||
{
|
||||
$formattedData = collect($submissionData)->map(function ($value, $key) {
|
||||
return ['id' => $key, 'value' => $value];
|
||||
})->values()->all();
|
||||
|
||||
$redirectUrl = ($form->redirect_url) ? (new MentionParser($form->redirect_url, $formattedData))->parse() : null;
|
||||
|
||||
if ($redirectUrl && !filter_var($redirectUrl, FILTER_VALIDATE_URL)) {
|
||||
$redirectUrl = null;
|
||||
}
|
||||
|
||||
return $form->is_pro && $redirectUrl ? [
|
||||
'redirect' => true,
|
||||
'redirect_url' => $request->form->redirect_url,
|
||||
'redirect_url' => $redirectUrl,
|
||||
] : [
|
||||
'redirect' => false,
|
||||
]));
|
||||
];
|
||||
}
|
||||
|
||||
public function fetchSubmission(Request $request, string $slug, string $submissionId)
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ abstract class UserFormRequest extends \Illuminate\Foundation\Http\FormRequest
|
|||
're_fillable' => 'boolean',
|
||||
're_fill_button_text' => 'string|min:1|max:50',
|
||||
'submitted_text' => 'string|max:2000',
|
||||
'redirect_url' => 'nullable|active_url|max:255',
|
||||
'redirect_url' => 'nullable|max:255',
|
||||
'database_fields_update' => 'nullable|array',
|
||||
'max_submissions_count' => 'integer|nullable|min:1',
|
||||
'max_submissions_reached_text' => 'string|nullable',
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Integrations\Handlers;
|
||||
|
||||
use App\Open\MentionParser;
|
||||
use App\Service\Forms\FormSubmissionFormatter;
|
||||
use Illuminate\Support\Arr;
|
||||
use Vinkla\Hashids\Facades\Hashids;
|
||||
|
|
@ -32,6 +33,9 @@ class DiscordIntegration extends AbstractIntegrationHandler
|
|||
|
||||
protected function getWebhookData(): array
|
||||
{
|
||||
$formatter = (new FormSubmissionFormatter($this->form, $this->submissionData))->outputStringsOnly();
|
||||
$formattedData = $formatter->getFieldsWithValue();
|
||||
|
||||
$settings = (array) $this->integrationData ?? [];
|
||||
$externalLinks = [];
|
||||
if (Arr::get($settings, 'link_open_form', true)) {
|
||||
|
|
@ -50,8 +54,7 @@ class DiscordIntegration extends AbstractIntegrationHandler
|
|||
$blocks = [];
|
||||
if (Arr::get($settings, 'include_submission_data', true)) {
|
||||
$submissionString = '';
|
||||
$formatter = (new FormSubmissionFormatter($this->form, $this->submissionData))->outputStringsOnly();
|
||||
foreach ($formatter->getFieldsWithValue() as $field) {
|
||||
foreach ($formattedData as $field) {
|
||||
$tmpVal = is_array($field['value']) ? implode(',', $field['value']) : $field['value'];
|
||||
$submissionString .= '**' . ucfirst($field['name']) . '**: ' . $tmpVal . "\n";
|
||||
}
|
||||
|
|
@ -80,8 +83,9 @@ class DiscordIntegration extends AbstractIntegrationHandler
|
|||
];
|
||||
}
|
||||
|
||||
$message = Arr::get($settings, 'message', 'New form submission');
|
||||
return [
|
||||
'content' => 'New submission for your form **' . $this->form->title . '**',
|
||||
'content' => (new MentionParser($message, $formattedData))->parse(),
|
||||
'tts' => false,
|
||||
'username' => config('app.name'),
|
||||
'avatar_url' => asset('img/logo.png'),
|
||||
|
|
|
|||
|
|
@ -2,24 +2,51 @@
|
|||
|
||||
namespace App\Integrations\Handlers;
|
||||
|
||||
use App\Rules\OneEmailPerLine;
|
||||
use App\Notifications\Forms\FormEmailNotification;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use App\Notifications\Forms\FormSubmissionNotification;
|
||||
use App\Open\MentionParser;
|
||||
use App\Service\Forms\FormSubmissionFormatter;
|
||||
|
||||
class EmailIntegration extends AbstractEmailIntegrationHandler
|
||||
{
|
||||
public const RISKY_USERS_LIMIT = 120;
|
||||
|
||||
public static function getValidationRules(): array
|
||||
{
|
||||
return [
|
||||
'notification_emails' => ['required', new OneEmailPerLine()],
|
||||
'notification_reply_to' => 'email|nullable',
|
||||
'send_to' => 'required',
|
||||
'sender_name' => 'required',
|
||||
'sender_email' => 'email|nullable',
|
||||
'subject' => 'required',
|
||||
'email_content' => 'required',
|
||||
'include_submission_data' => 'boolean',
|
||||
'include_hidden_fields_submission_data' => ['nullable', 'boolean'],
|
||||
'reply_to' => 'nullable',
|
||||
];
|
||||
}
|
||||
|
||||
protected function shouldRun(): bool
|
||||
{
|
||||
return $this->integrationData->notification_emails && parent::shouldRun();
|
||||
return $this->integrationData->send_to && parent::shouldRun() && !$this->riskLimitReached();
|
||||
}
|
||||
|
||||
// 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 function handle(): void
|
||||
|
|
@ -28,19 +55,27 @@ class EmailIntegration extends AbstractEmailIntegrationHandler
|
|||
return;
|
||||
}
|
||||
|
||||
$subscribers = collect(preg_split("/\r\n|\n|\r/", $this->integrationData->notification_emails))
|
||||
if ($this->form->is_pro) { // For Send to field Mentions are Pro feature
|
||||
$formatter = (new FormSubmissionFormatter($this->form, $this->submissionData))->outputStringsOnly();
|
||||
$parser = new MentionParser($this->integrationData->send_to, $formatter->getFieldsWithValue());
|
||||
$sendTo = $parser->parse();
|
||||
} else {
|
||||
$sendTo = $this->integrationData->send_to;
|
||||
}
|
||||
|
||||
$recipients = collect(preg_split("/\r\n|\n|\r/", $sendTo))
|
||||
->filter(function ($email) {
|
||||
return filter_var($email, FILTER_VALIDATE_EMAIL);
|
||||
});
|
||||
Log::debug('Sending email notification', [
|
||||
'recipients' => $subscribers->toArray(),
|
||||
'recipients' => $recipients->toArray(),
|
||||
'form_id' => $this->form->id,
|
||||
'form_slug' => $this->form->slug,
|
||||
'mailer' => $this->mailer
|
||||
]);
|
||||
$subscribers->each(function ($subscriber) {
|
||||
$recipients->each(function ($subscriber) {
|
||||
Notification::route('mail', $subscriber)->notify(
|
||||
new FormSubmissionNotification($this->event, $this->integrationData, $this->mailer)
|
||||
new FormEmailNotification($this->event, $this->integrationData, $this->mailer)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Integrations\Handlers;
|
||||
|
||||
use App\Open\MentionParser;
|
||||
use App\Service\Forms\FormSubmissionFormatter;
|
||||
use Illuminate\Support\Arr;
|
||||
use Vinkla\Hashids\Facades\Hashids;
|
||||
|
|
@ -32,6 +33,9 @@ class SlackIntegration extends AbstractIntegrationHandler
|
|||
|
||||
protected function getWebhookData(): array
|
||||
{
|
||||
$formatter = (new FormSubmissionFormatter($this->form, $this->submissionData))->outputStringsOnly();
|
||||
$formattedData = $formatter->getFieldsWithValue();
|
||||
|
||||
$settings = (array) $this->integrationData ?? [];
|
||||
$externalLinks = [];
|
||||
if (Arr::get($settings, 'link_open_form', true)) {
|
||||
|
|
@ -46,20 +50,20 @@ class SlackIntegration extends AbstractIntegrationHandler
|
|||
$externalLinks[] = '*<' . $this->form->share_url . '?submission_id=' . $submissionId . '|✍️ ' . $this->form->editable_submissions_button_text . '>*';
|
||||
}
|
||||
|
||||
$message = Arr::get($settings, 'message', 'New form submission');
|
||||
$blocks = [
|
||||
[
|
||||
'type' => 'section',
|
||||
'text' => [
|
||||
'type' => 'mrkdwn',
|
||||
'text' => 'New submission for your form *' . $this->form->title . '*',
|
||||
'text' => (new MentionParser($message, $formattedData))->parse(),
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
if (Arr::get($settings, 'include_submission_data', true)) {
|
||||
$submissionString = '';
|
||||
$formatter = (new FormSubmissionFormatter($this->form, $this->submissionData))->outputStringsOnly();
|
||||
foreach ($formatter->getFieldsWithValue() as $field) {
|
||||
foreach ($formattedData as $field) {
|
||||
$tmpVal = is_array($field['value']) ? implode(',', $field['value']) : $field['value'];
|
||||
$submissionString .= '>*' . ucfirst($field['name']) . '*: ' . $tmpVal . " \n";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,113 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Integrations\Handlers;
|
||||
|
||||
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 AbstractEmailIntegrationHandler
|
||||
{
|
||||
public const RISKY_USERS_LIMIT = 120;
|
||||
|
||||
public static function getValidationRules(): array
|
||||
{
|
||||
return [
|
||||
'respondent_email' => [
|
||||
'required',
|
||||
'boolean',
|
||||
function ($attribute, $value, $fail) {
|
||||
if ($value !== true) {
|
||||
$fail('Need at least 1 email field.');
|
||||
}
|
||||
},
|
||||
],
|
||||
'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,
|
||||
'mailer' => $this->mailer
|
||||
]);
|
||||
Mail::mailer($this->mailer)->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'] ?? ''),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mail\Forms;
|
||||
|
||||
use App\Events\Forms\FormSubmitted;
|
||||
use App\Mail\OpenFormMail;
|
||||
use App\Service\Forms\FormSubmissionFormatter;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Str;
|
||||
use Vinkla\Hashids\Facades\Hashids;
|
||||
|
||||
class SubmissionConfirmationMail extends OpenFormMail implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new message instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(private FormSubmitted $event, private $integrationData)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the message.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function build()
|
||||
{
|
||||
$form = $this->event->form;
|
||||
|
||||
$formatter = (new FormSubmissionFormatter($form, $this->event->data))
|
||||
->createLinks()
|
||||
->outputStringsOnly()
|
||||
->useSignedUrlForFiles();
|
||||
|
||||
return $this
|
||||
->replyTo($this->getReplyToEmail($form->creator->email))
|
||||
->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,
|
||||
]);
|
||||
}
|
||||
|
||||
private function getFromEmail()
|
||||
{
|
||||
if (config('app.self_hosted')) {
|
||||
return config('mail.from.address');
|
||||
}
|
||||
|
||||
$originalFromAddress = Str::of(config('mail.from.address'))->explode('@');
|
||||
|
||||
return $originalFromAddress->first() . '+' . time() . '@' . $originalFromAddress->last();
|
||||
}
|
||||
|
||||
private function getReplyToEmail($default)
|
||||
{
|
||||
$replyTo = $this->integrationData->confirmation_reply_to ?? null;
|
||||
|
||||
if ($replyTo && filter_var($replyTo, FILTER_VALIDATE_EMAIL)) {
|
||||
return $replyTo;
|
||||
}
|
||||
return $default;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
<?php
|
||||
|
||||
namespace App\Notifications\Forms;
|
||||
|
||||
use App\Events\Forms\FormSubmitted;
|
||||
use App\Open\MentionParser;
|
||||
use App\Service\Forms\FormSubmissionFormatter;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use Illuminate\Support\Str;
|
||||
use Vinkla\Hashids\Facades\Hashids;
|
||||
use Symfony\Component\Mime\Email;
|
||||
|
||||
class FormEmailNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public FormSubmitted $event;
|
||||
public string $mailer;
|
||||
private array $formattedData;
|
||||
|
||||
/**
|
||||
* Create a new notification instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(FormSubmitted $event, private $integrationData, string $mailer)
|
||||
{
|
||||
$this->event = $event;
|
||||
$this->mailer = $mailer;
|
||||
$this->formattedData = $this->formatSubmissionData();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the notification's delivery channels.
|
||||
*
|
||||
* @param mixed $notifiable
|
||||
* @return array
|
||||
*/
|
||||
public function via($notifiable)
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mail representation of the notification.
|
||||
*
|
||||
* @param mixed $notifiable
|
||||
* @return \Illuminate\Notifications\Messages\MailMessage
|
||||
*/
|
||||
public function toMail($notifiable)
|
||||
{
|
||||
return (new MailMessage())
|
||||
->mailer($this->mailer)
|
||||
->replyTo($this->getReplyToEmail($notifiable->routes['mail']))
|
||||
->from($this->getFromEmail(), $this->getSenderName())
|
||||
->subject($this->getSubject())
|
||||
->withSymfonyMessage(function (Email $message) {
|
||||
$this->addCustomHeaders($message);
|
||||
})
|
||||
->markdown('mail.form.email-notification', $this->getMailData());
|
||||
}
|
||||
|
||||
private function formatSubmissionData(): array
|
||||
{
|
||||
$formatter = (new FormSubmissionFormatter($this->event->form, $this->event->data))
|
||||
->createLinks()
|
||||
->outputStringsOnly()
|
||||
->useSignedUrlForFiles();
|
||||
|
||||
if ($this->integrationData->include_hidden_fields_submission_data ?? false) {
|
||||
$formatter->showHiddenFields();
|
||||
}
|
||||
|
||||
return $formatter->getFieldsWithValue();
|
||||
}
|
||||
|
||||
private function getFromEmail(): string
|
||||
{
|
||||
if (
|
||||
config('app.self_hosted')
|
||||
&& isset($this->integrationData->sender_email)
|
||||
&& $this->validateEmail($this->integrationData->sender_email)
|
||||
) {
|
||||
return $this->integrationData->sender_email;
|
||||
}
|
||||
|
||||
return config('mail.from.address');
|
||||
}
|
||||
|
||||
private function getSenderName(): string
|
||||
{
|
||||
return $this->integrationData->sender_name ?? config('app.name');
|
||||
}
|
||||
|
||||
private function getReplyToEmail($default): string
|
||||
{
|
||||
$replyTo = $this->integrationData->reply_to ?? null;
|
||||
|
||||
if ($replyTo) {
|
||||
$parsedReplyTo = $this->parseReplyTo($replyTo);
|
||||
if ($parsedReplyTo && $this->validateEmail($parsedReplyTo)) {
|
||||
return $parsedReplyTo;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->getRespondentEmail() ?? $default;
|
||||
}
|
||||
|
||||
private function parseReplyTo(string $replyTo): ?string
|
||||
{
|
||||
$parser = new MentionParser($replyTo, $this->formattedData);
|
||||
return $parser->parse();
|
||||
}
|
||||
|
||||
private function getSubject(): string
|
||||
{
|
||||
$defaultSubject = 'New form submission';
|
||||
$parser = new MentionParser($this->integrationData->subject ?? $defaultSubject, $this->formattedData);
|
||||
return $parser->parse();
|
||||
}
|
||||
|
||||
private function addCustomHeaders(Email $message): void
|
||||
{
|
||||
$formId = $this->event->form->id;
|
||||
$submissionId = $this->event->data['submission_id'] ?? 'unknown';
|
||||
$domain = Str::after(config('app.url'), '://');
|
||||
|
||||
$uniquePart = substr(md5($formId . $submissionId), 0, 8);
|
||||
$messageId = "form-{$formId}-{$uniquePart}@{$domain}";
|
||||
$references = "form-{$formId}@{$domain}";
|
||||
|
||||
$message->getHeaders()->remove('Message-ID');
|
||||
$message->getHeaders()->addIdHeader('Message-ID', $messageId);
|
||||
$message->getHeaders()->addTextHeader('References', $references);
|
||||
}
|
||||
|
||||
private function getMailData(): array
|
||||
{
|
||||
return [
|
||||
'emailContent' => $this->getEmailContent(),
|
||||
'fields' => $this->formattedData,
|
||||
'form' => $this->event->form,
|
||||
'integrationData' => $this->integrationData,
|
||||
'noBranding' => $this->event->form->no_branding,
|
||||
'submission_id' => $this->getEncodedSubmissionId(),
|
||||
];
|
||||
}
|
||||
|
||||
private function getEmailContent(): string
|
||||
{
|
||||
$parser = new MentionParser($this->integrationData->email_content ?? '', $this->formattedData);
|
||||
return $parser->parse();
|
||||
}
|
||||
|
||||
private function getEncodedSubmissionId(): ?string
|
||||
{
|
||||
$submissionId = $this->event->data['submission_id'] ?? null;
|
||||
return $submissionId ? Hashids::encode($submissionId) : null;
|
||||
}
|
||||
|
||||
private function getRespondentEmail(): ?string
|
||||
{
|
||||
$emailFields = ['email', 'e-mail', 'mail'];
|
||||
|
||||
foreach ($this->formattedData as $field => $value) {
|
||||
if (in_array(strtolower($field), $emailFields) && $this->validateEmail($value)) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
// If no email field found, search for any field containing a valid email
|
||||
foreach ($this->formattedData as $value) {
|
||||
if ($this->validateEmail($value)) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function validateEmail($email): bool
|
||||
{
|
||||
return (bool)filter_var($email, FILTER_VALIDATE_EMAIL);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Notifications\Forms;
|
||||
|
||||
use App\Events\Forms\FormSubmitted;
|
||||
use App\Service\Forms\FormSubmissionFormatter;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class FormSubmissionNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public FormSubmitted $event;
|
||||
private $mailer;
|
||||
|
||||
/**
|
||||
* Create a new notification instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(FormSubmitted $event, private $integrationData, string $mailer)
|
||||
{
|
||||
$this->event = $event;
|
||||
$this->mailer = $mailer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the notification's delivery channels.
|
||||
*
|
||||
* @param mixed $notifiable
|
||||
* @return array
|
||||
*/
|
||||
public function via($notifiable)
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mail representation of the notification.
|
||||
*
|
||||
* @param mixed $notifiable
|
||||
* @return \Illuminate\Notifications\Messages\MailMessage
|
||||
*/
|
||||
public function toMail($notifiable)
|
||||
{
|
||||
$formatter = (new FormSubmissionFormatter($this->event->form, $this->event->data))
|
||||
->showHiddenFields()
|
||||
->createLinks()
|
||||
->outputStringsOnly()
|
||||
->useSignedUrlForFiles();
|
||||
|
||||
return (new MailMessage())
|
||||
->mailer($this->mailer)
|
||||
->replyTo($this->getReplyToEmail($notifiable->routes['mail']))
|
||||
->from($this->getFromEmail(), config('app.name'))
|
||||
->subject('New form submission for "' . $this->event->form->title . '"')
|
||||
->markdown('mail.form.submission-notification', [
|
||||
'fields' => $formatter->getFieldsWithValue(),
|
||||
'form' => $this->event->form,
|
||||
]);
|
||||
}
|
||||
|
||||
private function getFromEmail()
|
||||
{
|
||||
if (config('app.self_hosted')) {
|
||||
return config('mail.from.address');
|
||||
}
|
||||
$originalFromAddress = Str::of(config('mail.from.address'))->explode('@');
|
||||
|
||||
return $originalFromAddress->first() . '+' . time() . '@' . $originalFromAddress->last();
|
||||
}
|
||||
|
||||
private function getReplyToEmail($default)
|
||||
{
|
||||
$replyTo = $this->integrationData->notification_reply_to ?? null;
|
||||
if ($replyTo && $this->validateEmail($replyTo)) {
|
||||
return $replyTo;
|
||||
}
|
||||
|
||||
return $this->getRespondentEmail() ?? $default;
|
||||
}
|
||||
|
||||
private function getRespondentEmail()
|
||||
{
|
||||
// Make sure we only have one email field in the form
|
||||
$emailFields = collect($this->event->form->properties)->filter(function ($field) {
|
||||
$hidden = $field['hidden'] ?? false;
|
||||
|
||||
return !$hidden && $field['type'] == 'email';
|
||||
});
|
||||
if ($emailFields->count() != 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isset($this->event->data[$emailFields->first()['id']])) {
|
||||
$email = $this->event->data[$emailFields->first()['id']];
|
||||
if ($this->validateEmail($email)) {
|
||||
return $email;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function validateEmail($email): bool
|
||||
{
|
||||
return (bool) filter_var($email, FILTER_VALIDATE_EMAIL);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
<?php
|
||||
|
||||
namespace App\Open;
|
||||
|
||||
use DOMDocument;
|
||||
use DOMXPath;
|
||||
|
||||
class MentionParser
|
||||
{
|
||||
private $content;
|
||||
private $data;
|
||||
|
||||
public function __construct($content, $data)
|
||||
{
|
||||
$this->content = $content;
|
||||
$this->data = $data;
|
||||
}
|
||||
|
||||
public function parse()
|
||||
{
|
||||
$doc = new DOMDocument();
|
||||
// Disable libxml errors and use internal errors
|
||||
$internalErrors = libxml_use_internal_errors(true);
|
||||
|
||||
// Wrap the content in a root element to ensure it's valid XML
|
||||
$wrappedContent = '<root>' . $this->content . '</root>';
|
||||
|
||||
// Load HTML, using UTF-8 encoding
|
||||
$doc->loadHTML(mb_convert_encoding($wrappedContent, 'HTML-ENTITIES', 'UTF-8'), LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
|
||||
|
||||
// Restore libxml error handling
|
||||
libxml_use_internal_errors($internalErrors);
|
||||
|
||||
$xpath = new DOMXPath($doc);
|
||||
$mentionElements = $xpath->query("//span[@mention]");
|
||||
|
||||
foreach ($mentionElements as $element) {
|
||||
$fieldId = $element->getAttribute('mention-field-id');
|
||||
$fallback = $element->getAttribute('mention-fallback');
|
||||
$value = $this->getData($fieldId);
|
||||
|
||||
if ($value !== null) {
|
||||
$textNode = $doc->createTextNode(is_array($value) ? implode(', ', $value) : $value);
|
||||
$element->parentNode->replaceChild($textNode, $element);
|
||||
} elseif ($fallback) {
|
||||
$textNode = $doc->createTextNode($fallback);
|
||||
$element->parentNode->replaceChild($textNode, $element);
|
||||
} else {
|
||||
$element->parentNode->removeChild($element);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract and return the processed HTML content
|
||||
$result = $doc->saveHTML($doc->getElementsByTagName('root')->item(0));
|
||||
|
||||
// Remove the root tags we added
|
||||
$result = preg_replace('/<\/?root>/', '', $result);
|
||||
|
||||
// Trim whitespace and convert HTML entities back to UTF-8 characters
|
||||
$result = trim(html_entity_decode($result, ENT_QUOTES | ENT_HTML5, 'UTF-8'));
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function replaceMentions()
|
||||
{
|
||||
$pattern = '/<span[^>]*mention-field-id="([^"]*)"[^>]*mention-fallback="([^"]*)"[^>]*>.*?<\/span>/';
|
||||
return preg_replace_callback($pattern, function ($matches) {
|
||||
$fieldId = $matches[1];
|
||||
$fallback = $matches[2];
|
||||
$value = $this->getData($fieldId);
|
||||
|
||||
if ($value !== null) {
|
||||
if (is_array($value)) {
|
||||
return implode(' ', array_map(function ($v) {
|
||||
return $v;
|
||||
}, $value));
|
||||
}
|
||||
return $value;
|
||||
} elseif ($fallback) {
|
||||
return $fallback;
|
||||
}
|
||||
return '';
|
||||
}, $this->content);
|
||||
}
|
||||
|
||||
private function getData($fieldId)
|
||||
{
|
||||
$value = collect($this->data)->firstWhere('id', $fieldId)['value'] ?? null;
|
||||
|
||||
if (is_object($value)) {
|
||||
return (array) $value;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
namespace App\Service\HtmlPurifier;
|
||||
|
||||
use HTMLPurifier_HTMLDefinition;
|
||||
use Stevebauman\Purify\Definitions\Definition;
|
||||
use Stevebauman\Purify\Definitions\Html5Definition;
|
||||
|
||||
class OpenFormsHtmlDefinition implements Definition
|
||||
{
|
||||
public static function apply(HTMLPurifier_HTMLDefinition $definition)
|
||||
{
|
||||
Html5Definition::apply($definition);
|
||||
|
||||
$definition->addAttribute('span', 'mention-field-id', 'Text');
|
||||
$definition->addAttribute('span', 'mention-field-name', 'Text');
|
||||
$definition->addAttribute('span', 'mention-fallback', 'Text');
|
||||
$definition->addAttribute('span', 'mention', 'Bool');
|
||||
$definition->addAttribute('span', 'contenteditable', 'Bool');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
<?php
|
||||
|
||||
use Stevebauman\Purify\Definitions\Html5Definition;
|
||||
use App\Service\HtmlPurifier\OpenFormsHtmlDefinition;
|
||||
|
||||
return [
|
||||
|
||||
|
|
@ -40,7 +40,7 @@ return [
|
|||
'configs' => [
|
||||
|
||||
'default' => [
|
||||
'HTML.Allowed' => 'h1,h2,b,u,strong,i,em,a[href|title],ul,ol,li,p,br,span,*[style]',
|
||||
'HTML.Allowed' => 'h1,h2,b,u,strong,i,em,a[href|title],ul,ol,li,p,br,span[mention|mention-field-id|mention-field-name|mention-fallback],*[style]',
|
||||
'HTML.ForbiddenElements' => '',
|
||||
'CSS.AllowedProperties' => 'font,font-size,font-weight,font-style,text-decoration,color,text-align',
|
||||
|
||||
|
|
@ -86,7 +86,7 @@ return [
|
|||
|
|
||||
*/
|
||||
|
||||
'definitions' => Html5Definition::class,
|
||||
'definitions' => OpenFormsHtmlDefinition::class,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -7,13 +7,6 @@
|
|||
"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",
|
||||
|
|
|
|||
|
|
@ -1,34 +0,0 @@
|
|||
@component('mail::message', ['noBranding' => $noBranding])
|
||||
|
||||
{!! $integrationData->notification_body !!}
|
||||
|
||||
@if($form->editable_submissions)
|
||||
@component('mail::button', ['url' => $form->share_url.'?submission_id='.$submission_id])
|
||||
{{($form->editable_submissions_button_text ?? 'Edit submission')}}
|
||||
@endcomponent
|
||||
@endif
|
||||
|
||||
@if($integrationData->notifications_include_submission)
|
||||
As a reminder, here are your answers:
|
||||
|
||||
@foreach($fields as $field)
|
||||
@if(isset($field['value']))
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
**{{$field['name']}}**
|
||||
@if($field['type'] == 'files')
|
||||
<br />
|
||||
@foreach($field['email_data'] as $link)
|
||||
<a href="{{$link['signed_url']}}">{{$link['label']}}</a> <br />
|
||||
@endforeach
|
||||
@else
|
||||
{!! is_array($field['value'])?implode(',',$field['value']):$field['value']!!}
|
||||
@endif
|
||||
@endif
|
||||
@endforeach
|
||||
@endif
|
||||
|
||||
<p style="text-align:center"><small>You are receiving this email because you answered the form: <a href="{{front_url("forms/".$form->slug)}}">"{{$form->title}}"</a>.</small></p>
|
||||
|
||||
@endcomponent
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
@component('mail::message', ['noBranding' => $noBranding])
|
||||
|
||||
{!! $emailContent !!}
|
||||
|
||||
@if($form->editable_submissions)
|
||||
@component('mail::button', ['url' => $form->share_url.'?submission_id='.$submission_id])
|
||||
{{($form->editable_submissions_button_text ?? 'Edit submission')}}
|
||||
@endcomponent
|
||||
@endif
|
||||
|
||||
@if($integrationData->include_submission_data)
|
||||
@foreach($fields as $field)
|
||||
@if(isset($field['value']))
|
||||
<p style="white-space: pre-wrap; border-top: 1px solid #9ca3af;">
|
||||
<b>{{$field['name']}}</b>
|
||||
{!! is_array($field['value'])?implode(',',$field['value']):$field['value']!!}
|
||||
</p>
|
||||
@endif
|
||||
@endforeach
|
||||
@endif
|
||||
|
||||
@endcomponent
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
@component('mail::message')
|
||||
|
||||
Hello there 👋
|
||||
|
||||
Your form "{{$form->title}}" has a new submission.
|
||||
|
||||
@foreach($fields as $field)
|
||||
@if(isset($field['value']))
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
**{{$field['name']}}**
|
||||
@if($field['type'] == 'files')
|
||||
<br/>
|
||||
@foreach($field['email_data'] as $link)
|
||||
<a href="{{$link['signed_url']}}">{{$link['label']}}</a> <br/>
|
||||
@endforeach
|
||||
@else
|
||||
@if($field['type'] == 'matrix')
|
||||
{!! nl2br(e($field['value'])) !!}
|
||||
@else
|
||||
{!! is_array($field['value'])?implode(',',$field['value']):$field['value']!!}
|
||||
@endif
|
||||
@endif
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
@endcomponent
|
||||
|
|
@ -1,144 +0,0 @@
|
|||
<?php
|
||||
|
||||
use App\Mail\Forms\SubmissionConfirmationMail;
|
||||
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);
|
||||
$integrationData = $this->createFormIntegration('submission_confirmation', $form->id, [
|
||||
'respondent_email' => true,
|
||||
'notifications_include_submission' => true,
|
||||
'notification_sender' => 'Custom Sender',
|
||||
'notification_subject' => 'Test subject',
|
||||
'notification_body' => 'Test body',
|
||||
]);
|
||||
|
||||
$formData = [
|
||||
collect($form->properties)->first(function ($property) {
|
||||
return $property['type'] == 'email';
|
||||
})['id'] => 'test@test.com',
|
||||
];
|
||||
$event = new \App\Events\Forms\FormSubmitted($form, $formData);
|
||||
$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:');
|
||||
});
|
||||
|
||||
it('creates confirmation emails without the submitted data', function () {
|
||||
$user = $this->actingAsUser();
|
||||
$workspace = $this->createUserWorkspace($user);
|
||||
$form = $this->createForm($user, $workspace);
|
||||
$integrationData = $this->createFormIntegration('submission_confirmation', $form->id, [
|
||||
'respondent_email' => true,
|
||||
'notifications_include_submission' => false,
|
||||
'notification_sender' => 'Custom Sender',
|
||||
'notification_subject' => 'Test subject',
|
||||
'notification_body' => 'Test body',
|
||||
]);
|
||||
|
||||
$formData = [
|
||||
collect($form->properties)->first(function ($property) {
|
||||
return $property['type'] == 'email';
|
||||
})['id'] => 'test@test.com',
|
||||
];
|
||||
$event = new \App\Events\Forms\FormSubmitted($form, $formData);
|
||||
$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:');
|
||||
});
|
||||
|
||||
it('sends a confirmation email if needed', function () {
|
||||
$user = $this->actingAsProUser();
|
||||
$workspace = $this->createUserWorkspace($user);
|
||||
$form = $this->createForm($user, $workspace);
|
||||
|
||||
$this->createFormIntegration('submission_confirmation', $form->id, [
|
||||
'respondent_email' => true,
|
||||
'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';
|
||||
});
|
||||
$formData = [
|
||||
$emailProperty['id'] => 'test@test.com',
|
||||
];
|
||||
|
||||
Mail::fake();
|
||||
|
||||
$this->postJson(route('forms.answer', $form->slug), $formData)
|
||||
->assertSuccessful()
|
||||
->assertJson([
|
||||
'type' => 'success',
|
||||
'message' => 'Form submission saved.',
|
||||
]);
|
||||
|
||||
Mail::assertQueued(
|
||||
SubmissionConfirmationMail::class,
|
||||
function (SubmissionConfirmationMail $mail) {
|
||||
return $mail->hasTo('test@test.com');
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('does not send a confirmation email if not needed', function () {
|
||||
$user = $this->actingAsUser();
|
||||
$workspace = $this->createUserWorkspace($user);
|
||||
$form = $this->createForm($user, $workspace);
|
||||
$emailProperty = collect($form->properties)->first(function ($property) {
|
||||
return $property['type'] == 'email';
|
||||
});
|
||||
$formData = [
|
||||
$emailProperty['id'] => 'test@test.com',
|
||||
];
|
||||
|
||||
Mail::fake();
|
||||
|
||||
$this->postJson(route('forms.answer', $form->slug), $formData)
|
||||
->assertSuccessful()
|
||||
->assertJson([
|
||||
'type' => 'success',
|
||||
'message' => 'Form submission saved.',
|
||||
]);
|
||||
|
||||
Mail::assertNotQueued(
|
||||
SubmissionConfirmationMail::class,
|
||||
function (SubmissionConfirmationMail $mail) {
|
||||
return $mail->hasTo('test@test.com');
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
$integrationData = $this->createFormIntegration('submission_confirmation', $form->id, [
|
||||
'respondent_email' => true,
|
||||
'notifications_include_submission' => true,
|
||||
'notification_sender' => 'Custom Sender',
|
||||
'notification_subject' => 'Test subject',
|
||||
'notification_body' => 'Test body',
|
||||
'confirmation_reply_to' => ''
|
||||
]);
|
||||
|
||||
$emailProperty = collect($form->properties)->first(function ($property) {
|
||||
return $property['type'] == 'email';
|
||||
});
|
||||
$formData = [
|
||||
$emailProperty['id'] => 'test@test.com',
|
||||
];
|
||||
$event = new \App\Events\Forms\FormSubmitted($form, $formData);
|
||||
$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:')
|
||||
->assertHasReplyTo($user->email); // Even though reply to is wrong, it should use the user's email
|
||||
});
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
<?php
|
||||
|
||||
use App\Mail\Forms\SubmissionConfirmationMail;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use App\Notifications\Forms\FormEmailNotification;
|
||||
use Tests\Helpers\FormSubmissionDataFactory;
|
||||
use Illuminate\Notifications\AnonymousNotifiable;
|
||||
|
||||
it('can not save custom SMTP settings if not pro user', function () {
|
||||
$user = $this->actingAsUser();
|
||||
|
|
@ -15,7 +16,7 @@ it('can not save custom SMTP settings if not pro user', function () {
|
|||
])->assertStatus(403);
|
||||
});
|
||||
|
||||
it('creates confirmation emails with custom SMTP settings', function () {
|
||||
it('send email with custom SMTP settings', function () {
|
||||
$user = $this->actingAsProUser();
|
||||
$workspace = $this->createUserWorkspace($user);
|
||||
$form = $this->createForm($user, $workspace);
|
||||
|
|
@ -28,21 +29,19 @@ it('creates confirmation emails with custom SMTP settings', function () {
|
|||
'password' => 'custom_password',
|
||||
])->assertSuccessful();
|
||||
|
||||
$integrationData = $this->createFormIntegration('submission_confirmation', $form->id, [
|
||||
'respondent_email' => true,
|
||||
'notifications_include_submission' => true,
|
||||
'notification_sender' => 'Custom Sender',
|
||||
'notification_subject' => 'Custom SMTP Test',
|
||||
'notification_body' => 'This email was sent using custom SMTP settings',
|
||||
$integrationData = $this->createFormIntegration('email', $form->id, [
|
||||
'send_to' => 'test@test.com',
|
||||
'sender_name' => 'OpnForm',
|
||||
'subject' => 'New form submission',
|
||||
'email_content' => 'Hello there 👋 <br>New form submission received.',
|
||||
'include_submission_data' => true,
|
||||
'include_hidden_fields_submission_data' => false,
|
||||
'reply_to' => 'reply@example.com',
|
||||
]);
|
||||
|
||||
$formData = [
|
||||
collect($form->properties)->first(function ($property) {
|
||||
return $property['type'] == 'email';
|
||||
})['id'] => 'test@test.com',
|
||||
];
|
||||
$formData = FormSubmissionDataFactory::generateSubmissionData($form);
|
||||
|
||||
Mail::fake();
|
||||
Notification::fake();
|
||||
|
||||
$this->postJson(route('forms.answer', $form->slug), $formData)
|
||||
->assertSuccessful()
|
||||
|
|
@ -51,10 +50,12 @@ it('creates confirmation emails with custom SMTP settings', function () {
|
|||
'message' => 'Form submission saved.',
|
||||
]);
|
||||
|
||||
Mail::assertQueued(
|
||||
SubmissionConfirmationMail::class,
|
||||
function (SubmissionConfirmationMail $mail) {
|
||||
return $mail->hasTo('test@test.com') && $mail->mailer === 'custom_smtp';
|
||||
Notification::assertSentTo(
|
||||
new AnonymousNotifiable(),
|
||||
FormEmailNotification::class,
|
||||
function (FormEmailNotification $notification, $channels, $notifiable) {
|
||||
return $notifiable->routes['mail'] === 'test@test.com' &&
|
||||
$notification->mailer === 'custom_smtp';
|
||||
}
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,165 @@
|
|||
<?php
|
||||
|
||||
use App\Notifications\Forms\FormEmailNotification;
|
||||
use Tests\Helpers\FormSubmissionDataFactory;
|
||||
use Illuminate\Notifications\AnonymousNotifiable;
|
||||
|
||||
it('send email with the submitted data', function () {
|
||||
$user = $this->actingAsUser();
|
||||
$workspace = $this->createUserWorkspace($user);
|
||||
$form = $this->createForm($user, $workspace);
|
||||
$integrationData = $this->createFormIntegration('email', $form->id, [
|
||||
'send_to' => 'test@test.com',
|
||||
'sender_name' => 'OpnForm',
|
||||
'subject' => 'New form submission',
|
||||
'email_content' => 'Hello there 👋 <br>Test body',
|
||||
'include_submission_data' => true,
|
||||
'include_hidden_fields_submission_data' => false,
|
||||
'reply_to' => 'reply@example.com',
|
||||
]);
|
||||
|
||||
$formData = FormSubmissionDataFactory::generateSubmissionData($form);
|
||||
|
||||
$event = new \App\Events\Forms\FormSubmitted($form, $formData);
|
||||
$mailable = new FormEmailNotification($event, $integrationData, 'mail');
|
||||
$notifiable = new AnonymousNotifiable();
|
||||
$notifiable->route('mail', 'test@test.com');
|
||||
$renderedMail = $mailable->toMail($notifiable);
|
||||
expect($renderedMail->subject)->toBe('New form submission');
|
||||
expect(trim($renderedMail->render()))->toContain('Test body');
|
||||
});
|
||||
|
||||
it('sends a email if needed', function () {
|
||||
$user = $this->actingAsProUser();
|
||||
$workspace = $this->createUserWorkspace($user);
|
||||
$form = $this->createForm($user, $workspace);
|
||||
|
||||
$emailProperty = collect($form->properties)->first(function ($property) {
|
||||
return $property['type'] == 'email';
|
||||
});
|
||||
|
||||
$this->createFormIntegration('email', $form->id, [
|
||||
'send_to' => '<span mention-field-id="' . $emailProperty['id'] . '" mention-field-name="' . $emailProperty['name'] . '" mention-fallback="" contenteditable="false" mention="true">' . $emailProperty['name'] . '</span>',
|
||||
'sender_name' => 'OpnForm',
|
||||
'subject' => 'New form submission',
|
||||
'email_content' => 'Hello there 👋 <br>New form submission received.',
|
||||
'include_submission_data' => true,
|
||||
'include_hidden_fields_submission_data' => false,
|
||||
'reply_to' => 'reply@example.com',
|
||||
]);
|
||||
|
||||
$formData = [
|
||||
$emailProperty['id'] => 'test@test.com',
|
||||
];
|
||||
|
||||
Notification::fake();
|
||||
|
||||
$this->postJson(route('forms.answer', $form->slug), $formData)
|
||||
->assertSuccessful()
|
||||
->assertJson([
|
||||
'type' => 'success',
|
||||
'message' => 'Form submission saved.',
|
||||
]);
|
||||
|
||||
Notification::assertSentTo(
|
||||
new AnonymousNotifiable(),
|
||||
FormEmailNotification::class,
|
||||
function (FormEmailNotification $notification, $channels, $notifiable) {
|
||||
return $notifiable->routes['mail'] === 'test@test.com';
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('does not send a email if not needed', function () {
|
||||
$user = $this->actingAsUser();
|
||||
$workspace = $this->createUserWorkspace($user);
|
||||
$form = $this->createForm($user, $workspace);
|
||||
$emailProperty = collect($form->properties)->first(function ($property) {
|
||||
return $property['type'] == 'email';
|
||||
});
|
||||
$formData = [
|
||||
$emailProperty['id'] => 'test@test.com',
|
||||
];
|
||||
|
||||
Notification::fake();
|
||||
|
||||
$this->postJson(route('forms.answer', $form->slug), $formData)
|
||||
->assertSuccessful()
|
||||
->assertJson([
|
||||
'type' => 'success',
|
||||
'message' => 'Form submission saved.',
|
||||
]);
|
||||
|
||||
Notification::assertNotSentTo(
|
||||
new AnonymousNotifiable(),
|
||||
FormEmailNotification::class,
|
||||
function (FormEmailNotification $notification, $channels, $notifiable) {
|
||||
return $notifiable->routes['mail'] === 'test@test.com';
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('uses custom sender email in self-hosted mode', function () {
|
||||
config(['app.self_hosted' => true]);
|
||||
|
||||
$user = $this->actingAsUser();
|
||||
$workspace = $this->createUserWorkspace($user);
|
||||
$form = $this->createForm($user, $workspace);
|
||||
$customSenderEmail = 'custom@example.com';
|
||||
$integrationData = $this->createFormIntegration('email', $form->id, [
|
||||
'send_to' => 'test@test.com',
|
||||
'sender_name' => 'Custom Sender',
|
||||
'sender_email' => $customSenderEmail,
|
||||
'subject' => 'Custom Subject',
|
||||
'email_content' => 'Custom content',
|
||||
'include_submission_data' => true,
|
||||
'include_hidden_fields_submission_data' => false,
|
||||
'reply_to' => 'reply@example.com',
|
||||
]);
|
||||
|
||||
$formData = FormSubmissionDataFactory::generateSubmissionData($form);
|
||||
|
||||
$event = new \App\Events\Forms\FormSubmitted($form, $formData);
|
||||
$mailable = new FormEmailNotification($event, $integrationData, 'mail');
|
||||
$notifiable = new AnonymousNotifiable();
|
||||
$notifiable->route('mail', 'test@test.com');
|
||||
$renderedMail = $mailable->toMail($notifiable);
|
||||
|
||||
expect($renderedMail->from[0])->toBe($customSenderEmail);
|
||||
expect($renderedMail->from[1])->toBe('Custom Sender');
|
||||
expect($renderedMail->subject)->toBe('Custom Subject');
|
||||
expect(trim($renderedMail->render()))->toContain('Custom content');
|
||||
});
|
||||
|
||||
it('does not use custom sender email in non-self-hosted mode', function () {
|
||||
config(['app.self_hosted' => false]);
|
||||
config(['mail.from.address' => 'default@example.com']);
|
||||
|
||||
$user = $this->actingAsUser();
|
||||
$workspace = $this->createUserWorkspace($user);
|
||||
$form = $this->createForm($user, $workspace);
|
||||
$customSenderEmail = 'custom@example.com';
|
||||
$integrationData = $this->createFormIntegration('email', $form->id, [
|
||||
'send_to' => 'test@test.com',
|
||||
'sender_name' => 'Custom Sender',
|
||||
'sender_email' => $customSenderEmail,
|
||||
'subject' => 'Custom Subject',
|
||||
'email_content' => 'Custom content',
|
||||
'include_submission_data' => true,
|
||||
'include_hidden_fields_submission_data' => false,
|
||||
'reply_to' => 'reply@example.com',
|
||||
]);
|
||||
|
||||
$formData = FormSubmissionDataFactory::generateSubmissionData($form);
|
||||
|
||||
$event = new \App\Events\Forms\FormSubmitted($form, $formData);
|
||||
$mailable = new FormEmailNotification($event, $integrationData, 'mail');
|
||||
$notifiable = new AnonymousNotifiable();
|
||||
$notifiable->route('mail', 'test@test.com');
|
||||
$renderedMail = $mailable->toMail($notifiable);
|
||||
|
||||
expect($renderedMail->from[0])->toBe('default@example.com');
|
||||
expect($renderedMail->from[1])->toBe('Custom Sender');
|
||||
expect($renderedMail->subject)->toBe('Custom Subject');
|
||||
expect(trim($renderedMail->render()))->toContain('Custom content');
|
||||
});
|
||||
|
|
@ -10,8 +10,13 @@ it('can fetch form integration events', function () {
|
|||
'integration_id' => 'email',
|
||||
'logic' => null,
|
||||
'settings' => [
|
||||
'notification_emails' => 'test@test.com',
|
||||
'notification_reply_to' => null
|
||||
'send_to' => 'test@test.com',
|
||||
'sender_name' => 'OpnForm',
|
||||
'subject' => 'New form submission',
|
||||
'email_content' => 'Hello there 👋 <br>New form submission received.',
|
||||
'include_submission_data' => true,
|
||||
'include_hidden_fields_submission_data' => false,
|
||||
'reply_to' => null
|
||||
]
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -10,8 +10,13 @@ it('can CRUD form integration', function () {
|
|||
'integration_id' => 'email',
|
||||
'logic' => null,
|
||||
'settings' => [
|
||||
'notification_emails' => 'test@test.com',
|
||||
'notification_reply_to' => null
|
||||
'send_to' => 'test@test.com',
|
||||
'sender_name' => 'OpnForm',
|
||||
'subject' => 'New form submission',
|
||||
'email_content' => 'Hello there 👋 <br>New form submission received.',
|
||||
'include_submission_data' => true,
|
||||
'include_hidden_fields_submission_data' => false,
|
||||
'reply_to' => null
|
||||
]
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Console\Commands\EmailNotificationMigration;
|
||||
use App\Models\Integration\FormIntegration;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->command = new EmailNotificationMigration();
|
||||
});
|
||||
|
||||
it('updates email integration correctly', function () {
|
||||
$user = $this->actingAsUser();
|
||||
$workspace = $this->createUserWorkspace($user);
|
||||
$form = $this->createForm($user, $workspace);
|
||||
|
||||
$integration = FormIntegration::create([
|
||||
'integration_id' => 'email',
|
||||
'form_id' => $form->id,
|
||||
'status' => FormIntegration::STATUS_ACTIVE,
|
||||
'data' => [
|
||||
'notification_emails' => 'test@example.com',
|
||||
'notification_reply_to' => 'reply@example.com',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->command->updateIntegration($integration);
|
||||
|
||||
expect($integration->fresh())
|
||||
->integration_id->toBe('email')
|
||||
->data->toMatchArray([
|
||||
'send_to' => 'test@example.com',
|
||||
'sender_name' => 'OpnForm',
|
||||
'subject' => 'New form submission',
|
||||
'email_content' => 'Hello there 👋 <br>New form submission received.',
|
||||
'include_submission_data' => true,
|
||||
'include_hidden_fields_submission_data' => false,
|
||||
'reply_to' => 'reply@example.com',
|
||||
]);
|
||||
});
|
||||
|
||||
it('updates submission confirmation integration correctly', function () {
|
||||
$user = $this->actingAsUser();
|
||||
$workspace = $this->createUserWorkspace($user);
|
||||
$form = $this->createForm($user, $workspace);
|
||||
|
||||
$emailProperty = collect($form->properties)->filter(function ($property) {
|
||||
return $property['type'] == 'email';
|
||||
})->first();
|
||||
|
||||
$integration = FormIntegration::create([
|
||||
'integration_id' => 'submission_confirmation',
|
||||
'form_id' => $form->id,
|
||||
'status' => FormIntegration::STATUS_ACTIVE,
|
||||
'data' => [
|
||||
'notification_sender' => 'Sender Name',
|
||||
'notification_subject' => 'Thank you for your submission',
|
||||
'notification_body' => 'We received your submission.',
|
||||
'notifications_include_submission' => true,
|
||||
'confirmation_reply_to' => 'reply@example.com',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->command->updateIntegration($integration);
|
||||
|
||||
expect($integration->fresh())
|
||||
->integration_id->toBe('email')
|
||||
->data->toMatchArray([
|
||||
'send_to' => '<span mention-field-id="' . $emailProperty['id'] . '" mention-field-name="' . $emailProperty['name'] . '" mention-fallback="" contenteditable="false" mention="true">' . $emailProperty['name'] . '</span>',
|
||||
'sender_name' => 'Sender Name',
|
||||
'subject' => 'Thank you for your submission',
|
||||
'email_content' => 'We received your submission.',
|
||||
'include_submission_data' => true,
|
||||
'include_hidden_fields_submission_data' => false,
|
||||
'reply_to' => 'reply@example.com',
|
||||
]);
|
||||
});
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
<?php
|
||||
|
||||
use App\Open\MentionParser;
|
||||
|
||||
test('it replaces mention elements with their corresponding values', function () {
|
||||
$content = '<p>Hello <span mention mention-field-id="123">Placeholder</span></p>';
|
||||
$data = [['id' => '123', 'value' => 'World']];
|
||||
|
||||
$parser = new MentionParser($content, $data);
|
||||
$result = $parser->parse();
|
||||
|
||||
expect($result)->toBe('<p>Hello World</p>');
|
||||
});
|
||||
|
||||
test('it handles multiple mentions', function () {
|
||||
$content = '<p><span mention mention-field-id="123">Name</span> is <span mention mention-field-id="456">Age</span> years old</p>';
|
||||
$data = [
|
||||
['id' => '123', 'value' => 'John'],
|
||||
['id' => '456', 'value' => 30],
|
||||
];
|
||||
|
||||
$parser = new MentionParser($content, $data);
|
||||
$result = $parser->parse();
|
||||
|
||||
expect($result)->toBe('<p>John is 30 years old</p>');
|
||||
});
|
||||
|
||||
test('it uses fallback when value is not found', function () {
|
||||
$content = '<p>Hello <span mention mention-field-id="123" mention-fallback="Friend">Placeholder</span></p>';
|
||||
$data = [];
|
||||
|
||||
$parser = new MentionParser($content, $data);
|
||||
$result = $parser->parse();
|
||||
|
||||
expect($result)->toBe('<p>Hello Friend</p>');
|
||||
});
|
||||
|
||||
test('it removes mention element when no value and no fallback', function () {
|
||||
$content = '<p>Hello <span mention mention-field-id="123">Placeholder</span></p>';
|
||||
$data = [];
|
||||
|
||||
$parser = new MentionParser($content, $data);
|
||||
$result = $parser->parse();
|
||||
|
||||
expect($result)->toBe('<p>Hello </p>');
|
||||
});
|
||||
|
||||
test('it handles array values', function () {
|
||||
$content = '<p>Tags: <span mention mention-field-id="123">Placeholder</span></p>';
|
||||
$data = [['id' => '123', 'value' => ['PHP', 'Laravel', 'Testing']]];
|
||||
|
||||
$parser = new MentionParser($content, $data);
|
||||
$result = $parser->parse();
|
||||
|
||||
expect($result)->toBe('<p>Tags: PHP, Laravel, Testing</p>');
|
||||
});
|
||||
|
||||
test('it preserves HTML structure', function () {
|
||||
$content = '<div><p>Hello <span mention mention-field-id="123">Placeholder</span></p><p>How are you?</p></div>';
|
||||
$data = [['id' => '123', 'value' => 'World']];
|
||||
|
||||
$parser = new MentionParser($content, $data);
|
||||
$result = $parser->parse();
|
||||
|
||||
expect($result)->toBe('<div><p>Hello World</p><p>How are you?</p></div>');
|
||||
});
|
||||
|
||||
test('it handles UTF-8 characters', function () {
|
||||
$content = '<p>こんにちは <span mention mention-field-id="123">Placeholder</span></p>';
|
||||
$data = [['id' => '123', 'value' => '世界']];
|
||||
|
||||
$parser = new MentionParser($content, $data);
|
||||
$result = $parser->parse();
|
||||
|
||||
expect($result)->toBe('<p>こんにちは 世界</p>');
|
||||
});
|
||||
|
||||
test('it handles content without surrounding paragraph tags', function () {
|
||||
$content = 'some text <span contenteditable="false" mention="" mention-field-id="123" mention-field-name="Post excerpt" mention-fallback="">Post excerpt</span> dewde';
|
||||
$data = [['id' => '123', 'value' => 'replaced text']];
|
||||
|
||||
$parser = new MentionParser($content, $data);
|
||||
$result = $parser->parse();
|
||||
|
||||
expect($result)->toBe('some text replaced text dewde');
|
||||
});
|
||||
|
|
@ -0,0 +1,199 @@
|
|||
<template>
|
||||
<InputWrapper v-bind="inputWrapperProps">
|
||||
<template #label>
|
||||
<slot name="label" />
|
||||
</template>
|
||||
|
||||
<MentionDropdown
|
||||
:state="mentionState"
|
||||
:mentions="mentions"
|
||||
/>
|
||||
|
||||
<div class="relative">
|
||||
<div
|
||||
ref="editableDiv"
|
||||
:contenteditable="!disabled"
|
||||
class="mention-input"
|
||||
:style="inputStyle"
|
||||
:class="[
|
||||
theme.default.input,
|
||||
theme.default.borderRadius,
|
||||
theme.default.spacing.horizontal,
|
||||
theme.default.spacing.vertical,
|
||||
theme.default.fontSize,
|
||||
{
|
||||
'!ring-red-500 !ring-2 !border-transparent': hasError,
|
||||
'!cursor-not-allowed dark:!bg-gray-600 !bg-gray-200': disabled,
|
||||
},
|
||||
'pr-12'
|
||||
]"
|
||||
:placeholder="placeholder"
|
||||
@input="onInput"
|
||||
/>
|
||||
<UButton
|
||||
type="button"
|
||||
color="white"
|
||||
class="absolute right-2 top-1/2 transform -translate-y-1/2 p-1 px-2"
|
||||
icon="i-heroicons-at-symbol-16-solid"
|
||||
@click="openMentionDropdown"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template
|
||||
v-if="$slots.help"
|
||||
#help
|
||||
>
|
||||
<slot name="help" />
|
||||
</template>
|
||||
|
||||
<template
|
||||
v-if="$slots.error"
|
||||
#error
|
||||
>
|
||||
<slot name="error" />
|
||||
</template>
|
||||
</InputWrapper>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch, computed } from 'vue'
|
||||
import { inputProps, useFormInput } from './useFormInput.js'
|
||||
import InputWrapper from './components/InputWrapper.vue'
|
||||
import MentionDropdown from './components/MentionDropdown.vue'
|
||||
const props = defineProps({
|
||||
...inputProps,
|
||||
mentions: { type: Array, default: () => [] },
|
||||
disableMention: { type: Boolean, default: false },
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const { compVal, inputStyle, hasValidation, hasError, inputWrapperProps } = useFormInput(props, { emit })
|
||||
const editableDiv = ref(null)
|
||||
const savedRange = ref(null)
|
||||
const subscriptionModalStore = useSubscriptionModalStore()
|
||||
|
||||
const mentionState = ref({
|
||||
open: false,
|
||||
onInsert: (mention) => {
|
||||
insertMention(mention)
|
||||
mentionState.value.open = false
|
||||
},
|
||||
onCancel: () => {
|
||||
mentionState.value.open = false
|
||||
restoreSelection()
|
||||
},
|
||||
})
|
||||
const createMentionSpan = (mention) => {
|
||||
const mentionSpan = document.createElement('span')
|
||||
mentionSpan.setAttribute('mention-field-id', mention.field.id)
|
||||
mentionSpan.setAttribute('mention-field-name', mention.field.name)
|
||||
mentionSpan.setAttribute('mention-fallback', mention.fallback || '')
|
||||
mentionSpan.setAttribute('contenteditable', 'false')
|
||||
mentionSpan.setAttribute('mention', 'true')
|
||||
mentionSpan.textContent = mention.field.name.length > 25 ? `${mention.field.name.slice(0, 25)}...` : mention.field.name
|
||||
return mentionSpan
|
||||
}
|
||||
const insertMention = (mention) => {
|
||||
const mentionSpan = createMentionSpan(mention)
|
||||
restoreSelection()
|
||||
const range = window.getSelection().getRangeAt(0)
|
||||
// Insert the mention span
|
||||
range.insertNode(mentionSpan)
|
||||
|
||||
// Move the cursor after the inserted mention
|
||||
range.setStartAfter(mentionSpan)
|
||||
range.collapse(true)
|
||||
// Apply the new selection
|
||||
const selection = window.getSelection()
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
// Ensure the editableDiv is focused
|
||||
editableDiv.value.focus()
|
||||
updateCompVal()
|
||||
}
|
||||
const openMentionDropdown = () => {
|
||||
if (props.disableMention) {
|
||||
subscriptionModalStore.setModalContent('Upgrade to Pro', 'Upgrade to Pro to use mentions')
|
||||
subscriptionModalStore.openModal()
|
||||
return
|
||||
}
|
||||
|
||||
saveSelection()
|
||||
if (!savedRange.value) {
|
||||
// If no previous selection, move cursor to the end
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(editableDiv.value)
|
||||
range.collapse(false)
|
||||
const selection = window.getSelection()
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
savedRange.value = range
|
||||
}
|
||||
mentionState.value.open = true
|
||||
}
|
||||
const saveSelection = () => {
|
||||
const selection = window.getSelection()
|
||||
if (selection.rangeCount > 0) {
|
||||
savedRange.value = selection.getRangeAt(0)
|
||||
}
|
||||
}
|
||||
const restoreSelection = () => {
|
||||
if (savedRange.value) {
|
||||
const selection = window.getSelection()
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(savedRange.value)
|
||||
editableDiv.value.focus()
|
||||
}
|
||||
}
|
||||
const updateCompVal = () => {
|
||||
compVal.value = editableDiv.value.innerHTML
|
||||
}
|
||||
const onInput = () => {
|
||||
updateCompVal()
|
||||
}
|
||||
onMounted(() => {
|
||||
if (compVal.value) {
|
||||
editableDiv.value.innerHTML = compVal.value
|
||||
}
|
||||
})
|
||||
watch(compVal, (newVal) => {
|
||||
if (editableDiv.value && editableDiv.value.innerHTML !== newVal) {
|
||||
editableDiv.value.innerHTML = newVal
|
||||
}
|
||||
})
|
||||
defineExpose({
|
||||
editableDiv,
|
||||
compVal,
|
||||
mentionState,
|
||||
openMentionDropdown,
|
||||
onInput,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mention-input {
|
||||
min-height: 1.5rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.mention-input:empty::before {
|
||||
content: attr(placeholder);
|
||||
color: #9ca3af;
|
||||
}
|
||||
.mention-input span[mention] {
|
||||
max-width: 150px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background-color: #dbeafe;
|
||||
color: #1e40af;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 0.25rem;
|
||||
padding: 0 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
position: relative;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -4,12 +4,8 @@
|
|||
<slot name="label" />
|
||||
</template>
|
||||
|
||||
<VueEditor
|
||||
:id="id ? id : name"
|
||||
ref="editor"
|
||||
v-model="compVal"
|
||||
:disabled="disabled ? true : null"
|
||||
:placeholder="placeholder"
|
||||
<div
|
||||
class="rich-editor resize-y"
|
||||
:class="[
|
||||
{
|
||||
'!ring-red-500 !ring-2 !border-transparent': hasError,
|
||||
|
|
@ -17,12 +13,23 @@
|
|||
},
|
||||
theme.RichTextAreaInput.input,
|
||||
theme.RichTextAreaInput.borderRadius,
|
||||
theme.default.fontSize,
|
||||
]"
|
||||
:editor-options="editorOptions"
|
||||
:editor-toolbar="editorToolbar"
|
||||
class="rich-editor resize-y"
|
||||
:style="inputStyle"
|
||||
/>
|
||||
:style="{
|
||||
'--font-size': theme.default.fontSize
|
||||
}"
|
||||
>
|
||||
<QuillyEditor
|
||||
:id="id ? id : name"
|
||||
ref="editor"
|
||||
:key="id+placeholder"
|
||||
v-model="compVal"
|
||||
:options="quillOptions"
|
||||
:disabled="disabled"
|
||||
:placeholder="placeholder"
|
||||
:style="inputStyle"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template #help>
|
||||
<slot name="help" />
|
||||
|
|
@ -30,59 +37,72 @@
|
|||
<template #error>
|
||||
<slot name="error" />
|
||||
</template>
|
||||
|
||||
<MentionDropdown
|
||||
v-if="enableMentions && mentionState"
|
||||
:state="mentionState"
|
||||
:mentions="mentions"
|
||||
/>
|
||||
</InputWrapper>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Quill, VueEditor } from 'vue3-editor'
|
||||
<script setup>
|
||||
import Quill from 'quill'
|
||||
import { inputProps, useFormInput } from './useFormInput.js'
|
||||
import InputWrapper from './components/InputWrapper.vue'
|
||||
|
||||
Quill.imports['formats/link'].PROTOCOL_WHITELIST.push('notion')
|
||||
|
||||
export default {
|
||||
name: 'RichTextAreaInput',
|
||||
components: { InputWrapper, VueEditor },
|
||||
|
||||
props: {
|
||||
...inputProps,
|
||||
editorOptions: {
|
||||
type: Object,
|
||||
default: () => {
|
||||
return {
|
||||
formats: [
|
||||
'bold',
|
||||
'color',
|
||||
'font',
|
||||
'italic',
|
||||
'link',
|
||||
'underline',
|
||||
'header',
|
||||
'indent',
|
||||
'list'
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
editorToolbar: {
|
||||
type: Array,
|
||||
default: () => {
|
||||
return [
|
||||
[{ header: 1 }, { header: 2 }],
|
||||
['bold', 'italic', 'underline', 'link'],
|
||||
[{ list: 'ordered' }, { list: 'bullet' }],
|
||||
[{ color: [] }]
|
||||
]
|
||||
}
|
||||
}
|
||||
import QuillyEditor from './components/QuillyEditor.vue'
|
||||
import MentionDropdown from './components/MentionDropdown.vue'
|
||||
import registerMentionExtension from '~/lib/quill/quillMentionExtension.js'
|
||||
const props = defineProps({
|
||||
...inputProps,
|
||||
editorOptions: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
enableMentions: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
mentions: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const { compVal, inputStyle, hasError, inputWrapperProps } = useFormInput(props, { emit })
|
||||
const editor = ref(null)
|
||||
const mentionState = ref(null)
|
||||
// Move the mention extension registration to onMounted
|
||||
|
||||
setup (props, context) {
|
||||
return {
|
||||
...useFormInput(props, context)
|
||||
if (props.enableMentions && !Quill.imports['blots/mention']) {
|
||||
mentionState.value = registerMentionExtension(Quill)
|
||||
}
|
||||
|
||||
const quillOptions = computed(() => {
|
||||
const defaultOptions = {
|
||||
theme: 'snow',
|
||||
modules: {
|
||||
toolbar: [
|
||||
[{ 'header': 1 }, { 'header': 2 }],
|
||||
['bold', 'italic', 'underline', 'strike'],
|
||||
['link'],
|
||||
[{ list: 'ordered' }, { list: 'bullet' }],
|
||||
[{ color: [] }],
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
const mergedOptions = { ...defaultOptions, ...props.editorOptions, modules: { ...defaultOptions.modules, ...props.editorOptions.modules } }
|
||||
|
||||
if (props.enableMentions) {
|
||||
mergedOptions.modules.mention = true
|
||||
if (!mergedOptions.modules.toolbar) {
|
||||
mergedOptions.modules.toolbar = []
|
||||
}
|
||||
mergedOptions.modules.toolbar.push(['mention'])
|
||||
}
|
||||
|
||||
return mergedOptions
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
|
|
@ -91,18 +111,22 @@ export default {
|
|||
border-bottom: 0px !important;
|
||||
border-right: 0px !important;
|
||||
border-left: 0px !important;
|
||||
|
||||
font-size: var(--font-size);
|
||||
.ql-editor {
|
||||
min-height: 100px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.ql-toolbar {
|
||||
border-top: 0px !important;
|
||||
border-right: 0px !important;
|
||||
border-left: 0px !important;
|
||||
}
|
||||
|
||||
.ql-header {
|
||||
@apply rounded-md;
|
||||
}
|
||||
.ql-editor.ql-blank:before {
|
||||
@apply text-gray-400 dark:text-gray-500 not-italic;
|
||||
}
|
||||
.ql-snow .ql-toolbar .ql-picker-item.ql-selected,
|
||||
.ql-snow .ql-toolbar .ql-picker-item:hover,
|
||||
.ql-snow .ql-toolbar .ql-picker-label.ql-active,
|
||||
|
|
@ -120,4 +144,21 @@ export default {
|
|||
@apply text-nt-blue;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
.ql-mention {
|
||||
padding-top: 0px !important;
|
||||
margin-top: -5px !important;
|
||||
}
|
||||
.ql-mention::after {
|
||||
content: '@';
|
||||
font-size: 16px;
|
||||
}
|
||||
.rich-editor, .mention-input {
|
||||
span[mention] {
|
||||
@apply inline-flex items-center align-baseline leading-tight text-sm relative bg-blue-100 text-blue-800 border border-blue-200 rounded-md px-1 py-0.5 mx-0.5;
|
||||
max-width: 200px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
<template>
|
||||
<div
|
||||
v-html="processedContent"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
content: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
mentionsAllowed: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
form: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
formData: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
const processedContent = computed(() => {
|
||||
return useParseMention(props.content, props.mentionsAllowed, props.form, props.formData)
|
||||
})
|
||||
</script>
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
import { format, parseISO } from 'date-fns'
|
||||
|
||||
export class FormSubmissionFormatter {
|
||||
constructor(form, formData) {
|
||||
this.form = form
|
||||
this.formData = formData
|
||||
this.createLinks = false
|
||||
this.outputStringsOnly = false
|
||||
this.showGeneratedIds = false
|
||||
this.datesIsoFormat = false
|
||||
}
|
||||
|
||||
setCreateLinks() {
|
||||
this.createLinks = true
|
||||
return this
|
||||
}
|
||||
|
||||
setShowGeneratedIds() {
|
||||
this.showGeneratedIds = true
|
||||
return this
|
||||
}
|
||||
|
||||
setOutputStringsOnly() {
|
||||
this.outputStringsOnly = true
|
||||
return this
|
||||
}
|
||||
|
||||
setUseIsoFormatForDates() {
|
||||
this.datesIsoFormat = true
|
||||
return this
|
||||
}
|
||||
|
||||
getFormattedData() {
|
||||
const formattedData = {}
|
||||
|
||||
this.form.properties.forEach(field => {
|
||||
if (!this.formData[field.id] && !this.fieldGeneratesId(field)) {
|
||||
return
|
||||
}
|
||||
|
||||
const value = this.formatFieldValue(field, this.formData[field.id])
|
||||
formattedData[field.id] = value
|
||||
})
|
||||
|
||||
return formattedData
|
||||
}
|
||||
|
||||
formatFieldValue(field, value) {
|
||||
switch (field.type) {
|
||||
case 'url':
|
||||
return this.createLinks ? `<a href="${value}">${value}</a>` : value
|
||||
case 'email':
|
||||
return this.createLinks ? `<a href="mailto:${value}">${value}</a>` : value
|
||||
case 'checkbox':
|
||||
return value ? 'Yes' : 'No'
|
||||
case 'date':
|
||||
return this.formatDateValue(field, value)
|
||||
case 'people':
|
||||
return this.formatPeopleValue(value)
|
||||
case 'multi_select':
|
||||
return this.outputStringsOnly ? value.join(', ') : value
|
||||
case 'relation':
|
||||
return this.formatRelationValue(value)
|
||||
default:
|
||||
return Array.isArray(value) && this.outputStringsOnly ? value.join(', ') : value
|
||||
}
|
||||
}
|
||||
|
||||
formatDateValue(field, value) {
|
||||
if (this.datesIsoFormat) {
|
||||
return Array.isArray(value)
|
||||
? { start_date: value[0], end_date: value[1] || null }
|
||||
: value
|
||||
}
|
||||
|
||||
const dateFormat = (field.date_format || 'dd/MM/yyyy') === 'dd/MM/yyyy' ? 'dd/MM/yyyy' : 'MM/dd/yyyy'
|
||||
const timeFormat = field.with_time ? (field.time_format === 24 ? 'HH:mm' : 'h:mm a') : ''
|
||||
const fullFormat = `${dateFormat}${timeFormat ? ' ' + timeFormat : ''}`
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const start = format(parseISO(value[0]), fullFormat)
|
||||
const end = value[1] ? format(parseISO(value[1]), fullFormat) : null
|
||||
return end ? `${start} - ${end}` : start
|
||||
}
|
||||
|
||||
return format(parseISO(value), fullFormat)
|
||||
}
|
||||
|
||||
formatPeopleValue(value) {
|
||||
if (!value) return []
|
||||
const people = Array.isArray(value) ? value : [value]
|
||||
return this.outputStringsOnly ? people.map(p => p.name).join(', ') : people
|
||||
}
|
||||
|
||||
formatRelationValue(value) {
|
||||
if (!value) return []
|
||||
const relations = Array.isArray(value) ? value : [value]
|
||||
const formatted = relations.map(r => r.title || 'Untitled')
|
||||
return this.outputStringsOnly ? formatted.join(', ') : formatted
|
||||
}
|
||||
|
||||
fieldGeneratesId(field) {
|
||||
return this.showGeneratedIds && (field.generates_auto_increment_id || field.generates_uuid)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
<template>
|
||||
<UPopover
|
||||
ref="popover"
|
||||
v-model:open="open"
|
||||
class="h-0"
|
||||
@close="cancel"
|
||||
>
|
||||
<span class="hidden" />
|
||||
<template #panel>
|
||||
<div class="p-2 max-h-[300px] flex flex-col">
|
||||
<div class="flex items-center border-b -mx-2 px-2">
|
||||
<div class="font-semibold w-1/2 mb-2 flex-grow">
|
||||
Insert Mention
|
||||
</div>
|
||||
<input
|
||||
v-model="fallbackValue"
|
||||
class="p-1 mb-2 text-sm w-1/2 border rounded-md hover:bg-gray-50"
|
||||
placeholder="Fallback value"
|
||||
>
|
||||
</div>
|
||||
<div class="overflow-scroll pt-2">
|
||||
<div class="w-full max-w-xs mb-2">
|
||||
<div class="text-sm text-gray-500 mb-1">
|
||||
Select a field
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div
|
||||
v-for="field in filteredMentions"
|
||||
:key="field.id"
|
||||
class="flex items-center p-2 rounded-md cursor-pointer hover:bg-gray-100"
|
||||
:class="{ 'bg-blue-50 border border-blue-100 inset-0': selectedField?.id === field.id, 'border border-transparent': selectedField?.id !== field.id }"
|
||||
@click="selectField(field)"
|
||||
@dblclick="selectField(field, true)"
|
||||
>
|
||||
<BlockTypeIcon
|
||||
:type="field.type"
|
||||
class="mr-2"
|
||||
/>
|
||||
<p class="text-sm text-gray-700 truncate">
|
||||
{{ field.name }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex border-t pt-2 -mx-2 px-2 justify-end space-x-2">
|
||||
<UButton
|
||||
size="sm"
|
||||
color="primary"
|
||||
class="px-6"
|
||||
:disabled="!selectedField"
|
||||
@click="insertMention"
|
||||
>
|
||||
Insert
|
||||
</UButton>
|
||||
<UButton
|
||||
size="sm"
|
||||
color="gray"
|
||||
@click="cancel"
|
||||
>
|
||||
Cancel
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UPopover>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, toRefs } from 'vue'
|
||||
import BlockTypeIcon from '~/components/open/forms/components/BlockTypeIcon.vue'
|
||||
import blocksTypes from '~/data/blocks_types.json'
|
||||
const props = defineProps({
|
||||
state: Object,
|
||||
mentions: Array
|
||||
})
|
||||
defineShortcuts({
|
||||
escape: () => {
|
||||
open.value = false
|
||||
}
|
||||
})
|
||||
const { open, onInsert, onCancel } = toRefs(props.state)
|
||||
const selectedField = ref(null)
|
||||
const fallbackValue = ref('')
|
||||
const filteredMentions = computed(() => {
|
||||
return props.mentions.filter(mention => blocksTypes[mention.type]?.is_input ?? false)
|
||||
})
|
||||
function selectField(field, insert = false) {
|
||||
selectedField.value = {...field}
|
||||
if (insert) {
|
||||
insertMention()
|
||||
}
|
||||
}
|
||||
watch(open, (newValue) => {
|
||||
if (newValue) {
|
||||
selectedField.value = null
|
||||
fallbackValue.value = ''
|
||||
}
|
||||
})
|
||||
const insertMention = () => {
|
||||
if (selectedField.value && onInsert.value) {
|
||||
onInsert.value({
|
||||
field: selectedField.value,
|
||||
fallback: fallbackValue.value
|
||||
})
|
||||
open.value = false
|
||||
}
|
||||
}
|
||||
const cancel = () => {
|
||||
if (onCancel.value) {
|
||||
onCancel.value()
|
||||
}
|
||||
open.value = false
|
||||
}
|
||||
</script>
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
<template>
|
||||
<div
|
||||
ref="container"
|
||||
class="quilly-editor"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Quill from 'quill'
|
||||
import 'quill/dist/quill.snow.css'
|
||||
import { onMounted, onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
options: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'update:modelValue',
|
||||
'text-change',
|
||||
'selection-change',
|
||||
'editor-change',
|
||||
'blur',
|
||||
'focus',
|
||||
'ready'
|
||||
])
|
||||
|
||||
let quillInstance = null
|
||||
const container = ref(null)
|
||||
let isInternalChange = false
|
||||
|
||||
const setContents = (content) => {
|
||||
if (!quillInstance) return
|
||||
|
||||
isInternalChange = true
|
||||
quillInstance.root.innerHTML = content
|
||||
quillInstance.update()
|
||||
isInternalChange = false
|
||||
}
|
||||
|
||||
const initializeQuill = () => {
|
||||
if (container.value) {
|
||||
quillInstance = new Quill(container.value, props.options)
|
||||
|
||||
quillInstance.on('selection-change', (range, oldRange, source) => {
|
||||
if (!range) {
|
||||
emit('blur', quillInstance)
|
||||
} else {
|
||||
emit('focus', quillInstance)
|
||||
}
|
||||
emit('selection-change', { range, oldRange, source })
|
||||
})
|
||||
|
||||
quillInstance.on('text-change', (delta, oldContents, source) => {
|
||||
if (!isInternalChange) {
|
||||
const html = quillInstance.root.innerHTML
|
||||
emit('text-change', { delta, oldContents, source })
|
||||
emit('update:modelValue', html)
|
||||
}
|
||||
})
|
||||
|
||||
quillInstance.on('editor-change', (eventName, ...args) => {
|
||||
emit('editor-change', eventName, ...args)
|
||||
})
|
||||
|
||||
if (props.modelValue) {
|
||||
setContents(props.modelValue)
|
||||
}
|
||||
|
||||
emit('ready', quillInstance)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initializeQuill()
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, (newValue) => {
|
||||
if (quillInstance && newValue !== quillInstance.root.innerHTML) {
|
||||
setContents(newValue || '')
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (quillInstance) {
|
||||
quillInstance.off('selection-change')
|
||||
quillInstance.off('text-change')
|
||||
quillInstance.off('editor-change')
|
||||
}
|
||||
quillInstance = null
|
||||
})
|
||||
</script>
|
||||
|
|
@ -4,8 +4,7 @@
|
|||
<div
|
||||
v-if="show"
|
||||
ref="backdrop"
|
||||
class="fixed z-40 top-0 inset-0 px-2 sm:px-4 flex items-top justify-center bg-gray-700/75 w-full h-screen overflow-y-scroll"
|
||||
:class="{ 'backdrop-blur-sm': backdropBlur }"
|
||||
:class="[{ 'backdrop-blur-sm': backdropBlur }, twMerge('fixed z-40 top-0 inset-0 px-2 sm:px-4 flex items-top justify-center bg-gray-700/75 w-full h-screen overflow-y-scroll', $attrs.class)]"
|
||||
@click.self="close"
|
||||
>
|
||||
<div
|
||||
|
|
@ -95,6 +94,7 @@
|
|||
<script setup>
|
||||
import { watch } from 'vue'
|
||||
import { default as _has } from 'lodash/has'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
|
|
|
|||
|
|
@ -140,9 +140,13 @@
|
|||
key="submitted"
|
||||
class="px-2"
|
||||
>
|
||||
<p
|
||||
<TextBlock
|
||||
v-if="form.submitted_text"
|
||||
class="form-description text-gray-700 dark:text-gray-300 whitespace-pre-wrap"
|
||||
v-html="form.submitted_text "
|
||||
:content="form.submitted_text"
|
||||
:mentions-allowed="true"
|
||||
:form="form"
|
||||
:form-data="submittedData"
|
||||
/>
|
||||
<open-form-button
|
||||
v-if="form.re_fillable"
|
||||
|
|
@ -232,6 +236,7 @@ export default {
|
|||
}),
|
||||
hidePasswordDisabledMsg: false,
|
||||
submissionId: false,
|
||||
submittedData: null,
|
||||
showFirstSubmissionModal: false
|
||||
}
|
||||
},
|
||||
|
|
@ -274,6 +279,7 @@ export default {
|
|||
this.loading = true
|
||||
|
||||
form.post('/forms/' + this.form.slug + '/answer').then((data) => {
|
||||
this.submittedData = form.data()
|
||||
useAmplitude().logEvent('form_submission', {
|
||||
workspace_id: this.form.workspace_id,
|
||||
form_id: this.form.id
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
<modal
|
||||
:show="show"
|
||||
compact-header
|
||||
backdrop-blur="sm"
|
||||
;backdrop-blur="true"
|
||||
@close="$emit('close')"
|
||||
>
|
||||
<template #title>
|
||||
|
|
|
|||
|
|
@ -118,9 +118,10 @@
|
|||
</template>
|
||||
</select-input>
|
||||
<template v-if="submissionOptions.submissionMode === 'redirect'">
|
||||
<text-input
|
||||
<MentionInput
|
||||
name="redirect_url"
|
||||
:form="form"
|
||||
:mentions="form.properties"
|
||||
class="w-full max-w-xs"
|
||||
label="Redirect URL"
|
||||
placeholder="https://www.google.com"
|
||||
|
|
@ -129,6 +130,8 @@
|
|||
</template>
|
||||
<template v-else>
|
||||
<rich-text-area-input
|
||||
enable-mentions
|
||||
:mentions="form.properties"
|
||||
name="submitted_text"
|
||||
class="w-full"
|
||||
:form="form"
|
||||
|
|
|
|||
|
|
@ -29,7 +29,10 @@
|
|||
<h4 class="font-bold mt-4">
|
||||
Discord message options
|
||||
</h4>
|
||||
<notifications-message-actions v-model="integrationData.settings" />
|
||||
<notifications-message-actions
|
||||
v-model="integrationData.settings"
|
||||
:form="form"
|
||||
/>
|
||||
</IntegrationWrapper>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -14,18 +14,66 @@
|
|||
</NuxtLink> to send emails from your own domain.
|
||||
</p>
|
||||
|
||||
<text-area-input
|
||||
<MentionInput
|
||||
:form="integrationData"
|
||||
name="settings.notification_emails"
|
||||
:mentions="form.properties"
|
||||
:disable-mention="!form.is_pro"
|
||||
name="settings.send_to"
|
||||
required
|
||||
label="Notification Emails"
|
||||
label="Send To"
|
||||
help="Add one email per line"
|
||||
/>
|
||||
<text-input
|
||||
/>
|
||||
<div class="flex space-x-4">
|
||||
<text-input
|
||||
:form="integrationData"
|
||||
name="settings.sender_name"
|
||||
label="Sender Name"
|
||||
class="flex-1"
|
||||
/>
|
||||
<text-input
|
||||
v-if="selfHosted"
|
||||
:form="integrationData"
|
||||
name="settings.sender_email"
|
||||
label="Sender Email"
|
||||
help="If supported by email provider - default otherwise"
|
||||
class="flex-1"
|
||||
/>
|
||||
</div>
|
||||
<MentionInput
|
||||
:form="integrationData"
|
||||
name="settings.notification_reply_to"
|
||||
label="Notification Reply To"
|
||||
:help="notifiesHelp"
|
||||
:mentions="form.properties"
|
||||
required
|
||||
name="settings.subject"
|
||||
label="Subject"
|
||||
/>
|
||||
<rich-text-area-input
|
||||
:form="integrationData"
|
||||
:enable-mentions="true"
|
||||
:mentions="form.properties"
|
||||
name="settings.email_content"
|
||||
label="Email Content"
|
||||
/>
|
||||
<toggle-switch-input
|
||||
:form="integrationData"
|
||||
name="settings.include_submission_data"
|
||||
class="mt-4"
|
||||
label="Include submission data"
|
||||
help="If enabled the email will contain form submission answers"
|
||||
/>
|
||||
<toggle-switch-input
|
||||
v-if="integrationData.settings.include_submission_data"
|
||||
:form="integrationData"
|
||||
name="settings.include_hidden_fields_submission_data"
|
||||
class="mt-4"
|
||||
label="Include hidden fields"
|
||||
help="If enabled the email will contain hidden fields"
|
||||
/>
|
||||
<MentionInput
|
||||
:form="integrationData"
|
||||
:mentions="form.properties"
|
||||
name="settings.reply_to"
|
||||
label="Reply To"
|
||||
help="If empty, Reply-to will be your own email."
|
||||
/>
|
||||
</IntegrationWrapper>
|
||||
</template>
|
||||
|
|
@ -40,22 +88,19 @@ const props = defineProps({
|
|||
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 selfHosted = computed(() => useFeatureFlag('self_hosted'))
|
||||
|
||||
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 +
|
||||
'".'
|
||||
)
|
||||
onBeforeMount(() => {
|
||||
for (const [keyname, defaultValue] of Object.entries({
|
||||
sender_name: "OpnForm",
|
||||
subject: "We saved your answers",
|
||||
email_content: "Hello there 👋 <br>This is a confirmation that your submission was successfully saved.",
|
||||
include_submission_data: true,
|
||||
include_hidden_fields_submission_data: false,
|
||||
})) {
|
||||
if (props.integrationData.settings[keyname] === undefined) {
|
||||
props.integrationData.settings[keyname] = defaultValue
|
||||
}
|
||||
}
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -29,7 +29,10 @@
|
|||
<h4 class="font-bold mt-4">
|
||||
Slack message actions
|
||||
</h4>
|
||||
<notifications-message-actions v-model="integrationData.settings" />
|
||||
<notifications-message-actions
|
||||
v-model="integrationData.settings"
|
||||
:form="form"
|
||||
/>
|
||||
</IntegrationWrapper>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,108 +0,0 @@
|
|||
<template>
|
||||
<IntegrationWrapper
|
||||
v-model="props.integrationData"
|
||||
:integration="props.integration"
|
||||
:form="form"
|
||||
>
|
||||
<div class="text-gray-500 text-sm">
|
||||
{{ emailSubmissionConfirmationHelp }}
|
||||
</div>
|
||||
|
||||
<p class="text-gray-500 text-sm mb-3">
|
||||
You can <NuxtLink
|
||||
class="underline"
|
||||
:to="{ name: 'settings-workspace' }"
|
||||
target="_blank"
|
||||
>
|
||||
use our custom SMTP feature
|
||||
</NuxtLink> to send emails from your own domain.
|
||||
</p>
|
||||
|
||||
<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({
|
||||
respondent_email: emailSubmissionConfirmationField.value !== null,
|
||||
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 (keyname === 'respondent_email' || props.integrationData.settings[keyname] === undefined) {
|
||||
props.integrationData.settings[keyname] = defaultValue
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
|
@ -11,19 +11,18 @@
|
|||
label="Enabled"
|
||||
/>
|
||||
</slot>
|
||||
<slot name="help">
|
||||
<v-button
|
||||
class="flex"
|
||||
color="white"
|
||||
size="small"
|
||||
<slot
|
||||
v-if="integration?.crisp_help_page_slug"
|
||||
name="help"
|
||||
>
|
||||
<UButton
|
||||
color="gray"
|
||||
size="sm"
|
||||
icon="i-heroicons-question-mark-circle-solid"
|
||||
@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>
|
||||
Help
|
||||
</UButton>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,13 @@
|
|||
<template>
|
||||
<div>
|
||||
<MentionInput
|
||||
v-model="compVal.message"
|
||||
:mentions="form.properties"
|
||||
name="message"
|
||||
class="mt-4"
|
||||
label="Notification Message"
|
||||
help="Customize the text of the notification message. Click @ to include form field values."
|
||||
/>
|
||||
<toggle-switch-input
|
||||
v-model="compVal.include_submission_data"
|
||||
name="include_submission_data"
|
||||
|
|
@ -43,6 +51,7 @@ export default {
|
|||
components: {},
|
||||
props: {
|
||||
modelValue: { type: Object, required: false },
|
||||
form: { type: Object, required: true },
|
||||
},
|
||||
emits: ['modelValue', 'input'],
|
||||
data() {
|
||||
|
|
@ -74,6 +83,7 @@ export default {
|
|||
this.compVal = {}
|
||||
}
|
||||
[
|
||||
"message",
|
||||
"include_submission_data",
|
||||
"link_open_form",
|
||||
"link_edit_form",
|
||||
|
|
@ -81,7 +91,11 @@ export default {
|
|||
"link_edit_submission",
|
||||
].forEach((keyname) => {
|
||||
if (this.compVal[keyname] === undefined) {
|
||||
this.compVal[keyname] = true
|
||||
if (keyname === 'message') {
|
||||
this.compVal[keyname] = 'New form submission'
|
||||
} else {
|
||||
this.compVal[keyname] = true
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
compact-header
|
||||
max-width="screen-lg"
|
||||
backdrop-blur
|
||||
class="z-50"
|
||||
@close="subscriptionModalStore.closeModal()"
|
||||
>
|
||||
<div class="overflow-hidden">
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ class Form {
|
|||
Object.keys(this)
|
||||
.filter((key) => !Form.ignore.includes(key))
|
||||
.forEach((key) => {
|
||||
this[key] = JSON.parse(JSON.stringify(this.originalData[key]))
|
||||
this[key] = cloneDeep(this.originalData[key])
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
import { FormSubmissionFormatter } from '~/components/forms/components/FormSubmissionFormatter'
|
||||
|
||||
export function useParseMention(content, mentionsAllowed, form, formData) {
|
||||
if (!mentionsAllowed || !form || !formData) {
|
||||
return content
|
||||
}
|
||||
|
||||
const formatter = new FormSubmissionFormatter(form, formData).setOutputStringsOnly()
|
||||
const formattedData = formatter.getFormattedData()
|
||||
|
||||
// Create a new DOMParser
|
||||
const parser = new DOMParser()
|
||||
// Parse the content as HTML
|
||||
const doc = parser.parseFromString(content, 'text/html')
|
||||
|
||||
// Find all elements with mention attribute
|
||||
const mentionElements = doc.querySelectorAll('[mention]')
|
||||
|
||||
mentionElements.forEach(element => {
|
||||
const fieldId = element.getAttribute('mention-field-id')
|
||||
const fallback = element.getAttribute('mention-fallback')
|
||||
const value = formattedData[fieldId]
|
||||
|
||||
if (value) {
|
||||
if (Array.isArray(value)) {
|
||||
element.textContent = value.join(', ')
|
||||
} else {
|
||||
element.textContent = value
|
||||
}
|
||||
} else if (fallback) {
|
||||
element.textContent = fallback
|
||||
} else {
|
||||
element.remove()
|
||||
}
|
||||
})
|
||||
|
||||
// Return the processed HTML content
|
||||
return doc.body.innerHTML
|
||||
}
|
||||
|
|
@ -5,7 +5,8 @@
|
|||
"icon": "i-heroicons-bars-3-bottom-left",
|
||||
"default_block_name": "Your name",
|
||||
"bg_class": "bg-blue-100",
|
||||
"text_class": "text-blue-900"
|
||||
"text_class": "text-blue-900",
|
||||
"is_input": true
|
||||
},
|
||||
"date": {
|
||||
"name": "date",
|
||||
|
|
@ -13,7 +14,8 @@
|
|||
"icon": "i-heroicons-calendar-20-solid",
|
||||
"default_block_name": "Date",
|
||||
"bg_class": "bg-green-100",
|
||||
"text_class": "text-green-900"
|
||||
"text_class": "text-green-900",
|
||||
"is_input": true
|
||||
},
|
||||
"url": {
|
||||
"name": "url",
|
||||
|
|
@ -21,7 +23,8 @@
|
|||
"icon": "i-heroicons-link-20-solid",
|
||||
"default_block_name": "Link",
|
||||
"bg_class": "bg-blue-100",
|
||||
"text_class": "text-blue-900"
|
||||
"text_class": "text-blue-900",
|
||||
"is_input": true
|
||||
},
|
||||
"phone_number": {
|
||||
"name": "phone_number",
|
||||
|
|
@ -29,7 +32,8 @@
|
|||
"icon": "i-heroicons-phone-20-solid",
|
||||
"default_block_name": "Phone Number",
|
||||
"bg_class": "bg-blue-100",
|
||||
"text_class": "text-blue-900"
|
||||
"text_class": "text-blue-900",
|
||||
"is_input": true
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
|
|
@ -37,7 +41,8 @@
|
|||
"icon": "i-heroicons-at-symbol-20-solid",
|
||||
"default_block_name": "Email",
|
||||
"bg_class": "bg-blue-100",
|
||||
"text_class": "text-blue-900"
|
||||
"text_class": "text-blue-900",
|
||||
"is_input": true
|
||||
},
|
||||
"checkbox": {
|
||||
"name": "checkbox",
|
||||
|
|
@ -45,7 +50,8 @@
|
|||
"icon": "i-heroicons-check-circle",
|
||||
"default_block_name": "Checkbox",
|
||||
"bg_class": "bg-red-100",
|
||||
"text_class": "text-red-900"
|
||||
"text_class": "text-red-900",
|
||||
"is_input": true
|
||||
},
|
||||
"select": {
|
||||
"name": "select",
|
||||
|
|
@ -53,7 +59,8 @@
|
|||
"icon": "i-heroicons-chevron-up-down-20-solid",
|
||||
"default_block_name": "Select",
|
||||
"bg_class": "bg-red-100",
|
||||
"text_class": "text-red-900"
|
||||
"text_class": "text-red-900",
|
||||
"is_input": true
|
||||
},
|
||||
"multi_select": {
|
||||
"name": "multi_select",
|
||||
|
|
@ -61,7 +68,8 @@
|
|||
"icon": "i-heroicons-chevron-up-down-20-solid",
|
||||
"default_block_name": "Multi Select",
|
||||
"bg_class": "bg-red-100",
|
||||
"text_class": "text-red-900"
|
||||
"text_class": "text-red-900",
|
||||
"is_input": true
|
||||
},
|
||||
"matrix": {
|
||||
"name": "matrix",
|
||||
|
|
@ -69,7 +77,8 @@
|
|||
"icon": "i-heroicons-table-cells-20-solid",
|
||||
"default_block_name": "Matrix",
|
||||
"bg_class": "bg-red-100",
|
||||
"text_class": "text-red-900"
|
||||
"text_class": "text-red-900",
|
||||
"is_input": true
|
||||
},
|
||||
"number": {
|
||||
"name": "number",
|
||||
|
|
@ -77,7 +86,8 @@
|
|||
"icon": "i-heroicons-hashtag-20-solid",
|
||||
"default_block_name": "Number",
|
||||
"bg_class": "bg-purple-100",
|
||||
"text_class": "text-purple-900"
|
||||
"text_class": "text-purple-900",
|
||||
"is_input": true
|
||||
},
|
||||
"rating": {
|
||||
"name": "rating",
|
||||
|
|
@ -85,7 +95,8 @@
|
|||
"icon": "i-heroicons-star",
|
||||
"default_block_name": "Rating",
|
||||
"bg_class": "bg-purple-100",
|
||||
"text_class": "text-purple-900"
|
||||
"text_class": "text-purple-900",
|
||||
"is_input": true
|
||||
},
|
||||
"scale": {
|
||||
"name": "scale",
|
||||
|
|
@ -93,7 +104,8 @@
|
|||
"icon": "i-heroicons-scale-20-solid",
|
||||
"default_block_name": "Scale",
|
||||
"bg_class": "bg-purple-100",
|
||||
"text_class": "text-purple-900"
|
||||
"text_class": "text-purple-900",
|
||||
"is_input": true
|
||||
},
|
||||
"slider": {
|
||||
"name": "slider",
|
||||
|
|
@ -101,7 +113,8 @@
|
|||
"icon": "i-heroicons-adjustments-horizontal",
|
||||
"default_block_name": "Slider",
|
||||
"bg_class": "bg-purple-100",
|
||||
"text_class": "text-purple-900"
|
||||
"text_class": "text-purple-900",
|
||||
"is_input": true
|
||||
},
|
||||
"files": {
|
||||
"name": "files",
|
||||
|
|
@ -109,7 +122,8 @@
|
|||
"icon": "i-heroicons-paper-clip",
|
||||
"default_block_name": "Files",
|
||||
"bg_class": "bg-pink-100",
|
||||
"text_class": "text-pink-900"
|
||||
"text_class": "text-pink-900",
|
||||
"is_input": true
|
||||
},
|
||||
"signature": {
|
||||
"name": "signature",
|
||||
|
|
@ -117,7 +131,8 @@
|
|||
"icon": "i-heroicons-pencil-square-20-solid",
|
||||
"default_block_name": "Signature",
|
||||
"bg_class": "bg-pink-100",
|
||||
"text_class": "text-pink-900"
|
||||
"text_class": "text-pink-900",
|
||||
"is_input": true
|
||||
},
|
||||
"nf-text": {
|
||||
"name": "nf-text",
|
||||
|
|
@ -125,7 +140,8 @@
|
|||
"icon": "i-heroicons-bars-3",
|
||||
"default_block_name": "Text",
|
||||
"bg_class": "bg-yellow-100",
|
||||
"text_class": "text-yellow-900"
|
||||
"text_class": "text-yellow-900",
|
||||
"is_input": false
|
||||
},
|
||||
"nf-page-break": {
|
||||
"name": "nf-page-break",
|
||||
|
|
@ -133,7 +149,8 @@
|
|||
"icon": "i-heroicons-document-plus",
|
||||
"default_block_name": "Page Break",
|
||||
"bg_class": "bg-gray-100",
|
||||
"text_class": "text-gray-900"
|
||||
"text_class": "text-gray-900",
|
||||
"is_input": false
|
||||
},
|
||||
"nf-divider": {
|
||||
"name": "nf-divider",
|
||||
|
|
@ -141,7 +158,8 @@
|
|||
"icon": "i-heroicons-minus",
|
||||
"default_block_name": "Divider",
|
||||
"bg_class": "bg-gray-100",
|
||||
"text_class": "text-gray-900"
|
||||
"text_class": "text-gray-900",
|
||||
"is_input": false
|
||||
},
|
||||
"nf-image": {
|
||||
"name": "nf-image",
|
||||
|
|
@ -149,7 +167,8 @@
|
|||
"icon": "i-heroicons-photo",
|
||||
"default_block_name": "Image",
|
||||
"bg_class": "bg-yellow-100",
|
||||
"text_class": "text-yellow-900"
|
||||
"text_class": "text-yellow-900",
|
||||
"is_input": false
|
||||
},
|
||||
"nf-code": {
|
||||
"name": "nf-code",
|
||||
|
|
@ -157,6 +176,7 @@
|
|||
"icon": "i-heroicons-code-bracket",
|
||||
"default_block_name": "Code Block",
|
||||
"bg_class": "bg-yellow-100",
|
||||
"text_class": "text-yellow-900"
|
||||
"text_class": "text-yellow-900",
|
||||
"is_input": false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,13 +7,6 @@
|
|||
"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",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,130 @@
|
|||
import { reactive } from 'vue'
|
||||
import Quill from 'quill'
|
||||
const Inline = Quill.import('blots/inline')
|
||||
|
||||
export default function registerMentionExtension(Quill) {
|
||||
class MentionBlot extends Inline {
|
||||
static blotName = 'mention'
|
||||
static tagName = 'SPAN'
|
||||
|
||||
static create(data) {
|
||||
let node = super.create()
|
||||
MentionBlot.setAttributes(node, data)
|
||||
return node
|
||||
}
|
||||
|
||||
static setAttributes(node, data) {
|
||||
node.setAttribute('contenteditable', 'false')
|
||||
node.setAttribute('mention', 'true')
|
||||
|
||||
if (data && typeof data === 'object') {
|
||||
node.setAttribute('mention-field-id', data.field?.nf_id || '')
|
||||
node.setAttribute('mention-field-name', data.field?.name || '')
|
||||
node.setAttribute('mention-fallback', data.fallback || '')
|
||||
node.textContent = data.field?.name || ''
|
||||
} else {
|
||||
// Handle case where data is not an object (e.g., during undo)
|
||||
node.textContent = data || ''
|
||||
}
|
||||
}
|
||||
|
||||
static formats(domNode) {
|
||||
return {
|
||||
'mention-field-id': domNode.getAttribute('mention-field-id') || '',
|
||||
'mention-field-name': domNode.getAttribute('mention-field-name') || '',
|
||||
'mention-fallback': domNode.getAttribute('mention-fallback') || ''
|
||||
}
|
||||
}
|
||||
|
||||
format(name, value) {
|
||||
if (name === 'mention' && value) {
|
||||
MentionBlot.setAttributes(this.domNode, value)
|
||||
} else {
|
||||
super.format(name, value)
|
||||
}
|
||||
}
|
||||
|
||||
formats() {
|
||||
let formats = super.formats()
|
||||
formats['mention'] = MentionBlot.formats(this.domNode)
|
||||
return formats
|
||||
}
|
||||
|
||||
static value(domNode) {
|
||||
return {
|
||||
field: {
|
||||
nf_id: domNode.getAttribute('mention-field-id') || '',
|
||||
name: domNode.getAttribute('mention-field-name') || ''
|
||||
},
|
||||
fallback: domNode.getAttribute('mention-fallback') || ''
|
||||
}
|
||||
}
|
||||
|
||||
// Override attach to ensure contenteditable is always set
|
||||
attach() {
|
||||
super.attach()
|
||||
this.domNode.setAttribute('contenteditable', 'false')
|
||||
}
|
||||
|
||||
length() {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
Quill.register(MentionBlot)
|
||||
|
||||
const mentionState = reactive({
|
||||
open: false,
|
||||
onInsert: null,
|
||||
onCancel: null,
|
||||
})
|
||||
|
||||
class MentionModule {
|
||||
constructor(quill, options) {
|
||||
this.quill = quill
|
||||
this.options = options
|
||||
|
||||
this.setupMentions()
|
||||
}
|
||||
|
||||
setupMentions() {
|
||||
const toolbar = this.quill.getModule('toolbar')
|
||||
if (toolbar) {
|
||||
toolbar.addHandler('mention', () => {
|
||||
const range = this.quill.getSelection()
|
||||
if (range) {
|
||||
mentionState.open = true
|
||||
mentionState.onInsert = (mention) => {
|
||||
this.insertMention(mention, range.index)
|
||||
}
|
||||
mentionState.onCancel = () => {
|
||||
mentionState.open = false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
insertMention(mention, index) {
|
||||
mentionState.open = false
|
||||
|
||||
// Insert the mention
|
||||
this.quill.insertEmbed(index, 'mention', mention, Quill.sources.USER)
|
||||
|
||||
// Calculate the length of the inserted mention
|
||||
const mentionLength = this.quill.getLength() - index
|
||||
|
||||
nextTick(() => {
|
||||
// Focus the editor
|
||||
this.quill.focus()
|
||||
|
||||
// Set the selection after the mention
|
||||
this.quill.setSelection(index + mentionLength, 0, Quill.sources.SILENT)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Quill.register('modules/mention', MentionModule)
|
||||
|
||||
return mentionState
|
||||
}
|
||||
|
|
@ -88,7 +88,7 @@ export const getHost = function () {
|
|||
* @returns {*}
|
||||
*/
|
||||
export const getDomain = function (url) {
|
||||
if (url.includes("localhost")) return "localhost"
|
||||
if (!url || url.includes("localhost")) return "localhost"
|
||||
try {
|
||||
if (!url.startsWith("http")) url = "https://" + url
|
||||
return new URL(url).hostname
|
||||
|
|
|
|||
|
|
@ -80,9 +80,6 @@ export default defineNuxtConfig({
|
|||
fallback: 'light',
|
||||
classPrefix: '',
|
||||
},
|
||||
ui: {
|
||||
icons: ['heroicons', 'material-symbols']
|
||||
},
|
||||
sitemap,
|
||||
runtimeConfig,
|
||||
gtm
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@
|
|||
"prismjs": "^1.29.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"query-builder-vue-3": "^1.0.1",
|
||||
"quill": "^2.0.2",
|
||||
"tailwind-merge": "^2.5.4",
|
||||
"tinymotion": "^0.2.0",
|
||||
"v-calendar": "^3.1.2",
|
||||
|
|
@ -46,8 +47,7 @@
|
|||
"vue-json-pretty": "^2.4.0",
|
||||
"vue-notion": "^3.0.0",
|
||||
"vue-signature-pad": "^3.0.2",
|
||||
"vue3-editor": "^0.1.1",
|
||||
"vuedraggable": "^4.1.0",
|
||||
"vuedraggable": "next",
|
||||
"webcam-easy": "^1.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -6219,15 +6219,6 @@
|
|||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/clone": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
|
||||
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/clone-deep": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz",
|
||||
|
|
@ -6458,17 +6449,6 @@
|
|||
"url": "https://github.com/sponsors/mesqueeb"
|
||||
}
|
||||
},
|
||||
"node_modules/core-js": {
|
||||
"version": "3.38.1",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.38.1.tgz",
|
||||
"integrity": "sha512-OP35aUorbU3Zvlx7pjsFdu1rGNnD4pgw/CWoYzRY3t2EzoVT7shKHY1dlAy3f41cGIO7ZDPQimhGFTlEYkG/Hw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/core-js"
|
||||
}
|
||||
},
|
||||
"node_modules/core-util-is": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||
|
|
@ -7679,9 +7659,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-2.0.3.tgz",
|
||||
"integrity": "sha512-jLN68Dx5kyFHaePoXWPsCGW5qdyZQtLYHkxkg02/Mz6g0kYpDx4FyP6XfArhQdlOC4b8Mv+EMxPo/8La7Tzghg==",
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
|
||||
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/events": {
|
||||
|
|
@ -7723,12 +7703,6 @@
|
|||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/extend": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/externality": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/externality/-/externality-1.0.2.tgz",
|
||||
|
|
@ -7752,7 +7726,6 @@
|
|||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
|
||||
"integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/fast-fifo": {
|
||||
|
|
@ -9574,12 +9547,24 @@
|
|||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash-es": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
|
||||
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.castarray": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz",
|
||||
"integrity": "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.clonedeep": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
|
||||
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.defaults": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
|
||||
|
|
@ -9592,6 +9577,12 @@
|
|||
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isequal": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
|
||||
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isplainobject": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||
|
|
@ -11867,9 +11858,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/parchment": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/parchment/-/parchment-1.1.4.tgz",
|
||||
"integrity": "sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==",
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz",
|
||||
"integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
|
|
@ -13142,39 +13133,34 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/quill": {
|
||||
"version": "1.3.7",
|
||||
"resolved": "https://registry.npmjs.org/quill/-/quill-1.3.7.tgz",
|
||||
"integrity": "sha512-hG/DVzh/TiknWtE6QmWAF/pxoZKYxfe3J/d/+ShUWkDvvkZQVTPeVmUJVu1uE6DDooC4fWTiCLh84ul89oNz5g==",
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/quill/-/quill-2.0.2.tgz",
|
||||
"integrity": "sha512-QfazNrhMakEdRG57IoYFwffUIr04LWJxbS/ZkidRFXYCQt63c1gK6Z7IHUXMx/Vh25WgPBU42oBaNzQ0K1R/xw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"clone": "^2.1.1",
|
||||
"deep-equal": "^1.0.1",
|
||||
"eventemitter3": "^2.0.3",
|
||||
"extend": "^3.0.2",
|
||||
"parchment": "^1.1.4",
|
||||
"quill-delta": "^3.6.2"
|
||||
"eventemitter3": "^5.0.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"parchment": "^3.0.0",
|
||||
"quill-delta": "^5.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"npm": ">=8.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/quill-delta": {
|
||||
"version": "3.6.3",
|
||||
"resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-3.6.3.tgz",
|
||||
"integrity": "sha512-wdIGBlcX13tCHOXGMVnnTVFtGRLoP0imqxM696fIPwIf5ODIYUHIvHbZcyvGlZFiFhK5XzDC2lpjbxRhnM05Tg==",
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz",
|
||||
"integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"deep-equal": "^1.0.1",
|
||||
"extend": "^3.0.2",
|
||||
"fast-diff": "1.1.2"
|
||||
"fast-diff": "^1.3.0",
|
||||
"lodash.clonedeep": "^4.5.0",
|
||||
"lodash.isequal": "^4.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10"
|
||||
"node": ">= 12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/quill-delta/node_modules/fast-diff": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.1.2.tgz",
|
||||
"integrity": "sha512-KaJUt+M9t1qaIteSvjc6P3RbMdXsNhK61GRftR6SNxqmhthcd9MGIi4T+o0jD8LUSpSnSKXE20nLtJ3fOHxQig==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/radix3": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/radix3/-/radix3-1.1.2.tgz",
|
||||
|
|
@ -16849,19 +16835,6 @@
|
|||
"vue": "^3.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vue3-editor": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/vue3-editor/-/vue3-editor-0.1.1.tgz",
|
||||
"integrity": "sha512-rH1U28wi+mHQlJFr4mvMW3D0oILnjV/BY9TslzWc6zM5zwv48I8LQk4sVzggN2KTDIGAdlDsmdReAd+7fhMYmQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-js": "^3.6.5",
|
||||
"quill": "^1.3.7"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vuedraggable": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-4.1.0.tgz",
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@
|
|||
"prismjs": "^1.29.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"query-builder-vue-3": "^1.0.1",
|
||||
"quill": "^2.0.2",
|
||||
"tailwind-merge": "^2.5.4",
|
||||
"tinymotion": "^0.2.0",
|
||||
"v-calendar": "^3.1.2",
|
||||
|
|
@ -70,8 +71,7 @@
|
|||
"vue-json-pretty": "^2.4.0",
|
||||
"vue-notion": "^3.0.0",
|
||||
"vue-signature-pad": "^3.0.2",
|
||||
"vue3-editor": "^0.1.1",
|
||||
"vuedraggable": "^4.1.0",
|
||||
"vuedraggable": "next",
|
||||
"webcam-easy": "^1.1.1"
|
||||
},
|
||||
"eslintIgnore": [
|
||||
|
|
|
|||
Loading…
Reference in New Issue