commit
fb64a948a3
|
|
@ -34,18 +34,7 @@ AWS_SECRET_ACCESS_KEY=
|
|||
AWS_DEFAULT_REGION=us-east-1
|
||||
AWS_BUCKET=
|
||||
|
||||
PUSHER_APP_ID=
|
||||
PUSHER_APP_KEY=
|
||||
PUSHER_APP_SECRET=
|
||||
PUSHER_APP_CLUSTER=mt1
|
||||
|
||||
MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
|
||||
MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
|
||||
|
||||
JWT_TTL=1440
|
||||
JWT_SECRET=
|
||||
|
||||
MUX_WORKSPACE_ID=
|
||||
MUX_API_TOKEN=
|
||||
|
||||
OPEN_AI_API_KEY=
|
||||
|
|
@ -76,9 +76,6 @@ H_CAPTCHA_SECRET_KEY=
|
|||
RE_CAPTCHA_SITE_KEY=
|
||||
RE_CAPTCHA_SECRET_KEY=
|
||||
|
||||
MUX_WORKSPACE_ID=
|
||||
MUX_API_TOKEN=
|
||||
|
||||
ADMIN_EMAILS=
|
||||
TEMPLATE_EDITOR_EMAILS=
|
||||
|
||||
|
|
|
|||
|
|
@ -223,7 +223,7 @@ class Form extends Model implements CachableAttributes
|
|||
|
||||
public function getIsClosedAttribute()
|
||||
{
|
||||
return $this->closes_at && now()->gt($this->closes_at);
|
||||
return $this->visibility === 'closed' || ($this->closes_at && now()->gt($this->closes_at));
|
||||
}
|
||||
|
||||
public function getFormPendingSubmissionKeyAttribute()
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ namespace App\Open;
|
|||
|
||||
use DOMDocument;
|
||||
use DOMXPath;
|
||||
use DOMElement;
|
||||
|
||||
class MentionParser
|
||||
{
|
||||
|
|
@ -39,21 +40,24 @@ class MentionParser
|
|||
libxml_use_internal_errors($internalErrors);
|
||||
|
||||
$xpath = new DOMXPath($doc);
|
||||
$mentionElements = $xpath->query("//span[@mention]");
|
||||
|
||||
$mentionElements = $xpath->query("//span[@mention or @mention='true']");
|
||||
|
||||
foreach ($mentionElements as $element) {
|
||||
$fieldId = $element->getAttribute('mention-field-id');
|
||||
$fallback = $element->getAttribute('mention-fallback');
|
||||
$value = $this->getData($fieldId);
|
||||
if ($element instanceof DOMElement) {
|
||||
$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($this->urlFriendly ? ',+' : ', ', $value) : $value);
|
||||
$element->parentNode->replaceChild($textNode, $element);
|
||||
} elseif ($fallback) {
|
||||
$textNode = $doc->createTextNode($fallback);
|
||||
$element->parentNode->replaceChild($textNode, $element);
|
||||
} else {
|
||||
$element->parentNode->removeChild($element);
|
||||
if ($value !== null) {
|
||||
$textNode = $doc->createTextNode(is_array($value) ? implode($this->urlFriendly ? ',+' : ', ', $value) : $value);
|
||||
$element->parentNode->replaceChild($textNode, $element);
|
||||
} elseif ($fallback) {
|
||||
$textNode = $doc->createTextNode($fallback);
|
||||
$element->parentNode->replaceChild($textNode, $element);
|
||||
} else {
|
||||
$element->parentNode->removeChild($element);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,18 @@ describe('MentionParser', function () {
|
|||
expect($result)->toBe('<div>Hello !</div>');
|
||||
});
|
||||
|
||||
it('supports mention="true" syntax', function () {
|
||||
$content = '<p>Hello <span mention="true" mention-field-id="123" mention-fallback="">Full Name</span></p>';
|
||||
$data = [
|
||||
['id' => '123', 'value' => 'John Doe']
|
||||
];
|
||||
|
||||
$parser = new MentionParser($content, $data);
|
||||
$result = $parser->parse();
|
||||
|
||||
expect($result)->toBe('<p>Hello John Doe</p>');
|
||||
});
|
||||
|
||||
describe('parseAsText', function () {
|
||||
it('converts HTML to plain text with proper line breaks', function () {
|
||||
$content = '<div>First line</div><div>Second line</div>';
|
||||
|
|
@ -144,6 +156,29 @@ describe('MentionParser', function () {
|
|||
expect($result)->toBe('<p>Tags: PHP, Laravel, Testing</p>');
|
||||
});
|
||||
|
||||
test('it supports mention="true" attributes', function () {
|
||||
$content = '<p>Hello <span contenteditable="false" mention="true" mention-field-id="123" mention-field-name="Full Name" mention-fallback="">Full Name</span></p>';
|
||||
$data = [['id' => '123', 'value' => 'John Doe']];
|
||||
|
||||
$parser = new MentionParser($content, $data);
|
||||
$result = $parser->parse();
|
||||
|
||||
expect($result)->toBe('<p>Hello John Doe</p>');
|
||||
});
|
||||
|
||||
test('it handles multiple mentions with mention="true" syntax', function () {
|
||||
$content = '<p><span contenteditable="false" mention="true" mention-field-id="123" mention-field-name="Name" mention-fallback="">Name</span> and <span contenteditable="false" mention="true" mention-field-id="456" mention-field-name="Title" mention-fallback="">Title</span></p>';
|
||||
$data = [
|
||||
['id' => '123', 'value' => 'John Doe'],
|
||||
['id' => '456', 'value' => 'Developer'],
|
||||
];
|
||||
|
||||
$parser = new MentionParser($content, $data);
|
||||
$result = $parser->parse();
|
||||
|
||||
expect($result)->toBe('<p>John Doe and Developer</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']];
|
||||
|
|
@ -174,6 +209,53 @@ describe('MentionParser', function () {
|
|||
expect($result)->toBe('some text replaced text dewde');
|
||||
});
|
||||
|
||||
test('it handles URL-encoded field IDs', function () {
|
||||
$content = '<p>Hello <span mention mention-field-id="%3ARGE" mention-fallback="">Full Name</span></p>';
|
||||
$data = [['id' => '%3ARGE', 'value' => 'John Doe']];
|
||||
|
||||
$parser = new MentionParser($content, $data);
|
||||
$result = $parser->parse();
|
||||
|
||||
expect($result)->toBe('<p>Hello John Doe</p>');
|
||||
});
|
||||
|
||||
test('it handles URL-encoded field IDs with mention="true" syntax', function () {
|
||||
$content = '<p>Hello <span contenteditable="false" mention="true" mention-field-id="%3ARGE" mention-field-name="Full Name" mention-fallback="">Full Name</span></p>';
|
||||
$data = [['id' => '%3ARGE', 'value' => 'John Doe']];
|
||||
|
||||
$parser = new MentionParser($content, $data);
|
||||
$result = $parser->parse();
|
||||
|
||||
expect($result)->toBe('<p>Hello John Doe</p>');
|
||||
});
|
||||
|
||||
test('it handles multiple mentions with URL-encoded IDs and mention="true" syntax', function () {
|
||||
$content = '<p><span contenteditable="false" mention="true" mention-field-id="%3ARGE" mention-field-name="Full Name" mention-fallback="">Full Name</span> and <span contenteditable="false" mention="true" mention-field-id="V%7D%40S" mention-field-name="Phone Number" mention-fallback="">Phone</span></p>';
|
||||
$data = [
|
||||
['id' => '%3ARGE', 'value' => 'John Doe'],
|
||||
['id' => 'V%7D%40S', 'value' => '123-456-7890'],
|
||||
];
|
||||
|
||||
$parser = new MentionParser($content, $data);
|
||||
$result = $parser->parse();
|
||||
|
||||
expect($result)->toBe('<p>John Doe and 123-456-7890</p>');
|
||||
});
|
||||
|
||||
test('it recreates real-world example with URL-encoded IDs', function () {
|
||||
$content = '<p>Hello there 👋 </p><p>This is a confirmation that your submission was successfully saved.</p><p><span contenteditable="false" mention="true" mention-field-id="%3ARGE" mention-field-name="Full Name" mention-fallback="">Full Name</span><span contenteditable="false" mention="true" mention-field-id="title" mention-field-name="Contact Form" mention-fallback="">Contact Form</span><span contenteditable="false" mention="true" mention-field-id="V%7D%40S" mention-field-name="Phone Number" mention-fallback="">Phone Number</span></p>';
|
||||
$data = [
|
||||
['id' => '%3ARGE', 'value' => 'jujujujuju'],
|
||||
['id' => 'title', 'value' => 'jujuuj'],
|
||||
['id' => 'V%7D%40S', 'value' => '555-1234'],
|
||||
];
|
||||
|
||||
$parser = new MentionParser($content, $data);
|
||||
$result = $parser->parse();
|
||||
|
||||
expect($result)->toBe('<p>Hello there 👋 </p><p>This is a confirmation that your submission was successfully saved.</p><p>jujujujujujujuuj555-1234</p>');
|
||||
});
|
||||
|
||||
describe('urlFriendlyOutput', function () {
|
||||
test('it encodes special characters in values', function () {
|
||||
$content = '<p>Test: <span mention mention-field-id="123">Placeholder</span></p>';
|
||||
|
|
|
|||
|
|
@ -3,12 +3,12 @@
|
|||
<template #label>
|
||||
<slot name="label" />
|
||||
</template>
|
||||
|
||||
|
||||
<MentionDropdown
|
||||
:state="mentionState"
|
||||
:mention-state="mentionState"
|
||||
:mentions="mentions"
|
||||
/>
|
||||
|
||||
|
||||
<div class="relative">
|
||||
<div
|
||||
ref="editableDiv"
|
||||
|
|
@ -38,14 +38,14 @@
|
|||
@click="openMentionDropdown"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<template
|
||||
v-if="$slots.help"
|
||||
#help
|
||||
>
|
||||
<slot name="help" />
|
||||
</template>
|
||||
|
||||
|
||||
<template
|
||||
v-if="$slots.error"
|
||||
#error
|
||||
|
|
@ -54,146 +54,169 @@
|
|||
</template>
|
||||
</InputWrapper>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } 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, 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)
|
||||
<script setup>
|
||||
import { ref, onMounted, watch, reactive } 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, hasError, inputWrapperProps } = useFormInput(props, { emit })
|
||||
const editableDiv = ref(null)
|
||||
const savedRange = ref(null)
|
||||
const subscriptionModalStore = useSubscriptionModalStore()
|
||||
|
||||
// Create a reactive state object for the mention dropdown
|
||||
const mentionState = reactive({
|
||||
open: false,
|
||||
onInsert: (mention) => {
|
||||
insertMention(mention)
|
||||
mentionState.open = false
|
||||
},
|
||||
onCancel: () => {
|
||||
mentionState.open = false
|
||||
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 createMentionSpan = (mention) => {
|
||||
const mentionSpan = document.createElement('span')
|
||||
mentionSpan.setAttribute('mention', 'true')
|
||||
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('class', 'mention-item')
|
||||
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)
|
||||
// Ensure the editableDiv is focused
|
||||
editableDiv.value.focus()
|
||||
updateCompVal()
|
||||
savedRange.value = range
|
||||
}
|
||||
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
|
||||
mentionState.open = true
|
||||
}
|
||||
|
||||
const saveSelection = () => {
|
||||
const selection = window.getSelection()
|
||||
if (selection.rangeCount > 0) {
|
||||
savedRange.value = selection.getRangeAt(0)
|
||||
}
|
||||
const saveSelection = () => {
|
||||
}
|
||||
|
||||
const restoreSelection = () => {
|
||||
if (savedRange.value) {
|
||||
const selection = window.getSelection()
|
||||
if (selection.rangeCount > 0) {
|
||||
savedRange.value = selection.getRangeAt(0)
|
||||
}
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(savedRange.value)
|
||||
editableDiv.value.focus()
|
||||
}
|
||||
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
|
||||
}
|
||||
const updateCompVal = () => {
|
||||
compVal.value = editableDiv.value.innerHTML
|
||||
})
|
||||
|
||||
watch(compVal, (newVal) => {
|
||||
if (editableDiv.value && editableDiv.value.innerHTML !== newVal) {
|
||||
editableDiv.value.innerHTML = newVal
|
||||
}
|
||||
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>
|
||||
})
|
||||
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@
|
|||
:options="quillOptions"
|
||||
:disabled="disabled"
|
||||
:style="inputStyle"
|
||||
@ready="onEditorReady"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -59,7 +60,7 @@
|
|||
|
||||
<MentionDropdown
|
||||
v-if="enableMentions && mentionState"
|
||||
:state="mentionState"
|
||||
:mention-state="mentionState"
|
||||
:mentions="mentions"
|
||||
/>
|
||||
</InputWrapper>
|
||||
|
|
@ -103,12 +104,23 @@ watch(compVal, (val) => {
|
|||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Move the mention extension registration to onMounted
|
||||
|
||||
if (props.enableMentions && !Quill.imports['blots/mention']) {
|
||||
// Initialize mention extension
|
||||
if (props.enableMentions) {
|
||||
// Register the mention extension with Quill
|
||||
mentionState.value = registerMentionExtension(Quill)
|
||||
}
|
||||
|
||||
// Handle editor ready event
|
||||
const onEditorReady = (quillInstance) => {
|
||||
// If we have a mention module, get its state
|
||||
if (props.enableMentions && quillInstance) {
|
||||
const mentionModule = quillInstance.getModule('mention')
|
||||
if (mentionModule && mentionModule.state) {
|
||||
mentionState.value = mentionModule.state
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const quillOptions = computed(() => {
|
||||
const defaultOptions = {
|
||||
placeholder: props.placeholder || '',
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<UPopover
|
||||
ref="popover"
|
||||
v-model:open="open"
|
||||
v-model:open="mentionState.open"
|
||||
class="h-0"
|
||||
@close="cancel"
|
||||
>
|
||||
|
|
@ -43,7 +43,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flex border-t pt-2 -mx-2 px-2 justify-end space-x-2">
|
||||
<UButton
|
||||
size="sm"
|
||||
|
|
@ -66,51 +66,62 @@
|
|||
</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()
|
||||
}
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import BlockTypeIcon from '~/components/open/forms/components/BlockTypeIcon.vue'
|
||||
import blocksTypes from '~/data/blocks_types.json'
|
||||
|
||||
const props = defineProps({
|
||||
mentionState: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
mentions: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
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
|
||||
}
|
||||
})
|
||||
|
||||
defineShortcuts({
|
||||
escape: () => {
|
||||
props.mentionState.open = false
|
||||
}
|
||||
const cancel = () => {
|
||||
if (onCancel.value) {
|
||||
onCancel.value()
|
||||
}
|
||||
open.value = false
|
||||
})
|
||||
|
||||
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()
|
||||
}
|
||||
</script>
|
||||
}
|
||||
|
||||
watch(() => props.mentionState.open, (newValue) => {
|
||||
if (newValue) {
|
||||
selectedField.value = null
|
||||
fallbackValue.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
const insertMention = () => {
|
||||
if (selectedField.value && props.mentionState.onInsert) {
|
||||
props.mentionState.onInsert({
|
||||
field: selectedField.value,
|
||||
fallback: fallbackValue.value
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const cancel = () => {
|
||||
if (props.mentionState.onCancel) {
|
||||
props.mentionState.onCancel()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -8,6 +8,7 @@
|
|||
<script setup>
|
||||
import Quill from 'quill'
|
||||
import 'quill/dist/quill.snow.css'
|
||||
import '../../../lib/quill/quillPatches'
|
||||
import { onMounted, onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
|
|
@ -35,13 +36,40 @@ let quillInstance = null
|
|||
const container = ref(null)
|
||||
const model = ref(props.modelValue)
|
||||
|
||||
// Safely paste HTML content with handling for empty content
|
||||
const pasteHTML = (instance) => {
|
||||
instance.clipboard.dangerouslyPasteHTML(props.modelValue || '', 'silent')
|
||||
if (!props.modelValue) {
|
||||
instance.setContents([])
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
instance.clipboard.dangerouslyPasteHTML(props.modelValue, 'silent')
|
||||
} catch (error) {
|
||||
console.error('Error pasting HTML:', error)
|
||||
// Fallback to setting empty content
|
||||
instance.setContents([])
|
||||
}
|
||||
}
|
||||
|
||||
const initializeQuill = () => {
|
||||
if (container.value) {
|
||||
quillInstance = new Quill(container.value, props.options)
|
||||
// Merge default options with user options
|
||||
const defaultOptions = {
|
||||
formats: ['bold', 'color', 'font', 'code', 'italic', 'link', 'size', 'strike', 'script', 'underline', 'header', 'list', 'mention']
|
||||
}
|
||||
|
||||
const mergedOptions = {
|
||||
...defaultOptions,
|
||||
...props.options,
|
||||
modules: {
|
||||
...defaultOptions.modules,
|
||||
...(props.options.modules || {})
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize Quill with merged options
|
||||
quillInstance = new Quill(container.value, mergedOptions)
|
||||
|
||||
quillInstance.on('selection-change', (range, oldRange, source) => {
|
||||
if (!range) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,18 @@
|
|||
<template>
|
||||
<notion-renderer
|
||||
<NotionRenderer
|
||||
v-if="!loading"
|
||||
:block-map="blockMap"
|
||||
:block-map="blocks"
|
||||
:block-overrides="blockOverrides"
|
||||
:content-id="contentId"
|
||||
:full-page="fullPage"
|
||||
:hide-list="hideList"
|
||||
:level="level"
|
||||
:map-image-url="mapImageUrl"
|
||||
:map-page-url="mapPageUrl"
|
||||
:page-link-options="pageLinkOptions"
|
||||
:image-options="imageOptions"
|
||||
:prism="prism"
|
||||
:todo="todo"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
|
|
@ -12,27 +23,69 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import { NotionRenderer } from "vue-notion"
|
||||
import { NotionRenderer, defaultMapPageUrl, defaultMapImageUrl } from 'vue-notion'
|
||||
|
||||
export default {
|
||||
name: "NotionPage",
|
||||
name: 'NotionPage',
|
||||
components: { NotionRenderer },
|
||||
props: {
|
||||
blockMap: {
|
||||
type: Object
|
||||
},
|
||||
blockOverrides: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
type: Boolean
|
||||
},
|
||||
contentId: String,
|
||||
contentIndex: { type: Number, default: 0 },
|
||||
fullPage: { type: Boolean, default: false },
|
||||
hideList: { type: Array, default: () => [] },
|
||||
level: { type: Number, default: 0 },
|
||||
mapImageUrl: { type: Function, default: defaultMapImageUrl },
|
||||
mapPageUrl: { type: Function, default: defaultMapPageUrl },
|
||||
pageLinkOptions: {
|
||||
type: Object, default: () => {
|
||||
const NuxtLink = resolveComponent('NuxtLink')
|
||||
return { component: NuxtLink, href: 'to' }
|
||||
}
|
||||
},
|
||||
imageOptions: Object,
|
||||
prism: { type: Boolean, default: false },
|
||||
todo: { type: Boolean, default: false }
|
||||
},
|
||||
computed: {
|
||||
blocks () {
|
||||
if (this.blockMap && this.blockMap.data) {
|
||||
return this.blockMap.data
|
||||
}
|
||||
return this.blockMap
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
<style lang='scss'>
|
||||
@import "vue-notion/src/styles.css";
|
||||
|
||||
.notion-blue {
|
||||
@apply text-nt-blue;
|
||||
}
|
||||
|
||||
.notion-spacer {
|
||||
width: 24px !important;
|
||||
}
|
||||
|
||||
.notion-link {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.notion {
|
||||
img, iframe {
|
||||
@apply rounded-md;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<div
|
||||
v-show="isActive"
|
||||
v-if="isActive"
|
||||
class="settings-section"
|
||||
>
|
||||
<h3 class="text-xl font-medium mb-1">
|
||||
|
|
|
|||
|
|
@ -5,19 +5,16 @@
|
|||
@close="emit('close')"
|
||||
>
|
||||
<open-form
|
||||
v-if="form"
|
||||
:form-manager="formManager"
|
||||
:theme="theme"
|
||||
:loading="false"
|
||||
:form="form"
|
||||
:fields="form.properties"
|
||||
:default-data-form="submission"
|
||||
:mode="FormMode.EDIT"
|
||||
@submit="updateForm"
|
||||
>
|
||||
<template #submit-btn="{ submitForm }">
|
||||
<template #submit-btn="{ loading }">
|
||||
<v-button
|
||||
:loading="loading"
|
||||
class="mt-2 px-8 mx-1"
|
||||
@click.prevent="submitForm"
|
||||
@click.prevent="updateForm"
|
||||
>
|
||||
Update Submission
|
||||
</v-button>
|
||||
|
|
@ -25,11 +22,13 @@
|
|||
</open-form>
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, defineProps, defineEmits } from "vue"
|
||||
import OpenForm from "../forms/OpenForm.vue"
|
||||
import CachedDefaultTheme from "~/lib/forms/themes/CachedDefaultTheme.js"
|
||||
import { FormMode } from "~/lib/forms/FormModeStrategy.js"
|
||||
import { useFormManager } from '~/lib/forms/composables/useFormManager'
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, required: true },
|
||||
|
|
@ -46,13 +45,37 @@ const props = defineProps({
|
|||
submission: { type: Object },
|
||||
})
|
||||
|
||||
// Set up form manager with proper mode
|
||||
let formManager = null
|
||||
const setupFormManager = () => {
|
||||
if (!props.form) return null
|
||||
|
||||
formManager = useFormManager(props.form, FormMode.EDIT)
|
||||
|
||||
return formManager
|
||||
}
|
||||
|
||||
// Initialize form manager
|
||||
formManager = setupFormManager()
|
||||
|
||||
watch(() => props.show, (newShow) => {
|
||||
if (newShow) {
|
||||
nextTick(() => {
|
||||
formManager.initialize({
|
||||
skipPendingSubmission: true,
|
||||
skipUrlParams: true,
|
||||
defaultData: props.submission
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
const emit = defineEmits(["close", "updated"])
|
||||
const updateForm = (form, onFailure) => {
|
||||
const updateForm = () => {
|
||||
loading.value = true
|
||||
form
|
||||
.put("/open/forms/" + props.form.id + "/submissions/" + props.submission.id)
|
||||
formManager.form.put("/open/forms/" + props.form.id + "/submissions/" + props.submission.id)
|
||||
.then((res) => {
|
||||
useAlert().success(res.message)
|
||||
loading.value = false
|
||||
|
|
@ -65,7 +88,6 @@ const updateForm = (form, onFailure) => {
|
|||
useAlert().formValidationError(error.data)
|
||||
}
|
||||
loading.value = false
|
||||
onFailure()
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
orientation="horizontal"
|
||||
>
|
||||
<UButton
|
||||
v-track.delete_record_click
|
||||
v-track.edit_record_click
|
||||
size="sm"
|
||||
color="white"
|
||||
icon="heroicons:pencil-square"
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@
|
|||
class="m-2 my-4">
|
||||
<UAlert
|
||||
:close-button="{ icon: 'i-heroicons-x-mark-20-solid', color: 'gray', variant: 'link', padded: false }"
|
||||
color="yellow"
|
||||
color="amber"
|
||||
variant="subtle"
|
||||
icon="i-material-symbols-info-outline"
|
||||
@close="hidePasswordDisabledMsg = true"
|
||||
|
|
@ -64,29 +64,35 @@
|
|||
</div>
|
||||
|
||||
|
||||
<div
|
||||
<UAlert
|
||||
v-if="isPublicFormPage && (form.is_closed || form.visibility=='closed')"
|
||||
class="border shadow-sm p-2 my-4 flex items-center rounded-md bg-yellow-100 dark:bg-yellow-600/20 border-yellow-500 dark:border-yellow-500/20"
|
||||
icon="i-heroicons-lock-closed-20-solid"
|
||||
color="amber"
|
||||
variant="subtle"
|
||||
class="m-2 my-4"
|
||||
>
|
||||
<div class="flex-grow">
|
||||
<template #description>
|
||||
<div
|
||||
class="mb-0 py-2 px-4 text-yellow-600"
|
||||
class="py-2"
|
||||
v-html="form.closed_text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UAlert>
|
||||
|
||||
<div
|
||||
<UAlert
|
||||
v-else-if="isPublicFormPage && form.max_number_of_submissions_reached"
|
||||
class="border shadow-sm p-2 my-4 flex items-center rounded-md bg-yellow-100 dark:bg-yellow-600/20 border-yellow-500 dark:border-yellow-500/20"
|
||||
icon="i-heroicons-lock-closed-20-solid"
|
||||
color="amber"
|
||||
variant="subtle"
|
||||
class="m-2 my-4"
|
||||
>
|
||||
<div class="flex-grow">
|
||||
<template #description>
|
||||
<div
|
||||
class="mb-0 py-2 px-4 text-yellow-600 dark:text-yellow-600"
|
||||
class="py-2"
|
||||
v-html="form.max_submissions_reached_text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UAlert>
|
||||
|
||||
<form-cleanings
|
||||
v-if="showFormCleanings"
|
||||
|
|
@ -343,7 +349,7 @@ const triggerSubmit = async () => {
|
|||
submissionId: submissionId.value
|
||||
}).then(result => {
|
||||
if (result) {
|
||||
submittedData.value = result || {}
|
||||
submittedData.value = formManager.form.data()
|
||||
|
||||
if (result?.submission_id) {
|
||||
submissionId.value = result.submission_id
|
||||
|
|
@ -392,7 +398,9 @@ const addPasswordError = (msg) => {
|
|||
}
|
||||
|
||||
defineExpose({
|
||||
addPasswordError
|
||||
addPasswordError,
|
||||
restart,
|
||||
formManager
|
||||
})
|
||||
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@
|
|||
<slot
|
||||
v-if="isLastPage"
|
||||
name="submit-btn"
|
||||
:loading="form.busy"
|
||||
:loading="isProcessing"
|
||||
/>
|
||||
<open-form-button
|
||||
v-else-if="currentFieldsPageBreak"
|
||||
|
|
@ -73,7 +73,7 @@
|
|||
:color="form.color"
|
||||
:theme="theme"
|
||||
class="mt-2 px-8 mx-1"
|
||||
:loading="form.busy"
|
||||
:loading="isProcessing"
|
||||
@click.stop="handleNextClick"
|
||||
>
|
||||
{{ currentFieldsPageBreak.next_btn_text }}
|
||||
|
|
@ -165,6 +165,8 @@ const handleDragDropped = (data) => {
|
|||
workingFormStore.moveField(oldTargetIndex, newTargetIndex)
|
||||
}
|
||||
}
|
||||
|
||||
const isProcessing = computed(() => props.formManager.state.isProcessing)
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@
|
|||
]"
|
||||
/>
|
||||
|
||||
<div class="flex-grow flex justify-center">
|
||||
<div class="flex-grow flex justify-center gap-2">
|
||||
<EditableTag
|
||||
id="form-editor-title"
|
||||
v-model="form.title"
|
||||
|
|
@ -34,12 +34,14 @@
|
|||
v-if="form.visibility == 'draft'"
|
||||
color="yellow"
|
||||
variant="soft"
|
||||
icon="i-heroicons-pencil-square"
|
||||
label="Draft"
|
||||
/>
|
||||
<UBadge
|
||||
v-else-if="form.visibility == 'closed'"
|
||||
color="gray"
|
||||
variant="soft"
|
||||
icon="i-heroicons-lock-closed-20-solid"
|
||||
label="Closed"
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,110 @@
|
|||
<template>
|
||||
<div
|
||||
v-if="shouldDisplayBadges"
|
||||
class="flex items-center flex-wrap gap-1"
|
||||
>
|
||||
<!-- Draft Badge -->
|
||||
<UTooltip v-if="form.visibility === 'draft'" text="Not publicly accessible">
|
||||
<UBadge
|
||||
color="amber"
|
||||
variant="subtle"
|
||||
icon="i-heroicons-exclamation-triangle"
|
||||
:size="size"
|
||||
>
|
||||
Draft
|
||||
</UBadge>
|
||||
</UTooltip>
|
||||
|
||||
<!-- Closed Badge -->
|
||||
<UTooltip v-else-if="form.visibility === 'closed'" text="Won't accept new submissions">
|
||||
<UBadge
|
||||
color="gray"
|
||||
variant="subtle"
|
||||
icon="i-heroicons-lock-closed"
|
||||
:size="size"
|
||||
>
|
||||
Closed
|
||||
</UBadge>
|
||||
</UTooltip>
|
||||
|
||||
<!-- Time Limited Badge -->
|
||||
<UTooltip v-if="form.closes_at && !form.is_closed" :text="`Will close on ${closesDate}`">
|
||||
<UBadge
|
||||
color="amber"
|
||||
variant="subtle"
|
||||
icon="i-heroicons-clock"
|
||||
:size="size"
|
||||
>
|
||||
Time limited
|
||||
</UBadge>
|
||||
</UTooltip>
|
||||
|
||||
<!-- Submission Limited Badge -->
|
||||
<UTooltip
|
||||
v-if="form.max_submissions_count > 0 && !form.max_number_of_submissions_reached"
|
||||
:text="`Limited to ${form.max_submissions_count} submissions`"
|
||||
>
|
||||
<UBadge
|
||||
color="amber"
|
||||
variant="subtle"
|
||||
icon="i-heroicons-chart-bar"
|
||||
:size="size"
|
||||
>
|
||||
Submission limited
|
||||
</UBadge>
|
||||
</UTooltip>
|
||||
|
||||
<!-- Tags Badges -->
|
||||
<UBadge
|
||||
v-for="tag in form.tags"
|
||||
:key="tag"
|
||||
color="white"
|
||||
variant="solid"
|
||||
class="capitalize"
|
||||
:size="size"
|
||||
>
|
||||
{{ tag }}
|
||||
</UBadge>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
form: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'sm',
|
||||
validator: (value) => ['xs', 'sm', 'md', 'lg'].includes(value)
|
||||
}
|
||||
})
|
||||
|
||||
const closesDate = computed(() => {
|
||||
if (props.form && props.form.closes_at) {
|
||||
try {
|
||||
const dateObj = new Date(props.form.closes_at)
|
||||
return dateObj.getFullYear() + '-' +
|
||||
String(dateObj.getMonth() + 1).padStart(2, '0') + '-' +
|
||||
String(dateObj.getDate()).padStart(2, '0') + ' ' +
|
||||
String(dateObj.getHours()).padStart(2, '0') + ':' +
|
||||
String(dateObj.getMinutes()).padStart(2, '0')
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
// Conditional to determine if badges should be displayed
|
||||
const shouldDisplayBadges = computed(() => {
|
||||
return ['draft', 'closed'].includes(props.form.visibility) ||
|
||||
(props.form.tags && props.form.tags.length > 0) ||
|
||||
props.form.closes_at ||
|
||||
(props.form.max_submissions_count > 0)
|
||||
})
|
||||
</script>
|
||||
|
|
@ -142,6 +142,13 @@ watch(() => form.value.dark_mode, () => {
|
|||
handleDarkModeChange()
|
||||
})
|
||||
|
||||
// Watch for form mode changes to reset the form when switching modes
|
||||
watch(formMode, () => {
|
||||
if (previewFormSubmitted.value) {
|
||||
restartForm()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
handleDarkModeChange()
|
||||
})
|
||||
|
|
@ -163,7 +170,16 @@ function handleDarkModeChange() {
|
|||
|
||||
function restartForm() {
|
||||
previewFormSubmitted.value = false
|
||||
formPreview.value.restart()
|
||||
|
||||
try {
|
||||
// Try using the component reference first
|
||||
if (formPreview.value && typeof formPreview.value.restart === 'function') {
|
||||
formPreview.value.restart()
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error restarting form:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleExpand() {
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@
|
|||
:options="visibilityOptions"
|
||||
/>
|
||||
<div
|
||||
v-if="form.closes_at || form.visibility == 'closed'"
|
||||
v-if="isFormClosingOrClosed"
|
||||
class="bg-gray-50 border rounded-lg px-4 py-2"
|
||||
>
|
||||
<rich-text-area-input
|
||||
|
|
@ -112,119 +112,103 @@
|
|||
</modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import clonedeep from "clone-deep"
|
||||
import { default as _has } from "lodash/has"
|
||||
<script setup>
|
||||
import clonedeep from 'clone-deep'
|
||||
import { default as _has } from 'lodash/has'
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
const formsStore = useFormsStore()
|
||||
const workingFormStore = useWorkingFormStore()
|
||||
const { getAll: forms } = storeToRefs(formsStore)
|
||||
// Store setup
|
||||
const formsStore = useFormsStore()
|
||||
const workingFormStore = useWorkingFormStore()
|
||||
const { content: form } = storeToRefs(workingFormStore)
|
||||
const forms = computed(() => formsStore.getAll)
|
||||
|
||||
// Reactive state
|
||||
const showCopyFormSettingsModal = ref(false)
|
||||
const copyFormId = ref(null)
|
||||
|
||||
// Computed properties
|
||||
const visibilityOptions = [
|
||||
{
|
||||
name: 'Published',
|
||||
value: 'public',
|
||||
},
|
||||
{
|
||||
name: 'Draft - not publicly accessible',
|
||||
value: 'draft',
|
||||
},
|
||||
{
|
||||
name: 'Closed - won\'t accept new submissions',
|
||||
value: 'closed',
|
||||
},
|
||||
]
|
||||
|
||||
const copyFormOptions = computed(() => {
|
||||
return forms.value
|
||||
.filter((formItem) => {
|
||||
return form.value.id !== formItem.id
|
||||
})
|
||||
.map((formItem) => {
|
||||
return {
|
||||
name: formItem.title,
|
||||
value: formItem.id,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const allTagsOptions = computed(() => {
|
||||
return formsStore.allTags.map((tagname) => {
|
||||
return {
|
||||
forms,
|
||||
formsStore,
|
||||
workingFormStore,
|
||||
name: tagname,
|
||||
value: tagname,
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
data() {
|
||||
return {
|
||||
showCopyFormSettingsModal: false,
|
||||
copyFormId: null,
|
||||
visibilityOptions: [
|
||||
{
|
||||
name: "Published",
|
||||
value: "public",
|
||||
},
|
||||
{
|
||||
name: "Draft - not publicly accessible",
|
||||
value: "draft",
|
||||
},
|
||||
{
|
||||
name: "Closed - won't accept new submissions",
|
||||
value: "closed",
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
// New computed property for v-if condition
|
||||
const isFormClosingOrClosed = computed(() => {
|
||||
return form.value.closes_at || form.value.visibility === 'closed'
|
||||
})
|
||||
|
||||
computed: {
|
||||
copyFormOptions() {
|
||||
return this.forms
|
||||
.filter((form) => {
|
||||
return this.form.id !== form.id
|
||||
})
|
||||
.map((form) => {
|
||||
return {
|
||||
name: form.title,
|
||||
value: form.id,
|
||||
}
|
||||
})
|
||||
},
|
||||
form: {
|
||||
get() {
|
||||
return this.workingFormStore.content
|
||||
},
|
||||
/* We add a setter */
|
||||
set(value) {
|
||||
this.workingFormStore.set(value)
|
||||
},
|
||||
},
|
||||
allTagsOptions() {
|
||||
return this.formsStore.allTags.map((tagname) => {
|
||||
return {
|
||||
name: tagname,
|
||||
value: tagname,
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
// Methods
|
||||
const copySettings = () => {
|
||||
if (copyFormId.value == null)
|
||||
return
|
||||
const copyForm = clonedeep(
|
||||
forms.value.find(form => form.id === copyFormId.value),
|
||||
)
|
||||
if (!copyForm)
|
||||
return;
|
||||
|
||||
watch: {},
|
||||
// Clean copy from form
|
||||
[
|
||||
"title",
|
||||
"properties",
|
||||
"cleanings",
|
||||
"views_count",
|
||||
"submissions_count",
|
||||
"workspace",
|
||||
"workspace_id",
|
||||
"updated_at",
|
||||
"share_url",
|
||||
"slug",
|
||||
"notion_database_url",
|
||||
"id",
|
||||
"database_id",
|
||||
"database_fields_update",
|
||||
"creator",
|
||||
"created_at",
|
||||
"deleted_at",
|
||||
"last_edited_human",
|
||||
].forEach((property) => {
|
||||
if (_has(copyForm, property))
|
||||
delete copyForm[property]
|
||||
})
|
||||
|
||||
mounted() {},
|
||||
|
||||
methods: {
|
||||
copySettings() {
|
||||
if (this.copyFormId == null) return
|
||||
const copyForm = clonedeep(
|
||||
this.forms.find((form) => form.id === this.copyFormId),
|
||||
)
|
||||
if (!copyForm) return;
|
||||
|
||||
// Clean copy from form
|
||||
[
|
||||
"title",
|
||||
"properties",
|
||||
"cleanings",
|
||||
"views_count",
|
||||
"submissions_count",
|
||||
"workspace",
|
||||
"workspace_id",
|
||||
"updated_at",
|
||||
"share_url",
|
||||
"slug",
|
||||
"notion_database_url",
|
||||
"id",
|
||||
"database_id",
|
||||
"database_fields_update",
|
||||
"creator",
|
||||
"created_at",
|
||||
"deleted_at",
|
||||
"last_edited_human",
|
||||
].forEach((property) => {
|
||||
if (_has(copyForm, property)) {
|
||||
delete copyForm[property]
|
||||
}
|
||||
})
|
||||
|
||||
// Apply changes
|
||||
Object.keys(copyForm).forEach((property) => {
|
||||
this.form[property] = copyForm[property]
|
||||
})
|
||||
this.showCopyFormSettingsModal = false
|
||||
},
|
||||
},
|
||||
// Apply changes
|
||||
Object.keys(copyForm).forEach((property) => {
|
||||
form.value[property] = copyForm[property]
|
||||
})
|
||||
showCopyFormSettingsModal.value = false
|
||||
useAlert().success('Form settings copied.')
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -50,6 +50,18 @@
|
|||
Technical Docs
|
||||
</a>
|
||||
<template v-if="!useFeatureFlag('self_hosted')">
|
||||
<router-link
|
||||
:to="{ name: 'integrations' }"
|
||||
class="text-gray-600 dark:text-gray-400 transition-colors duration-300 hover:text-nt-blue"
|
||||
>
|
||||
Integrations
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="{ name: 'report-abuse' }"
|
||||
class="text-gray-600 dark:text-gray-400 transition-colors duration-300 hover:text-nt-blue"
|
||||
>
|
||||
Report Abuse
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="{ name: 'privacy-policy' }"
|
||||
class="text-gray-600 dark:text-gray-400 transition-colors duration-300 hover:text-nt-blue"
|
||||
|
|
@ -63,13 +75,6 @@
|
|||
>
|
||||
Terms & Conditions
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
:to="{ name: 'report-abuse' }"
|
||||
class="text-gray-600 dark:text-gray-400 transition-colors duration-300 hover:text-nt-blue"
|
||||
>
|
||||
Report Abuse
|
||||
</router-link>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
<template>
|
||||
<div
|
||||
v-if="innerJson"
|
||||
id="custom-block"
|
||||
>
|
||||
<div
|
||||
v-if="innerJson.type=='faq'"
|
||||
class="rounded-lg bg-white z-10 pt-10"
|
||||
>
|
||||
<h2 class="font-medium">
|
||||
Frequently Asked Questions
|
||||
</h2>
|
||||
<dl class="pt-4 space-y-6">
|
||||
<div
|
||||
v-for="question in innerJson.content"
|
||||
:key="question.label"
|
||||
class="space-y-2"
|
||||
>
|
||||
<dt class="font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ question.label }}
|
||||
</dt>
|
||||
<dd
|
||||
class="leading-6 text-gray-600 dark:text-gray-400"
|
||||
v-html="question.content"
|
||||
/>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="innerJson.type=='cta'"
|
||||
class="rounded-lg relative bg-gradient-to-r from-blue-400 to-blue-600 shadow-ld p-8 z-10"
|
||||
>
|
||||
<div class="absolute inset-px rounded-[calc(var(--radius)-1px)]">
|
||||
<div class="flex justify-center w-full h-full">
|
||||
<SpotlightCard
|
||||
class="w-full p-2 rounded-[--radius] [--radius:theme(borderRadius.lg)] opacity-70"
|
||||
from="#60a5fa"
|
||||
:size="200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative z-20 flex flex-col items-center gap-4 pb-1">
|
||||
<h2 class="text-xl md:text-2xl text-center font-medium text-white">
|
||||
{{ innerJson.title ? innerJson.title : 'Ready to upgrade your OpnForm forms?' }}
|
||||
</h2>
|
||||
<UButton
|
||||
to="/register"
|
||||
color="white"
|
||||
class="hover:no-underline"
|
||||
icon="i-heroicons-arrow-right"
|
||||
trailing
|
||||
>
|
||||
Try OpnForm for free
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { blockProps } from 'vue-notion'
|
||||
import useNotionBlock from '~/components/pages/notion/useNotionBlock.js'
|
||||
|
||||
const props = defineProps(blockProps)
|
||||
|
||||
const block = useNotionBlock(props)
|
||||
const innerJson = computed(() => block.innerJson.value)
|
||||
</script>
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
import { computed } from 'vue'
|
||||
|
||||
export default function useNotionBlock (props) {
|
||||
|
||||
const block = computed(() => {
|
||||
const id = props.contentId || Object.keys(props.blockMap)[0]
|
||||
return props.blockMap[id]
|
||||
})
|
||||
|
||||
const value = computed(() => {
|
||||
return block.value?.value
|
||||
})
|
||||
|
||||
const format = computed(() => {
|
||||
return value.value?.format
|
||||
})
|
||||
|
||||
const icon = computed(() => {
|
||||
return format.value?.page_icon || ''
|
||||
})
|
||||
|
||||
const width = computed(() => {
|
||||
return format.value?.block_width
|
||||
})
|
||||
|
||||
const properties = computed(() => {
|
||||
return value.value?.properties
|
||||
})
|
||||
|
||||
const caption = computed(() => {
|
||||
return properties.value?.caption
|
||||
})
|
||||
|
||||
const description = computed(() => {
|
||||
return properties.value?.description
|
||||
})
|
||||
|
||||
const src = computed(() => {
|
||||
return mapImageUrl(properties.value?.source[0][0], block.value)
|
||||
})
|
||||
|
||||
const title = computed(() => {
|
||||
return properties.value?.title
|
||||
})
|
||||
|
||||
const alt = computed(() => {
|
||||
return caption.value?.[0][0]
|
||||
})
|
||||
|
||||
const type = computed(() => {
|
||||
return value.value?.type
|
||||
})
|
||||
|
||||
const visible = computed(() => {
|
||||
return !props.hideList.includes(type.value)
|
||||
})
|
||||
|
||||
const hasPageLinkOptions = computed(() => {
|
||||
return props.pageLinkOptions?.component && props.pageLinkOptions?.href
|
||||
})
|
||||
|
||||
const parent = computed(() => {
|
||||
return props.blockMap[value.value?.parent_id]
|
||||
})
|
||||
|
||||
const innerJson = computed(() => {
|
||||
if (type.value !== 'code') return
|
||||
if (properties.value.language.flat('Infinity').join('') !== 'JSON') {
|
||||
return
|
||||
}
|
||||
try {
|
||||
return JSON.parse(
|
||||
title.value.flat(Infinity).join('').replace(/\n/g, '').replace(/\t/g, '').trim()
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Failed to parse JSON',
|
||||
error,
|
||||
title.value.flat(Infinity).join('').replace(/\n/g, '').replace(/\t/g, '').trim()
|
||||
)
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
function mapImageUrl (source) {
|
||||
// Implement your mapImageUrl logic here
|
||||
return source
|
||||
}
|
||||
|
||||
return {
|
||||
icon,
|
||||
width,
|
||||
properties,
|
||||
caption,
|
||||
description,
|
||||
src,
|
||||
title,
|
||||
alt,
|
||||
block,
|
||||
value,
|
||||
format,
|
||||
type,
|
||||
visible,
|
||||
hasPageLinkOptions,
|
||||
parent,
|
||||
innerJson
|
||||
}
|
||||
}
|
||||
|
|
@ -54,7 +54,7 @@ const setupForUser = () => {
|
|||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (import.meta.server) return
|
||||
if (import.meta.server || !user.value) return
|
||||
|
||||
// Setup base
|
||||
if (
|
||||
|
|
@ -66,7 +66,6 @@ onMounted(() => {
|
|||
}
|
||||
}
|
||||
|
||||
if (!user.value) return
|
||||
loadScript()
|
||||
try {
|
||||
setupForUser()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export default {
|
||||
id: process.env.NUXT_PUBLIC_GTM_CODE, // Your GTM single container ID, array of container ids ['GTM-xxxxxx', 'GTM-yyyyyy'] or array of objects [{id: 'GTM-xxxxxx', queryParams: { gtm_auth: 'abc123', gtm_preview: 'env-4', gtm_cookies_win: 'x'}}, {id: 'GTM-yyyyyy', queryParams: {gtm_auth: 'abc234', gtm_preview: 'env-5', gtm_cookies_win: 'x'}}], // Your GTM single container ID or array of container ids ['GTM-xxxxxx', 'GTM-yyyyyy']
|
||||
debug: false, // Whether or not display console logs debugs (optional)
|
||||
devtools: false, // (optional)
|
||||
devtools: false, // (optional),
|
||||
enabled: false, // Disabled by default, will be enabled conditionally by our plugin
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,133 @@
|
|||
import { toValue } from 'vue'
|
||||
import { opnFetch } from '~/composables/useOpnApi.js'
|
||||
import clonedeep from 'clone-deep'
|
||||
|
||||
/**
|
||||
* @fileoverview Composable for initializing form data, with complete handling of
|
||||
* form state persistence, URL parameters, and default values.
|
||||
*/
|
||||
export function useFormInitialization(formConfig, form, pendingSubmission) {
|
||||
|
||||
/**
|
||||
* Main method to initialize the form data.
|
||||
* Follows a clear priority order:
|
||||
* 1. Load from submission ID (if provided)
|
||||
* 2. Load from pendingSubmission (localStorage) - client-side only
|
||||
* 3. Apply URL parameters
|
||||
* 4. Apply default values for fields
|
||||
*
|
||||
* @param {Object} options - Initialization options
|
||||
* @param {String} [options.submissionId] - ID of submission to load
|
||||
* @param {URLSearchParams} [options.urlParams] - URL parameters
|
||||
* @param {Object} [options.defaultData] - Default data to apply
|
||||
* @param {Array} [options.fields] - Form fields for special handling
|
||||
*/
|
||||
const initialize = async (options = {}) => {
|
||||
const config = toValue(formConfig)
|
||||
|
||||
// 1. Reset form state
|
||||
form.reset()
|
||||
form.errors.clear()
|
||||
|
||||
// 2. Try loading from submission ID
|
||||
if (options.submissionId) {
|
||||
const loaded = await tryLoadFromSubmissionId(options.submissionId)
|
||||
if (loaded) return // Exit if loaded successfully
|
||||
}
|
||||
|
||||
// 3. Try loading from pendingSubmission
|
||||
if (!(options.skipPendingSubmission ?? false) && tryLoadFromPendingSubmission()) {
|
||||
updateSpecialFields()
|
||||
return // Exit if loaded successfully
|
||||
}
|
||||
|
||||
// 4. Apply URL parameters
|
||||
if (!(options.skipUrlParams ?? false) && options.urlParams) {
|
||||
applyUrlParameters(options.urlParams)
|
||||
}
|
||||
|
||||
// 5. Apply special field handling
|
||||
updateSpecialFields()
|
||||
|
||||
// 6. Apply default data from config or options
|
||||
const defaultValuesToApply = options.defaultData || config?.default_data
|
||||
if (defaultValuesToApply) {
|
||||
applyDefaultValues(defaultValuesToApply, config?.properties)
|
||||
}
|
||||
|
||||
// 7. Process any select fields to ensure IDs are converted to names
|
||||
// This is crucial when receiving data that might contain IDs instead of names
|
||||
const currentData = form.data()
|
||||
if (Object.keys(currentData).length > 0) {
|
||||
resetAndFill(currentData)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper for form.resetAndFill that converts select option IDs to names
|
||||
* @param {Object} formData - Form data to clean and fill
|
||||
*/
|
||||
const resetAndFill = (formData) => {
|
||||
if (!formData) {
|
||||
form.reset()
|
||||
return
|
||||
}
|
||||
|
||||
// Clone the data to avoid mutating the original
|
||||
const cleanData = clonedeep(formData)
|
||||
|
||||
// Process select fields to convert IDs to names
|
||||
if (!formConfig.value || !formConfig.value.properties || !Array.isArray(formConfig.value.properties)) {
|
||||
// If properties aren't available, just use the data as is
|
||||
form.resetAndFill(cleanData)
|
||||
return
|
||||
}
|
||||
|
||||
// Iterate through form fields to process select fields
|
||||
formConfig.value.properties.forEach(field => {
|
||||
// Basic validation
|
||||
if (!field || typeof field !== 'object') return
|
||||
if (!field.id || !field.type) return
|
||||
// Skip only when value is truly undefined or null
|
||||
if (cleanData[field.id] === undefined || cleanData[field.id] === null) return
|
||||
|
||||
// Process checkbox fields - convert string and numeric values to boolean
|
||||
if (field.type === 'checkbox') {
|
||||
const value = cleanData[field.id]
|
||||
if (typeof value === 'string' && value.toLowerCase() === 'true' || value === '1' || value === 1) {
|
||||
cleanData[field.id] = true
|
||||
} else if (typeof value === 'string' && value.toLowerCase() === 'false' || value === '0' || value === 0) {
|
||||
cleanData[field.id] = false
|
||||
}
|
||||
}
|
||||
// Only process select, multi_select fields
|
||||
else if (['select', 'multi_select'].includes(field.type)) {
|
||||
// Make sure the field has options
|
||||
if (!field[field.type] || !Array.isArray(field[field.type].options)) return
|
||||
|
||||
const options = field[field.type].options
|
||||
|
||||
// Process array values (multi-select)
|
||||
if (Array.isArray(cleanData[field.id])) {
|
||||
cleanData[field.id] = cleanData[field.id].map(optionId => {
|
||||
const option = options.find(opt => opt.id === optionId)
|
||||
return option ? option.name : optionId
|
||||
})
|
||||
}
|
||||
// Process single values (select)
|
||||
else {
|
||||
const option = options.find(opt => opt.id === cleanData[field.id])
|
||||
if (option) {
|
||||
cleanData[field.id] = option.name
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Fill with cleaned data
|
||||
form.resetAndFill(cleanData)
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies URL parameters to the form data.
|
||||
* @param {URLSearchParams} params - The URL search parameters.
|
||||
|
|
@ -50,12 +172,8 @@ export function useFormInitialization(formConfig, form, pendingSubmission) {
|
|||
*/
|
||||
const applyDefaultValues = (defaultData) => {
|
||||
if (!defaultData || Object.keys(defaultData).length === 0) return
|
||||
|
||||
for (const key in defaultData) {
|
||||
if (Object.hasOwnProperty.call(defaultData, key) && form[key] === undefined) {
|
||||
form[key] = defaultData[key]
|
||||
}
|
||||
}
|
||||
|
||||
form.resetAndFill(defaultData)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -98,7 +216,7 @@ export function useFormInitialization(formConfig, form, pendingSubmission) {
|
|||
return opnFetch(`/forms/${slug}/submissions/${submissionIdValue}`)
|
||||
.then(submissionData => {
|
||||
if (submissionData.data) {
|
||||
form.resetAndFill(submissionData.data)
|
||||
resetAndFill(submissionData.data)
|
||||
return true
|
||||
} else {
|
||||
console.warn(`Submission ${submissionIdValue} for form ${slug} loaded but returned no data.`)
|
||||
|
|
@ -135,73 +253,14 @@ export function useFormInitialization(formConfig, form, pendingSubmission) {
|
|||
return false
|
||||
}
|
||||
|
||||
form.resetAndFill(pendingData)
|
||||
resetAndFill(pendingData)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Main method to initialize the form data.
|
||||
* Follows a clear priority order:
|
||||
* 1. Load from submission ID (if provided)
|
||||
* 2. Load from pendingSubmission (localStorage) - client-side only
|
||||
* 3. Apply URL parameters
|
||||
* 4. Apply default values for fields
|
||||
*
|
||||
* @param {Object} options - Initialization options
|
||||
* @param {String} [options.submissionId] - ID of submission to load
|
||||
* @param {URLSearchParams} [options.urlParams] - URL parameters
|
||||
* @param {Object} [options.defaultData] - Default data to apply
|
||||
* @param {Array} [options.fields] - Form fields for special handling
|
||||
*/
|
||||
const initialize = async (options = {}) => {
|
||||
const config = toValue(formConfig)
|
||||
|
||||
// 1. Reset form state
|
||||
form.reset()
|
||||
form.errors.clear()
|
||||
|
||||
// 2. Try loading from submission ID
|
||||
if (options.submissionId) {
|
||||
const loaded = await tryLoadFromSubmissionId(options.submissionId)
|
||||
if (loaded) return // Exit if loaded successfully
|
||||
}
|
||||
|
||||
// 3. Try loading from pendingSubmission
|
||||
if (tryLoadFromPendingSubmission()) {
|
||||
updateSpecialFields()
|
||||
return // Exit if loaded successfully
|
||||
}
|
||||
|
||||
// 4. Start with empty form data
|
||||
const formData = {}
|
||||
|
||||
// 5. Apply URL parameters
|
||||
if (options.urlParams) {
|
||||
applyUrlParameters(options.urlParams)
|
||||
}
|
||||
|
||||
// 6. Apply special field handling
|
||||
updateSpecialFields()
|
||||
|
||||
// 7. Apply default data from config or options
|
||||
const defaultData = options.defaultData || config?.default_data
|
||||
if (defaultData) {
|
||||
for (const key in defaultData) {
|
||||
if (!formData[key]) { // Only if not already set
|
||||
formData[key] = defaultData[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Fill the form with the collected data
|
||||
if (Object.keys(formData).length > 0) {
|
||||
form.resetAndFill(formData)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
initialize,
|
||||
applyUrlParameters,
|
||||
applyDefaultValues
|
||||
applyDefaultValues,
|
||||
resetAndFill // Export our wrapped function for use elsewhere
|
||||
}
|
||||
}
|
||||
|
|
@ -56,13 +56,29 @@ export function useFormSubmission(formConfig, form) {
|
|||
// Prepare metadata only (form data will be auto-merged by Form.js)
|
||||
const metadata = _prepareMetadata(options)
|
||||
|
||||
// Use the vForm post method, which will automatically merge form data with metadata
|
||||
const response = await toValue(form).post(url, {
|
||||
data: metadata
|
||||
})
|
||||
// Check if we should actually perform the submission based on mode strategy
|
||||
const formModeStrategy = options.formModeStrategy
|
||||
const shouldSubmit = formModeStrategy?.validation?.performActualSubmission !== false
|
||||
|
||||
let response
|
||||
|
||||
if (shouldSubmit) {
|
||||
// Only perform the actual API call if the strategy allows it
|
||||
response = await toValue(form).post(url, {
|
||||
data: metadata
|
||||
})
|
||||
} else {
|
||||
// Return a mock successful response when in preview/test mode
|
||||
response = {
|
||||
success: true,
|
||||
data: {
|
||||
message: 'Form preview submission (no actual submission performed)',
|
||||
mock: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Optionally reset form after successful submission based on strategy
|
||||
const formModeStrategy = options.formModeStrategy
|
||||
if (formModeStrategy?.submission?.resetAfterSubmit) {
|
||||
toValue(form).reset()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,177 +1,198 @@
|
|||
import { reactive } from 'vue'
|
||||
import { reactive, nextTick } from 'vue'
|
||||
import Quill from 'quill'
|
||||
const Inline = Quill.import('blots/inline')
|
||||
const Clipboard = Quill.import('modules/clipboard')
|
||||
|
||||
export default function registerMentionExtension(Quill) {
|
||||
// Extend Clipboard to handle pasted content
|
||||
class MentionClipboard extends Clipboard {
|
||||
convert(html) {
|
||||
const delta = super.convert(html)
|
||||
const processedDelta = delta.ops.reduce((newDelta, op) => {
|
||||
if (op.attributes && op.attributes.mention) {
|
||||
const mentionData = op.attributes.mention
|
||||
let isValid = false
|
||||
// Check for nested structure
|
||||
if (
|
||||
mentionData &&
|
||||
typeof mentionData === 'object' &&
|
||||
mentionData.field &&
|
||||
typeof mentionData.field === 'object' &&
|
||||
mentionData.field.id
|
||||
) {
|
||||
isValid = true
|
||||
} else if (
|
||||
mentionData &&
|
||||
typeof mentionData === 'object' &&
|
||||
mentionData['mention-field-id']
|
||||
) {
|
||||
// Transform flat structure to nested structure
|
||||
op.attributes.mention = {
|
||||
field: {
|
||||
id: mentionData['mention-field-id'],
|
||||
name: mentionData['mention-field-name'] || '',
|
||||
},
|
||||
fallback: mentionData['mention-fallback'] || '',
|
||||
}
|
||||
isValid = true
|
||||
}
|
||||
if (!isValid) {
|
||||
delete op.attributes.mention
|
||||
}
|
||||
// Core imports
|
||||
const ParchmentEmbed = Quill.import('parchment').EmbedBlot
|
||||
const Delta = Quill.import('delta')
|
||||
const Parchment = Quill.import('parchment')
|
||||
|
||||
/**
|
||||
* Utility to remove BOM and other zero-width characters from a string.
|
||||
*/
|
||||
function cleanString(str) {
|
||||
if (typeof str !== 'string') return ''
|
||||
return str.replace(/\uFEFF/g, '').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
export default function registerMentionExtension(QuillInstance) {
|
||||
/**
|
||||
* MentionBlot - Embeds a mention as a non-editable element
|
||||
*/
|
||||
if (!QuillInstance.imports['formats/mention']) {
|
||||
class MentionBlot extends ParchmentEmbed {
|
||||
static blotName = 'mention'
|
||||
static tagName = 'SPAN'
|
||||
static scope = Parchment.Scope.INLINE
|
||||
|
||||
// Match any span with a 'mention' attribute
|
||||
static matches(domNode) {
|
||||
return (
|
||||
domNode instanceof HTMLElement &&
|
||||
domNode.tagName === this.tagName &&
|
||||
domNode.hasAttribute('mention')
|
||||
)
|
||||
}
|
||||
|
||||
static create(value) {
|
||||
const node = document.createElement(this.tagName)
|
||||
node.setAttribute('contenteditable', 'false')
|
||||
|
||||
const data = (typeof value === 'object' && value !== null) ? value : { field: {}, fallback: '' }
|
||||
data.field = (typeof data.field === 'object' && data.field !== null) ? data.field : {}
|
||||
|
||||
const fieldName = cleanString(data.field.name)
|
||||
const fallbackText = cleanString(data.fallback)
|
||||
const displayText = fieldName || fallbackText || 'mention'
|
||||
|
||||
node.setAttribute('mention', 'true')
|
||||
node.setAttribute('mention-field-id', data.field.id || '')
|
||||
node.setAttribute('mention-field-name', fieldName)
|
||||
node.setAttribute('mention-fallback', fallbackText)
|
||||
|
||||
const textNode = document.createTextNode(displayText)
|
||||
node.appendChild(textNode)
|
||||
|
||||
return node
|
||||
}
|
||||
|
||||
static value(domNode) {
|
||||
return {
|
||||
field: {
|
||||
id: domNode.getAttribute('mention-field-id') || '',
|
||||
name: domNode.getAttribute('mention-field-name') || ''
|
||||
},
|
||||
fallback: domNode.getAttribute('mention-fallback') || ''
|
||||
}
|
||||
newDelta.push(op)
|
||||
return newDelta
|
||||
}, [])
|
||||
return processedDelta
|
||||
}
|
||||
}
|
||||
Quill.register('modules/clipboard', MentionClipboard, true)
|
||||
|
||||
class MentionBlot extends Inline {
|
||||
static blotName = 'mention'
|
||||
static tagName = 'SPAN'
|
||||
|
||||
static create(data) {
|
||||
// Only create mention if we have valid data
|
||||
if (!data || !data.field || !data.field.id) {
|
||||
return null
|
||||
}
|
||||
let node = super.create()
|
||||
MentionBlot.setAttributes(node, data)
|
||||
return node
|
||||
}
|
||||
|
||||
static setAttributes(node, data) {
|
||||
// Only set attributes if we have valid data
|
||||
if (!data || !data.field || !data.field.id) {
|
||||
return
|
||||
static formats(domNode) {
|
||||
return MentionBlot.value(domNode)
|
||||
}
|
||||
node.setAttribute('contenteditable', 'false')
|
||||
node.setAttribute('mention', 'true')
|
||||
node.setAttribute('mention-field-id', data.field.id || '')
|
||||
node.setAttribute('mention-field-name', data.field.name || '')
|
||||
node.setAttribute('mention-fallback', data.fallback || '')
|
||||
node.textContent = data.field.name || ''
|
||||
}
|
||||
|
||||
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') || ''
|
||||
length() {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
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.value(this.domNode)
|
||||
return formats
|
||||
}
|
||||
|
||||
static value(domNode) {
|
||||
return {
|
||||
field: {
|
||||
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
|
||||
}
|
||||
// Register the blot with Quill
|
||||
QuillInstance.register('formats/mention', MentionBlot)
|
||||
}
|
||||
|
||||
Quill.register(MentionBlot)
|
||||
// Add clipboard matcher for handling mentions in pasted/loaded HTML
|
||||
if (QuillInstance.clipboard && typeof QuillInstance.clipboard.addMatcher === 'function') {
|
||||
QuillInstance.clipboard.addMatcher('span[mention]', (node, delta) => {
|
||||
if (node.hasAttribute('mention')) {
|
||||
const mentionData = {
|
||||
field: {
|
||||
id: node.getAttribute('mention-field-id') || '',
|
||||
name: node.getAttribute('mention-field-name') || ''
|
||||
},
|
||||
fallback: node.getAttribute('mention-fallback') || ''
|
||||
}
|
||||
|
||||
return new Delta().insert({ mention: mentionData })
|
||||
}
|
||||
|
||||
return delta
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
/**
|
||||
* MentionModule - Handles mention UI integration with Quill
|
||||
*/
|
||||
if (!QuillInstance.imports['modules/mention']) {
|
||||
class MentionModule {
|
||||
constructor(quill, options = {}) {
|
||||
this.quill = quill
|
||||
this.options = options
|
||||
|
||||
// Reactive state for the UI component
|
||||
this.state = reactive({
|
||||
open: false,
|
||||
onInsert: null,
|
||||
onCancel: null
|
||||
})
|
||||
|
||||
this.setupMentions()
|
||||
}
|
||||
|
||||
setupMentions() {
|
||||
const toolbar = this.quill.getModule('toolbar')
|
||||
if (toolbar) {
|
||||
toolbar.addHandler('mention', () => {
|
||||
const range = this.quill.getSelection()
|
||||
if (range) {
|
||||
this.state.open = true
|
||||
this.state.onInsert = (mentionData) => this.insertMention(mentionData, range.index)
|
||||
this.state.onCancel = () => {
|
||||
this.state.open = false
|
||||
}
|
||||
}
|
||||
mentionState.onCancel = () => {
|
||||
mentionState.open = false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
insertMention(mentionData, index) {
|
||||
if (!mentionData || typeof mentionData.field !== 'object' || mentionData.field === null) {
|
||||
console.error("Invalid mention data for insertion:", mentionData)
|
||||
return
|
||||
}
|
||||
|
||||
this.state.open = false
|
||||
|
||||
// Handle selection
|
||||
const selection = this.quill.getSelection()
|
||||
if (selection && selection.length > 0) {
|
||||
this.quill.deleteText(selection.index, selection.length, QuillInstance.sources.USER)
|
||||
index = selection.index
|
||||
}
|
||||
|
||||
// Prepare clean data for the blot
|
||||
const blotData = {
|
||||
field: {
|
||||
id: mentionData.field.id || '',
|
||||
name: cleanString(mentionData.field.name)
|
||||
},
|
||||
fallback: cleanString(mentionData.fallback)
|
||||
}
|
||||
|
||||
// Insert mention as embed using the blotData
|
||||
this.quill.insertEmbed(index, 'mention', blotData, QuillInstance.sources.USER)
|
||||
|
||||
// Move cursor after mention
|
||||
nextTick(() => {
|
||||
this.quill.focus()
|
||||
this.quill.setSelection(index + 1, 0, QuillInstance.sources.SILENT)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
// Register the module
|
||||
QuillInstance.register('modules/mention', MentionModule)
|
||||
}
|
||||
|
||||
// Patch getSemanticHTML to handle non-breaking spaces
|
||||
if (typeof Quill.prototype.getSemanticHTML === 'function') {
|
||||
if (!Quill.prototype.getSemanticHTML.isPatched) {
|
||||
const originalGetSemanticHTML = Quill.prototype.getSemanticHTML
|
||||
Quill.prototype.getSemanticHTML = function(index = 0, length) {
|
||||
const currentLength = this.getLength()
|
||||
const sanitizedIndex = Math.max(0, index)
|
||||
const sanitizedLength = Math.max(0, Math.min(length ?? (currentLength - sanitizedIndex), currentLength - sanitizedIndex))
|
||||
if (sanitizedIndex >= currentLength && currentLength > 0) {
|
||||
return originalGetSemanticHTML.call(this, 0, 0)
|
||||
}
|
||||
const html = originalGetSemanticHTML.call(this, sanitizedIndex, sanitizedLength)
|
||||
return html.replace(/ |\u00A0/g, ' ')
|
||||
}
|
||||
Quill.prototype.getSemanticHTML.isPatched = true
|
||||
}
|
||||
}
|
||||
|
||||
Quill.register('modules/mention', MentionModule)
|
||||
|
||||
return mentionState
|
||||
|
||||
// Return reactive state for component binding
|
||||
return reactive({
|
||||
open: false,
|
||||
onInsert: null,
|
||||
onCancel: null
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import Quill from 'quill'
|
||||
|
||||
// Self-executing function to patch Quill's prototype
|
||||
;(function installQuillFixes() {
|
||||
// Store the original method
|
||||
const originalGetSemanticHTML = Quill.prototype.getSemanticHTML
|
||||
|
||||
// Override the getSemanticHTML method
|
||||
Quill.prototype.getSemanticHTML = function(index = 0, length) {
|
||||
// Call the original method
|
||||
const html = originalGetSemanticHTML.call(this, index, length || this.getLength())
|
||||
|
||||
// Apply fixes:
|
||||
return html
|
||||
// 1. Replace with regular spaces
|
||||
.replace(/ /g, ' ')
|
||||
// 2. Fix line breaks by replacing empty paragraphs with paragraphs containing <br/>
|
||||
.replace(/<p><\/p>/g, '<p><br/></p>')
|
||||
}
|
||||
})()
|
||||
|
||||
export default {}
|
||||
|
|
@ -8,6 +8,7 @@
|
|||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@codemirror/lang-html": "^6.4.9",
|
||||
"@gtm-support/vue-gtm": "^3.1.0",
|
||||
"@iconify-json/material-symbols": "^1.2.4",
|
||||
"@nuxt/ui": "^2.19.2",
|
||||
"@pinia/nuxt": "^0.11.0",
|
||||
|
|
@ -1448,26 +1449,24 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@gtm-support/core": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@gtm-support/core/-/core-2.3.1.tgz",
|
||||
"integrity": "sha512-eD0hndQjhgKm5f/7IA9fZYujmHiVMY+fnYv4mdZSmz5XJQlS4TiTmpdZx2l7I2A9rI9J6Ysz8LpXYYNo/Xq4LQ==",
|
||||
"dev": true,
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@gtm-support/core/-/core-3.0.1.tgz",
|
||||
"integrity": "sha512-SctcoqvvAbGAgZzOb7DZ4wjbZF3ZS7Las3qIEByv6g7mzPf4E9LpRXcQzjmywYLcUx2jys7PWJAa3s5slvj/0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@gtm-support/vue-gtm": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@gtm-support/vue-gtm/-/vue-gtm-2.2.0.tgz",
|
||||
"integrity": "sha512-7nhBTRkTG0mD+7r7JvNalJz++YwszZk0oP1HIY6fCgz6wNKxT6LuiXCqdPrZmNPe/WbPIKuqxGZN5s+i6NZqow==",
|
||||
"dev": true,
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@gtm-support/vue-gtm/-/vue-gtm-3.1.0.tgz",
|
||||
"integrity": "sha512-kGUnCI+Z5lBeCKd7rzgU7UoFU8Q0EkJfh17SgeyAyx8cLdISqeq54BNJKZrME3WXersoigLZVJ1GLs0buYD3lA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@gtm-support/core": "^2.0.0"
|
||||
"@gtm-support/core": "^3.0.1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"vue-router": ">= 4.1.0 < 5.0.0"
|
||||
"vue-router": ">= 4.4.1 < 5.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": ">= 3.2.0 < 4.0.0"
|
||||
"vue": ">= 3.2.26 < 4.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"vue-router": {
|
||||
|
|
@ -9117,6 +9116,34 @@
|
|||
"nuxt": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@zadigetvoltaire/nuxt-gtm/node_modules/@gtm-support/core": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@gtm-support/core/-/core-2.3.1.tgz",
|
||||
"integrity": "sha512-eD0hndQjhgKm5f/7IA9fZYujmHiVMY+fnYv4mdZSmz5XJQlS4TiTmpdZx2l7I2A9rI9J6Ysz8LpXYYNo/Xq4LQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@zadigetvoltaire/nuxt-gtm/node_modules/@gtm-support/vue-gtm": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@gtm-support/vue-gtm/-/vue-gtm-2.2.0.tgz",
|
||||
"integrity": "sha512-7nhBTRkTG0mD+7r7JvNalJz++YwszZk0oP1HIY6fCgz6wNKxT6LuiXCqdPrZmNPe/WbPIKuqxGZN5s+i6NZqow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@gtm-support/core": "^2.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"vue-router": ">= 4.1.0 < 5.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": ">= 3.2.0 < 4.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"vue-router": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@zadigetvoltaire/nuxt-gtm/node_modules/sirv": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz",
|
||||
|
|
|
|||
|
|
@ -78,60 +78,11 @@
|
|||
</span>
|
||||
<span>- Edited {{ form.last_edited_human }}</span>
|
||||
</p>
|
||||
<div
|
||||
v-if="
|
||||
['draft', 'closed'].includes(form.visibility) ||
|
||||
(form.tags && form.tags.length > 0)
|
||||
"
|
||||
class="mt-2 flex items-center flex-wrap gap-3"
|
||||
>
|
||||
<span
|
||||
v-if="form.visibility == 'draft'"
|
||||
class="inline-flex items-center rounded-full bg-yellow-100 px-2 py-1 text-xs font-medium text-yellow-600 ring-1 ring-inset ring-gray-500/10 dark:text-white dark:bg-gray-700"
|
||||
>
|
||||
Draft - not publicly accessible
|
||||
</span>
|
||||
<span
|
||||
v-else-if="form.visibility == 'closed'"
|
||||
class="inline-flex items-center rounded-full bg-yellow-100 px-2 py-1 text-xs font-medium text-yellow-600 ring-1 ring-inset ring-gray-500/10 dark:text-white dark:bg-gray-700"
|
||||
>
|
||||
Closed - won't accept new submissions
|
||||
</span>
|
||||
<span
|
||||
v-for="(tag) in form.tags"
|
||||
:key="tag"
|
||||
class="inline-flex items-center rounded-full bg-gray-50 px-2 py-1 text-xs font-medium text-gray-600 ring-1 ring-inset ring-gray-500/10 dark:text-white dark:bg-gray-700"
|
||||
>
|
||||
{{ tag }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="form.closes_at"
|
||||
class="text-yellow-500"
|
||||
>
|
||||
<span v-if="form.is_closed">
|
||||
This form stopped accepting submissions on the
|
||||
{{ displayClosesDate }}
|
||||
</span>
|
||||
<span v-else>
|
||||
This form will stop accepting submissions on the
|
||||
{{ displayClosesDate }}
|
||||
</span>
|
||||
</p>
|
||||
<p
|
||||
v-if="form.max_submissions_count > 0"
|
||||
class="text-yellow-500"
|
||||
>
|
||||
<span v-if="form.max_number_of_submissions_reached">
|
||||
The form is now closed because it reached its limit of
|
||||
{{ form.max_submissions_count }} submissions.
|
||||
</span>
|
||||
<span v-else>
|
||||
This form will stop accepting submissions after
|
||||
{{ form.max_submissions_count }} submissions.
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<FormStatusBadges
|
||||
:form="form"
|
||||
class="mt-2"
|
||||
/>
|
||||
|
||||
<form-cleanings
|
||||
class="mt-4"
|
||||
|
|
@ -181,6 +132,7 @@
|
|||
import { computed } from "vue"
|
||||
import ExtraMenu from "../../../components/pages/forms/show/ExtraMenu.vue"
|
||||
import FormCleanings from "../../../components/pages/forms/show/FormCleanings.vue"
|
||||
import FormStatusBadges from "../../../components/open/forms/components/FormStatusBadges.vue"
|
||||
|
||||
definePageMeta({
|
||||
middleware: "auth",
|
||||
|
|
@ -201,23 +153,6 @@ const form = computed(() => formsStore.getByKey(slug))
|
|||
const workspace = computed(() => workspacesStore.getCurrent)
|
||||
|
||||
const loading = computed(() => formsStore.loading || workspacesStore.loading)
|
||||
const displayClosesDate = computed(() => {
|
||||
if (form.value && form.value.closes_at) {
|
||||
const dateObj = new Date(form.value.closes_at)
|
||||
return (
|
||||
dateObj.getFullYear() +
|
||||
"-" +
|
||||
String(dateObj.getMonth() + 1).padStart(2, "0") +
|
||||
"-" +
|
||||
String(dateObj.getDate()).padStart(2, "0") +
|
||||
" " +
|
||||
String(dateObj.getHours()).padStart(2, "0") +
|
||||
":" +
|
||||
String(dateObj.getMinutes()).padStart(2, "0")
|
||||
)
|
||||
}
|
||||
return ""
|
||||
})
|
||||
|
||||
const tabsList = [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -153,30 +153,13 @@
|
|||
{{ form?.creator?.name }}
|
||||
</li>
|
||||
</ul>
|
||||
<div
|
||||
v-if="['draft','closed'].includes(form.visibility) || (form.tags && form.tags.length > 0)"
|
||||
class="mt-1 flex items-center flex-wrap gap-3"
|
||||
>
|
||||
<span
|
||||
v-if="form.visibility=='draft'"
|
||||
class="inline-flex items-center rounded-full bg-yellow-100 px-2 py-1 text-xs font-medium text-yellow-600 ring-1 ring-inset ring-gray-500/10 dark:text-white dark:bg-gray-700"
|
||||
>
|
||||
Draft
|
||||
</span>
|
||||
<span
|
||||
v-else-if="form.visibility=='closed'"
|
||||
class="inline-flex items-center rounded-full bg-yellow-100 px-2 py-1 text-xs font-medium text-yellow-600 ring-1 ring-inset ring-gray-500/10 dark:text-white dark:bg-gray-700"
|
||||
>
|
||||
Closed
|
||||
</span>
|
||||
<span
|
||||
v-for="(tag) in form.tags"
|
||||
:key="tag"
|
||||
class="inline-flex items-center rounded-full bg-gray-50 px-2 py-1 text-xs font-medium text-gray-600 ring-1 ring-inset ring-gray-500/10 dark:text-white dark:bg-gray-700"
|
||||
>
|
||||
{{ tag }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<FormStatusBadges
|
||||
:form="form"
|
||||
class="mt-1"
|
||||
size="xs"
|
||||
/>
|
||||
|
||||
</div>
|
||||
<extra-menu
|
||||
:form="form"
|
||||
|
|
@ -238,6 +221,7 @@ import {useWorkspacesStore} from "../stores/workspaces"
|
|||
import Fuse from "fuse.js"
|
||||
import TextInput from "../components/forms/TextInput.vue"
|
||||
import ExtraMenu from "../components/pages/forms/show/ExtraMenu.vue"
|
||||
import FormStatusBadges from "../components/open/forms/components/FormStatusBadges.vue"
|
||||
import {refDebounced} from "@vueuse/core"
|
||||
|
||||
definePageMeta({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
<template>
|
||||
<div class="relative">
|
||||
<div
|
||||
v-if="(page && page.blocks && published) || loading"
|
||||
class="w-full flex justify-center"
|
||||
>
|
||||
<div class="w-full md:max-w-3xl md:mx-auto px-4 pt-8 md:pt-16 pb-10">
|
||||
<p class="mb-4 text-sm">
|
||||
<UButton
|
||||
:to="{ name: 'integrations' }"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
class="mb-4"
|
||||
icon="i-heroicons-arrow-left"
|
||||
>
|
||||
Other Integrations
|
||||
</UButton>
|
||||
</p>
|
||||
<h1 class="text-3xl mb-2">
|
||||
{{ page.Title }}
|
||||
</h1>
|
||||
<NotionPage
|
||||
:block-map="page.blocks"
|
||||
:loading="loading"
|
||||
:block-overrides="blockOverrides"
|
||||
:map-page-url="mapPageUrl"
|
||||
/>
|
||||
<p class="text-sm">
|
||||
<NuxtLink
|
||||
:to="{ name: 'integrations' }"
|
||||
class="text-blue-500 hover:text-blue-700 inline-block"
|
||||
>
|
||||
Discover our other Integrations
|
||||
</NuxtLink>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="w-full md:max-w-3xl md:mx-auto px-4 pt-8 md:pt-16 pb-10"
|
||||
>
|
||||
<h1 class="text-3xl">
|
||||
Whoops - Page not found
|
||||
</h1>
|
||||
<UButton
|
||||
:to="{name: 'index'}"
|
||||
class="mt-4"
|
||||
label="Go Home"
|
||||
/>
|
||||
</div>
|
||||
<OpenFormFooter class="border-t" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import CustomBlock from '~/components/pages/notion/CustomBlock.vue'
|
||||
import { useNotionCmsStore } from '~/stores/notion_cms.js'
|
||||
|
||||
const blockOverrides = { code: CustomBlock }
|
||||
const slug = computed(() => useRoute().params.slug)
|
||||
const dbId = '1eda631bec208005bd8ed9988b380263'
|
||||
|
||||
const notionCmsStore = useNotionCmsStore()
|
||||
const loading = computed(() => notionCmsStore.loading)
|
||||
|
||||
await notionCmsStore.loadDatabase(dbId)
|
||||
await notionCmsStore.loadPageBySlug(slug.value)
|
||||
|
||||
const page = notionCmsStore.pageBySlug(slug.value)
|
||||
const published = computed(() => {
|
||||
if (!page.value) return false
|
||||
return page.value.Published ?? page.value.published ?? false
|
||||
})
|
||||
|
||||
const mapPageUrl = (pageId) => {
|
||||
// Get everything before the ?
|
||||
pageId = pageId.split('?')[0]
|
||||
const page = notionCmsStore.pages[pageId]
|
||||
const slug = page.slug ?? page.Slug ?? null
|
||||
return useRouter().resolve({ name: 'integrations', params: { slug } }).href
|
||||
}
|
||||
|
||||
defineRouteRules({
|
||||
swr: 3600
|
||||
})
|
||||
definePageMeta({
|
||||
stickyNavbar: true,
|
||||
middleware: ["self-hosted"]
|
||||
})
|
||||
|
||||
useOpnSeoMeta({
|
||||
title: () => page.value.Name,
|
||||
description: () => page.value['Summary - SEO description'] ?? 'Create beautiful forms for free. Unlimited fields, unlimited submissions.'
|
||||
})
|
||||
</script>
|
||||
|
|
@ -0,0 +1,239 @@
|
|||
<template>
|
||||
<div class="relative">
|
||||
<div class="mt-2 flex flex-col">
|
||||
<div
|
||||
v-if="loading"
|
||||
class="bg-white py-12 px-4 sm:px-6 lg:px-8"
|
||||
>
|
||||
<loader class="mx-auto h-6 w-6" />
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="bg-white py-12 px-4 sm:px-6 lg:px-8"
|
||||
>
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<h1 class="text-3xl font-bold text-center text-gray-900">
|
||||
Available Integrations
|
||||
</h1>
|
||||
<p class="text-center text-gray-600 mt-2 mb-10">
|
||||
Explore our powerful Integrations
|
||||
</p>
|
||||
|
||||
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div
|
||||
v-for="integration in integrationsList"
|
||||
:key="integration.title"
|
||||
class="relative rounded-2xl bg-gray-50 p-6 shadow border border-gray-200 hover:shadow-lg transition-all duration-300 hover:bg-white"
|
||||
>
|
||||
<a
|
||||
:href="`/integrations/${integration.slug}`"
|
||||
class="absolute inset-0"
|
||||
/>
|
||||
<div
|
||||
v-if="integration.popular"
|
||||
class="absolute -top-2 -left-3 -rotate-12 bg-blue-500 text-white text-xs font-semibold px-2 py-1 rounded-md shadow"
|
||||
>
|
||||
Most Popular
|
||||
</div>
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="w-10 h-10 bg-white border border-gray-200 rounded-xl flex items-center justify-center">
|
||||
<Icon
|
||||
:name="integration.icon"
|
||||
class="w-8 h-8"
|
||||
dynamic
|
||||
/>
|
||||
</div>
|
||||
<a
|
||||
href="#"
|
||||
class="text-sm text-blue-500 font-medium hover:underline flex items-center gap-1"
|
||||
>
|
||||
Setup Guide
|
||||
<Icon
|
||||
name="heroicons:arrow-top-right-on-square"
|
||||
class="w-4 h-4 flex-shrink-0"
|
||||
dynamic
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<h3 class="mt-4 text-lg font-semibold text-gray-900">
|
||||
{{ integration.title }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
{{ integration.description }}
|
||||
</p>
|
||||
|
||||
<ul class="mt-4 space-y-2 text-sm text-gray-700">
|
||||
<li
|
||||
v-for="step in integration.steps"
|
||||
:key="step"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<span class="text-green-500">✔</span> {{ step }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="bg-white p-10 max-w-6xl mx-auto">
|
||||
<h2 class="text-4xl font-bold text-center text-gray-900 mb-2">
|
||||
Integration General Setup Guides
|
||||
</h2>
|
||||
<p class="text-center text-gray-600 mb-12">
|
||||
This can be another text
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-10 text-gray-800 max-w-6xl mx-auto">
|
||||
<div
|
||||
v-for="guide in setupGuides"
|
||||
:key="guide.title"
|
||||
>
|
||||
<h2 class="text-xl font-semibold mb-4">
|
||||
{{ guide.title }}
|
||||
</h2>
|
||||
<ol class="space-y-4 text-base">
|
||||
<li
|
||||
v-for="(step, index) in guide.steps"
|
||||
:key="step"
|
||||
class="flex items-start gap-3"
|
||||
>
|
||||
<span class="w-8 h-8 flex items-center justify-center rounded-full bg-blue-100 text-blue-700 font-bold">{{ index + 1 }}</span>
|
||||
<span v-html="step" />
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-[#f4f9ff] max-w-6xl mx-auto rounded-3xl m-10 p-10 flex justify-between items-center">
|
||||
<div class="max-w-md">
|
||||
<h2 class="text-3xl font-bold text-gray-900">
|
||||
Need help?
|
||||
</h2>
|
||||
<p class="mt-2 text-gray-500 text-lg">
|
||||
Visit our Help Center for detailed documentation!
|
||||
</p>
|
||||
<a
|
||||
href="#"
|
||||
class="inline-flex items-center gap-2 mt-6 px-4 py-2 bg-blue-500 text-white text-sm font-semibold rounded-lg shadow hover:bg-blue-600 transition"
|
||||
@click.prevent="crisp.openHelpdesk()"
|
||||
>
|
||||
Help Center
|
||||
<Icon
|
||||
name="heroicons:arrow-top-right-on-square"
|
||||
class="w-4 h-4 flex-shrink-0"
|
||||
dynamic
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<div class="hidden lg:grid grid-cols-2 gap-4">
|
||||
<div class="space-y-4">
|
||||
<div class="bg-white p-4 rounded-2xl shadow w-64 h-20">
|
||||
<div class="bg-gray-200 w-24 h-3 mb-2 rounded" />
|
||||
<div class="bg-gray-200 w-full h-6 rounded-full" />
|
||||
</div>
|
||||
<div class="bg-white p-4 rounded-2xl shadow w-64 h-20">
|
||||
<div class="bg-gray-200 w-24 h-3 mb-2 rounded" />
|
||||
<div class="bg-gray-200 w-full h-6 rounded-full" />
|
||||
</div>
|
||||
<div class="bg-white p-4 rounded-2xl shadow w-64 h-20">
|
||||
<div class="bg-gray-200 w-24 h-3 mb-2 rounded" />
|
||||
<div class="bg-gray-200 w-full h-6 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-4 pt-8">
|
||||
<div class="bg-white p-4 rounded-2xl shadow w-64 h-20">
|
||||
<div class="bg-gray-200 w-24 h-3 mb-2 rounded" />
|
||||
<div class="bg-gray-200 w-full h-6 rounded-full" />
|
||||
</div>
|
||||
<div class="bg-white p-4 rounded-2xl shadow w-64 h-20">
|
||||
<div class="bg-gray-200 w-24 h-3 mb-2 rounded" />
|
||||
<div class="bg-gray-200 w-full h-6 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<OpenFormFooter />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useNotionCmsStore } from '~/stores/notion_cms.js'
|
||||
|
||||
useOpnSeoMeta({
|
||||
title: 'Integrations',
|
||||
description:
|
||||
'Create beautiful forms for free. Unlimited fields, unlimited submissions.'
|
||||
})
|
||||
defineRouteRules({
|
||||
swr: 3600
|
||||
})
|
||||
definePageMeta({
|
||||
stickyNavbar: true,
|
||||
middleware: ["self-hosted"]
|
||||
})
|
||||
|
||||
const crisp = useCrisp()
|
||||
|
||||
const dbId = '1eda631bec208005bd8ed9988b380263'
|
||||
const notionCmsStore = useNotionCmsStore()
|
||||
const loading = computed(() => notionCmsStore.loading)
|
||||
await notionCmsStore.loadDatabase(dbId)
|
||||
const pages = notionCmsStore.databasePages(dbId)
|
||||
|
||||
const integrationsList = computed(() => {
|
||||
if (!pages.value) return []
|
||||
return Object.values(pages.value).filter(page => page.Published).map(page => ({
|
||||
title: page['Integration Name'] ?? page.Name,
|
||||
description: page.Summary ?? '',
|
||||
icon: page.Icon ?? 'i-heroicons-envelope-20-solid',
|
||||
slug: page.slug,
|
||||
steps: (page.Steps) ? page.Steps.split('\n') : [],
|
||||
popular: page['Most Popular'] ?? false
|
||||
}))
|
||||
})
|
||||
|
||||
|
||||
const setupGuides = [
|
||||
{
|
||||
title: 'Email Integration Setup',
|
||||
steps: [
|
||||
'Navigate to <b>OpnForm</b> > <b>Integrations</b>.',
|
||||
'Select <b>Email</b> and configure SMTP settings.',
|
||||
'Set up email rules for notifications.',
|
||||
'Save & activate email alerts.'
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Slack Integration Setup',
|
||||
steps: [
|
||||
'Navigate to <b>OpnForm</b> > <b>Integrations</b>.',
|
||||
'Select <b>Slack</b> and authorize your workspace.',
|
||||
'Choose a channel & customize messages.',
|
||||
'Save & activate Slack alerts.'
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'WebHook Integration Setup',
|
||||
steps: [
|
||||
'Navigate to <b>OpnForm</b> > <b>Integrations</b>.',
|
||||
'Select <b>WebHook</b> and enter your endpoint URL.',
|
||||
'Map fields & configure triggers.',
|
||||
'Save & activate WebHook alerts.'
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
</script>
|
||||
|
||||
<style lang='scss'>
|
||||
.integration-page {
|
||||
.notion-asset-wrapper {
|
||||
max-width: 200px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -6,7 +6,7 @@
|
|||
Privacy Policy
|
||||
</h1>
|
||||
<NotionPage
|
||||
:block-map="blockMap"
|
||||
:block-map="page.blocks"
|
||||
:loading="loading"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -16,26 +16,19 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useNotionPagesStore } from "~/stores/notion_pages.js"
|
||||
import { computed } from "vue"
|
||||
|
||||
useOpnSeoMeta({
|
||||
title: "Privacy Policy",
|
||||
})
|
||||
|
||||
definePageMeta({
|
||||
middleware: ["self-hosted"]
|
||||
})
|
||||
|
||||
useOpnSeoMeta({
|
||||
title: "Privacy Policy",
|
||||
})
|
||||
defineRouteRules({
|
||||
swr: 3600,
|
||||
swr: 3600
|
||||
})
|
||||
|
||||
const notionPageStore = useNotionPagesStore()
|
||||
await notionPageStore.load("9c97349ceda7455aab9b341d1ff70f79")
|
||||
|
||||
const loading = computed(() => notionPageStore.loading)
|
||||
const blockMap = computed(() =>
|
||||
notionPageStore.getByKey("9c97349ceda7455aab9b341d1ff70f79"),
|
||||
)
|
||||
const pageId = '9c97349ceda7455aab9b341d1ff70f79'
|
||||
const notionCmsStore = useNotionCmsStore()
|
||||
const loading = computed(() => notionCmsStore.loading)
|
||||
await notionCmsStore.loadPage(pageId)
|
||||
const page = notionCmsStore.getPage(pageId)
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
Terms & Conditions
|
||||
</h1>
|
||||
<NotionPage
|
||||
:block-map="blockMap"
|
||||
:block-map="page.blocks"
|
||||
:loading="loading"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -16,26 +16,19 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useNotionPagesStore } from "~/stores/notion_pages.js"
|
||||
import { computed } from "vue"
|
||||
|
||||
useOpnSeoMeta({
|
||||
title: "Terms & Conditions",
|
||||
})
|
||||
|
||||
definePageMeta({
|
||||
middleware: ["self-hosted"]
|
||||
})
|
||||
|
||||
useOpnSeoMeta({
|
||||
title: "Terms & Conditions",
|
||||
})
|
||||
defineRouteRules({
|
||||
swr: 3600,
|
||||
swr: 3600
|
||||
})
|
||||
|
||||
const notionPageStore = useNotionPagesStore()
|
||||
await notionPageStore.load("246420da2834480ca04047b0c5a00929")
|
||||
|
||||
const loading = computed(() => notionPageStore.loading)
|
||||
const blockMap = computed(() =>
|
||||
notionPageStore.getByKey("246420da2834480ca04047b0c5a00929"),
|
||||
)
|
||||
const pageId = '246420da2834480ca04047b0c5a00929'
|
||||
const notionCmsStore = useNotionCmsStore()
|
||||
const loading = computed(() => notionCmsStore.loading)
|
||||
await notionCmsStore.loadPage(pageId)
|
||||
const page = notionCmsStore.getPage(pageId)
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
const router = useRouter()
|
||||
|
||||
router.onError((error) => {
|
||||
if (
|
||||
error.message.includes('Failed to fetch dynamically imported module') ||
|
||||
error.message.includes('Failed to load resource')
|
||||
) {
|
||||
window.location.reload()
|
||||
}
|
||||
})
|
||||
|
||||
nuxtApp.hook('app:error', (error) => {
|
||||
if (
|
||||
error.message.includes('Loading chunk') ||
|
||||
error.message.includes('Failed to load resource')
|
||||
) {
|
||||
window.location.reload()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -3,7 +3,9 @@ import { Crisp } from "crisp-sdk-web"
|
|||
export default defineNuxtPlugin(() => {
|
||||
const isIframe = useIsIframe()
|
||||
const crispWebsiteId = useRuntimeConfig().public.crispWebsiteId
|
||||
if (crispWebsiteId && !isIframe) {
|
||||
const isPublicFormPage = useRoute().name === 'forms-slug'
|
||||
|
||||
if (crispWebsiteId && !isIframe && !isPublicFormPage) {
|
||||
Crisp.configure(crispWebsiteId)
|
||||
window.Crisp = Crisp
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
import gtmConfig from '../gtm'
|
||||
|
||||
export default defineNuxtPlugin(() => {
|
||||
const route = useRoute()
|
||||
const isIframe = useIsIframe()
|
||||
const isPublicFormPage = route.name === 'forms-slug'
|
||||
|
||||
// Only enable GTM if not in a form page (for respondents) and not in an iframe
|
||||
if (!isPublicFormPage && !isIframe && process.env.NUXT_PUBLIC_GTM_CODE) {
|
||||
// Initialize GTM manually only when needed
|
||||
const gtm = useGtm()
|
||||
|
||||
// Override the enabled setting to true for this session
|
||||
gtmConfig.enabled = true
|
||||
|
||||
// Watch for route changes to track page views
|
||||
watch(() => route.fullPath, () => {
|
||||
if (!route.name || route.name !== 'forms-slug') {
|
||||
gtm.trackView(route.name, route.fullPath)
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
return {
|
||||
provide: {
|
||||
gtm
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {}
|
||||
})
|
||||
|
|
@ -24,23 +24,37 @@ Sentry.init({
|
|||
|
||||
beforeSend (event) {
|
||||
if (event.exception?.values?.length) {
|
||||
const errorType = event.exception.values[0]?.type || '';
|
||||
const errorValue = event.exception.values[0]?.value || '';
|
||||
|
||||
// Don't send validation exceptions to Sentry
|
||||
if (
|
||||
event.exception.values[0]?.type === 'FetchError'
|
||||
&& (event.exception.values[0]?.value?.includes('422')
|
||||
|| event.exception.values[0]?.value?.includes('401'))
|
||||
)
|
||||
errorType === 'FetchError' &&
|
||||
(errorValue.includes('422') || errorValue.includes('401'))
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Filter out chunk loading errors
|
||||
if (
|
||||
errorValue.includes('Failed to fetch dynamically imported module') ||
|
||||
errorValue.includes('Loading chunk') ||
|
||||
errorValue.includes('Failed to load resource')
|
||||
) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const authStore = useAuthStore()
|
||||
if (authStore.check) {
|
||||
const user = authStore.user as { id?: string; email?: string } | null
|
||||
Sentry.setUser({
|
||||
id: authStore.user?.id,
|
||||
email: authStore.user?.email
|
||||
id: user?.id,
|
||||
email: user?.email
|
||||
})
|
||||
event.user = {
|
||||
id: authStore.user?.id,
|
||||
email: authStore.user?.email
|
||||
id: user?.id,
|
||||
email: user?.email
|
||||
}
|
||||
}
|
||||
return event
|
||||
|
|
|
|||
|
|
@ -0,0 +1,127 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import opnformConfig from "~/opnform.config.js"
|
||||
|
||||
function notionApiFetch (entityId, type = 'table') {
|
||||
const apiUrl = opnformConfig.notion.worker
|
||||
return useFetch(`${apiUrl}/${type}/${entityId}`)
|
||||
}
|
||||
|
||||
function fetchNotionDatabasePages (databaseId) {
|
||||
return notionApiFetch(databaseId)
|
||||
}
|
||||
|
||||
function fetchNotionPageContent (pageId) {
|
||||
return notionApiFetch(pageId, 'page')
|
||||
}
|
||||
|
||||
export const useNotionCmsStore = defineStore('notion_cms', () => {
|
||||
|
||||
// State
|
||||
const databases = ref({})
|
||||
const pages = ref({})
|
||||
const pageContents = ref({})
|
||||
const slugToIdMap = ref({})
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
// Actions
|
||||
const loadDatabase = (databaseId) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (databases.value[databaseId]) return resolve()
|
||||
|
||||
loading.value = true
|
||||
return fetchNotionDatabasePages(databaseId).then((response) => {
|
||||
databases.value[databaseId] = response.data.value.map(page => formatId(page.id))
|
||||
response.data.value.forEach(page => {
|
||||
pages.value[formatId(page.id)] = {
|
||||
...page,
|
||||
id: formatId(page.id)
|
||||
}
|
||||
const slug = page.Slug ?? page.slug ?? null
|
||||
if (slug) {
|
||||
setSlugToIdMap(slug, page.id)
|
||||
}
|
||||
})
|
||||
loading.value = false
|
||||
resolve()
|
||||
}).catch((error) => {
|
||||
loading.value = false
|
||||
console.error(error)
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
const loadPage = async (pageId) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (pageContents.value[pageId]) return resolve('in already here')
|
||||
loading.value = true
|
||||
return fetchNotionPageContent(pageId).then((response) => {
|
||||
pageContents.value[formatId(pageId)] = response.data.value
|
||||
loading.value = false
|
||||
return resolve('in finishg')
|
||||
}).catch((error) => {
|
||||
console.error(error)
|
||||
loading.value = false
|
||||
return reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const loadPageBySlug = (slug) => {
|
||||
if (!slugToIdMap.value[slug.toLowerCase()]) return
|
||||
loadPage(slugToIdMap.value[slug.toLowerCase()])
|
||||
}
|
||||
|
||||
const formatId = (id) => id.replaceAll('-', '')
|
||||
|
||||
const getPage = (pageId) => {
|
||||
return {
|
||||
...pages.value[pageId],
|
||||
blocks: getPageBody(pageId)
|
||||
}
|
||||
}
|
||||
|
||||
const getPageBody = (pageId) => {
|
||||
return pageContents.value[pageId]
|
||||
}
|
||||
|
||||
const setSlugToIdMap = (slug, pageId) => {
|
||||
if (!slug) return
|
||||
slugToIdMap.value[slug.toLowerCase()] = formatId(pageId)
|
||||
}
|
||||
|
||||
const getPageBySlug = (slug) => {
|
||||
if (!slug) return
|
||||
const pageId = slugToIdMap.value[slug.toLowerCase()]
|
||||
return getPage(pageId)
|
||||
}
|
||||
|
||||
// Getters
|
||||
const databasePages = (databaseId) => computed(() => databases.value[databaseId]?.map(id => pages.value[id]) || [])
|
||||
const pageContent = (pageId) => computed(() => pageContents.value[pageId])
|
||||
const pageBySlug = (slug) => computed(() => getPageBySlug(slug))
|
||||
|
||||
return {
|
||||
// state
|
||||
loading,
|
||||
databases,
|
||||
pages,
|
||||
pageContents,
|
||||
slugToIdMap,
|
||||
|
||||
// actions
|
||||
loadDatabase,
|
||||
loadPage,
|
||||
loadPageBySlug,
|
||||
getPage,
|
||||
getPageBody,
|
||||
setSlugToIdMap,
|
||||
getPageBySlug,
|
||||
|
||||
// getters
|
||||
databasePages,
|
||||
pageContent,
|
||||
pageBySlug
|
||||
}
|
||||
})
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
import { defineStore } from "pinia"
|
||||
import { useContentStore } from "~/composables/stores/useContentStore.js"
|
||||
import opnformConfig from "~/opnform.config.js"
|
||||
export const useNotionPagesStore = defineStore("notion_pages", () => {
|
||||
const contentStore = useContentStore()
|
||||
|
||||
const load = (pageId) => {
|
||||
contentStore.startLoading()
|
||||
|
||||
const apiUrl = opnformConfig.notion.worker
|
||||
return useFetch(`${apiUrl}/page/${pageId}`)
|
||||
.then(({ data }) => {
|
||||
const val = data.value
|
||||
val["id"] = pageId
|
||||
contentStore.save(val)
|
||||
})
|
||||
.finally(() => {
|
||||
contentStore.stopLoading()
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
...contentStore,
|
||||
load,
|
||||
}
|
||||
})
|
||||
|
|
@ -4,12 +4,6 @@ services:
|
|||
api:
|
||||
image: jhumanj/opnform-api:dev
|
||||
container_name: opnform-api
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/Dockerfile.api
|
||||
args:
|
||||
APP_ENV: local
|
||||
COMPOSER_FLAGS: ""
|
||||
volumes:
|
||||
- ./api:/usr/share/nginx/html:delegated
|
||||
- /usr/share/nginx/html/vendor # Exclude vendor directory from the mount
|
||||
|
|
@ -44,9 +38,6 @@ services:
|
|||
ui:
|
||||
image: jhumanj/opnform-client:dev
|
||||
container_name: opnform-client
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/Dockerfile.client
|
||||
command: sh -c "npm install && NITRO_HOST=0.0.0.0 NITRO_PORT=3000 npm run dev"
|
||||
volumes:
|
||||
- ./client:/app:delegated
|
||||
|
|
|
|||
|
|
@ -3,12 +3,6 @@ services:
|
|||
api: &api-environment
|
||||
image: jhumanj/opnform-api:latest
|
||||
container_name: opnform-api
|
||||
build: &api-build
|
||||
context: .
|
||||
dockerfile: docker/Dockerfile.api
|
||||
args:
|
||||
APP_ENV: production
|
||||
COMPOSER_FLAGS: --no-dev
|
||||
volumes: &api-environment-volumes
|
||||
- ./api/storage:/usr/share/nginx/html/storage:rw
|
||||
environment: &api-env
|
||||
|
|
@ -35,7 +29,6 @@ services:
|
|||
api-worker:
|
||||
<<: *api-environment
|
||||
container_name: opnform-api-worker
|
||||
build: *api-build
|
||||
command: ["php", "artisan", "queue:work"]
|
||||
volumes: *api-environment-volumes
|
||||
environment:
|
||||
|
|
@ -48,7 +41,6 @@ services:
|
|||
api-scheduler:
|
||||
<<: *api-environment
|
||||
container_name: opnform-api-scheduler
|
||||
build: *api-build
|
||||
command: ["php", "artisan", "schedule:work"]
|
||||
volumes: *api-environment-volumes
|
||||
environment:
|
||||
|
|
@ -65,9 +57,6 @@ services:
|
|||
ui:
|
||||
image: jhumanj/opnform-client:latest
|
||||
container_name: opnform-client
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/Dockerfile.client
|
||||
env_file:
|
||||
- ./client/.env
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,8 @@ RUN npm cache clean --force && \
|
|||
# RUN npm install esbuild@0.21.5
|
||||
|
||||
ADD ./client/ /app/
|
||||
RUN npm run build
|
||||
# Increase Node memory limit to prevent out of memory error during build
|
||||
RUN NODE_OPTIONS="--max-old-space-size=4096" npm run build
|
||||
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
|
|
|
|||
Loading…
Reference in New Issue