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:
Chirag Chhatrala
2024-10-22 14:04:29 +05:30
committed by GitHub
parent 2fdf2a439b
commit dad5c825b1
50 changed files with 1903 additions and 874 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
<modal
:show="show"
compact-header
backdrop-blur="sm"
;backdrop-blur="true"
@close="$emit('close')"
>
<template #title>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
}
}
})
},

View File

@@ -4,6 +4,7 @@
compact-header
max-width="screen-lg"
backdrop-blur
class="z-50"
@close="subscriptionModalStore.closeModal()"
>
<div class="overflow-hidden">

View File

@@ -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])
})
}

39
client/composables/useParseMention.js vendored Normal file
View File

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

View File

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

View File

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

View File

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

2
client/lib/utils.js vendored
View File

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

View File

@@ -80,9 +80,6 @@ export default defineNuxtConfig({
fallback: 'light',
classPrefix: '',
},
ui: {
icons: ['heroicons', 'material-symbols']
},
sitemap,
runtimeConfig,
gtm

113
client/package-lock.json generated
View File

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

View File

@@ -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": [