Merge pull request #265 from frappe/develop
chore: Merge develop to main
This commit is contained in:
commit
360d22edd0
@ -132,3 +132,36 @@ def set_as_primary(contact, field, value):
|
|||||||
|
|
||||||
contact.save()
|
contact.save()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def search_emails(txt: str):
|
||||||
|
doctype = "Contact"
|
||||||
|
meta = frappe.get_meta(doctype)
|
||||||
|
filters = [["Contact", "email_id", "is", "set"]]
|
||||||
|
|
||||||
|
if meta.get("fields", {"fieldname": "enabled", "fieldtype": "Check"}):
|
||||||
|
filters.append([doctype, "enabled", "=", 1])
|
||||||
|
if meta.get("fields", {"fieldname": "disabled", "fieldtype": "Check"}):
|
||||||
|
filters.append([doctype, "disabled", "!=", 1])
|
||||||
|
|
||||||
|
or_filters = []
|
||||||
|
search_fields = ["full_name", "email_id", "name"]
|
||||||
|
if txt:
|
||||||
|
for f in search_fields:
|
||||||
|
or_filters.append([doctype, f.strip(), "like", f"%{txt}%"])
|
||||||
|
|
||||||
|
results = frappe.get_list(
|
||||||
|
doctype,
|
||||||
|
filters=filters,
|
||||||
|
fields=search_fields,
|
||||||
|
or_filters=or_filters,
|
||||||
|
limit_start=0,
|
||||||
|
limit_page_length=20,
|
||||||
|
order_by='email_id, full_name, name',
|
||||||
|
ignore_permissions=False,
|
||||||
|
as_list=True,
|
||||||
|
strict=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
return results
|
||||||
@ -9,6 +9,7 @@
|
|||||||
"serve": "vite preview"
|
"serve": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tiptap/extension-paragraph": "^2.4.0",
|
||||||
"@twilio/voice-sdk": "^2.10.2",
|
"@twilio/voice-sdk": "^2.10.2",
|
||||||
"@vueuse/core": "^10.3.0",
|
"@vueuse/core": "^10.3.0",
|
||||||
"@vueuse/integrations": "^10.3.0",
|
"@vueuse/integrations": "^10.3.0",
|
||||||
|
|||||||
@ -442,7 +442,7 @@
|
|||||||
'outgoing_call',
|
'outgoing_call',
|
||||||
].includes(activity.activity_type),
|
].includes(activity.activity_type),
|
||||||
'bg-white': ['added', 'removed', 'changed'].includes(
|
'bg-white': ['added', 'removed', 'changed'].includes(
|
||||||
activity.activity_type
|
activity.activity_type,
|
||||||
),
|
),
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
@ -528,7 +528,10 @@
|
|||||||
<span v-if="activity.data.bcc">{{ activity.data.bcc }}</span>
|
<span v-if="activity.data.bcc">{{ activity.data.bcc }}</span>
|
||||||
</div>
|
</div>
|
||||||
<EmailContent :content="activity.data.content" />
|
<EmailContent :content="activity.data.content" />
|
||||||
<div class="flex flex-wrap gap-2">
|
<div
|
||||||
|
v-if="activity.data?.attachments?.length"
|
||||||
|
class="flex flex-wrap gap-2"
|
||||||
|
>
|
||||||
<AttachmentItem
|
<AttachmentItem
|
||||||
v-for="a in activity.data.attachments"
|
v-for="a in activity.data.attachments"
|
||||||
:key="a.file_url"
|
:key="a.file_url"
|
||||||
@ -1102,7 +1105,7 @@ const defaultActions = computed(() => {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
return actions.filter((action) =>
|
return actions.filter((action) =>
|
||||||
action.condition ? action.condition() : true
|
action.condition ? action.condition() : true,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -1120,12 +1123,12 @@ const activities = computed(() => {
|
|||||||
} else if (props.title == 'Emails') {
|
} else if (props.title == 'Emails') {
|
||||||
if (!all_activities.data?.versions) return []
|
if (!all_activities.data?.versions) return []
|
||||||
activities = all_activities.data.versions.filter(
|
activities = all_activities.data.versions.filter(
|
||||||
(activity) => activity.activity_type === 'communication'
|
(activity) => activity.activity_type === 'communication',
|
||||||
)
|
)
|
||||||
} else if (props.title == 'Comments') {
|
} else if (props.title == 'Comments') {
|
||||||
if (!all_activities.data?.versions) return []
|
if (!all_activities.data?.versions) return []
|
||||||
activities = all_activities.data.versions.filter(
|
activities = all_activities.data.versions.filter(
|
||||||
(activity) => activity.activity_type === 'comment'
|
(activity) => activity.activity_type === 'comment',
|
||||||
)
|
)
|
||||||
} else if (props.title == 'Calls') {
|
} else if (props.title == 'Calls') {
|
||||||
if (!all_activities.data?.calls) return []
|
if (!all_activities.data?.calls) return []
|
||||||
@ -1338,12 +1341,15 @@ function reply(email, reply_all = false) {
|
|||||||
editor.bccEmails = bcc
|
editor.bccEmails = bcc
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let repliedMessage = `<blockquote>${message}</blockquote>`
|
||||||
|
|
||||||
editor.editor
|
editor.editor
|
||||||
.chain()
|
.chain()
|
||||||
.clearContent()
|
.clearContent()
|
||||||
.insertContent(message)
|
.insertContent('<p>.</p>')
|
||||||
|
.updateAttributes('paragraph', {class:'reply-to-content'})
|
||||||
|
.insertContent(repliedMessage)
|
||||||
.focus('all')
|
.focus('all')
|
||||||
.setBlockquote()
|
|
||||||
.insertContentAt(0, { type: 'paragraph' })
|
.insertContentAt(0, { type: 'paragraph' })
|
||||||
.focus('start')
|
.focus('start')
|
||||||
.run()
|
.run()
|
||||||
|
|||||||
@ -12,7 +12,8 @@
|
|||||||
<template v-slot:editor="{ editor }">
|
<template v-slot:editor="{ editor }">
|
||||||
<EditorContent
|
<EditorContent
|
||||||
:class="[
|
:class="[
|
||||||
editable && 'sm:mx-10 mx-4 max-h-[50vh] overflow-y-auto border-t py-3',
|
editable &&
|
||||||
|
'sm:mx-10 mx-4 max-h-[50vh] overflow-y-auto border-t py-3',
|
||||||
]"
|
]"
|
||||||
:editor="editor"
|
:editor="editor"
|
||||||
/>
|
/>
|
||||||
@ -37,11 +38,19 @@
|
|||||||
<div
|
<div
|
||||||
class="flex justify-between gap-2 overflow-hidden border-t sm:px-10 px-4 py-2.5"
|
class="flex justify-between gap-2 overflow-hidden border-t sm:px-10 px-4 py-2.5"
|
||||||
>
|
>
|
||||||
<div class="flex items-center overflow-x-auto">
|
<div class="flex gap-1 items-center overflow-x-auto">
|
||||||
<TextEditorFixedMenu
|
<TextEditorBubbleMenu :buttons="textEditorMenuButtons" />
|
||||||
class="-ml-1"
|
<IconPicker
|
||||||
:buttons="textEditorMenuButtons"
|
v-model="emoji"
|
||||||
/>
|
v-slot="{ togglePopover }"
|
||||||
|
@update:modelValue="() => appendEmoji()"
|
||||||
|
>
|
||||||
|
<Button variant="ghost" @click="togglePopover()">
|
||||||
|
<template #icon>
|
||||||
|
<SmileIcon class="h-4" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
</IconPicker>
|
||||||
<FileUploader
|
<FileUploader
|
||||||
:upload-args="{
|
:upload-args="{
|
||||||
doctype: doctype,
|
doctype: doctype,
|
||||||
@ -77,10 +86,12 @@
|
|||||||
</TextEditor>
|
</TextEditor>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import IconPicker from '@/components/IconPicker.vue'
|
||||||
|
import SmileIcon from '@/components/Icons/SmileIcon.vue'
|
||||||
import AttachmentIcon from '@/components/Icons/AttachmentIcon.vue'
|
import AttachmentIcon from '@/components/Icons/AttachmentIcon.vue'
|
||||||
import AttachmentItem from '@/components/AttachmentItem.vue'
|
import AttachmentItem from '@/components/AttachmentItem.vue'
|
||||||
import { usersStore } from '@/stores/users'
|
import { usersStore } from '@/stores/users'
|
||||||
import { TextEditorFixedMenu, TextEditor, FileUploader } from 'frappe-ui'
|
import { TextEditorBubbleMenu, TextEditor, FileUploader } from 'frappe-ui'
|
||||||
import { EditorContent } from '@tiptap/vue-3'
|
import { EditorContent } from '@tiptap/vue-3'
|
||||||
import { ref, computed, defineModel } from 'vue'
|
import { ref, computed, defineModel } from 'vue'
|
||||||
|
|
||||||
@ -118,11 +129,18 @@ const content = defineModel('content')
|
|||||||
const { users: usersList } = usersStore()
|
const { users: usersList } = usersStore()
|
||||||
|
|
||||||
const textEditor = ref(null)
|
const textEditor = ref(null)
|
||||||
|
const emoji = ref('')
|
||||||
|
|
||||||
const editor = computed(() => {
|
const editor = computed(() => {
|
||||||
return textEditor.value.editor
|
return textEditor.value.editor
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function appendEmoji() {
|
||||||
|
editor.value.commands.insertContent(emoji.value)
|
||||||
|
editor.value.commands.focus()
|
||||||
|
emoji.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
function removeAttachment(attachment) {
|
function removeAttachment(attachment) {
|
||||||
attachments.value = attachments.value.filter((a) => a !== attachment)
|
attachments.value = attachments.value.filter((a) => a !== attachment)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,18 +23,6 @@
|
|||||||
</template>
|
</template>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="showEmailBox" class="flex gap-1.5">
|
|
||||||
<Button
|
|
||||||
:label="__('CC')"
|
|
||||||
@click="toggleCC()"
|
|
||||||
:class="[newEmailEditor.cc ? 'bg-gray-300 hover:bg-gray-200' : '']"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
:label="__('BCC')"
|
|
||||||
@click="toggleBCC()"
|
|
||||||
:class="[newEmailEditor.bcc ? 'bg-gray-300 hover:bg-gray-200' : '']"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-show="showEmailBox"
|
v-show="showEmailBox"
|
||||||
@ -103,7 +91,7 @@ import EmailIcon from '@/components/Icons/EmailIcon.vue'
|
|||||||
import { usersStore } from '@/stores/users'
|
import { usersStore } from '@/stores/users'
|
||||||
import { useStorage } from '@vueuse/core'
|
import { useStorage } from '@vueuse/core'
|
||||||
import { call, createResource } from 'frappe-ui'
|
import { call, createResource } from 'frappe-ui'
|
||||||
import { ref, watch, computed, nextTick } from 'vue'
|
import { ref, watch, computed } from 'vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
doctype: {
|
doctype: {
|
||||||
@ -145,6 +133,7 @@ const signature = createResource({
|
|||||||
})
|
})
|
||||||
|
|
||||||
function setSignature(editor) {
|
function setSignature(editor) {
|
||||||
|
if (!signature.data) return
|
||||||
signature.data = signature.data.replace(/\n/g, '<br>')
|
signature.data = signature.data.replace(/\n/g, '<br>')
|
||||||
let emailContent = editor.getHTML()
|
let emailContent = editor.getHTML()
|
||||||
emailContent = emailContent.startsWith('<p></p>')
|
emailContent = emailContent.startsWith('<p></p>')
|
||||||
@ -236,22 +225,6 @@ async function submitComment() {
|
|||||||
emit('scroll')
|
emit('scroll')
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleCC() {
|
|
||||||
newEmailEditor.value.cc = !newEmailEditor.value.cc
|
|
||||||
newEmailEditor.value.cc &&
|
|
||||||
nextTick(() => {
|
|
||||||
newEmailEditor.value.ccInput.setFocus()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleBCC() {
|
|
||||||
newEmailEditor.value.bcc = !newEmailEditor.value.bcc
|
|
||||||
newEmailEditor.value.bcc &&
|
|
||||||
nextTick(() => {
|
|
||||||
newEmailEditor.value.bccInput.setFocus()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleEmailBox() {
|
function toggleEmailBox() {
|
||||||
if (showCommentBox.value) {
|
if (showCommentBox.value) {
|
||||||
showCommentBox.value = false
|
showCommentBox.value = false
|
||||||
|
|||||||
@ -8,7 +8,7 @@
|
|||||||
:label="value"
|
:label="value"
|
||||||
theme="gray"
|
theme="gray"
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
class="rounded-full"
|
class="rounded"
|
||||||
@keydown.delete.capture.stop="removeLastValue"
|
@keydown.delete.capture.stop="removeLastValue"
|
||||||
>
|
>
|
||||||
<template #suffix>
|
<template #suffix>
|
||||||
@ -133,30 +133,26 @@ watchDebounced(
|
|||||||
query,
|
query,
|
||||||
(val) => {
|
(val) => {
|
||||||
val = val || ''
|
val = val || ''
|
||||||
if (text.value === val) return
|
if (text.value === val && options.value?.length) return
|
||||||
text.value = val
|
text.value = val
|
||||||
reload(val)
|
reload(val)
|
||||||
},
|
},
|
||||||
{ debounce: 300, immediate: true }
|
{ debounce: 300, immediate: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
const filterOptions = createResource({
|
const filterOptions = createResource({
|
||||||
url: 'frappe.desk.search.search_link',
|
url: 'crm.api.contact.search_emails',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
cache: [text.value, 'Contact'],
|
cache: [text.value, 'Contact'],
|
||||||
params: {
|
params: { txt: text.value },
|
||||||
txt: text.value,
|
|
||||||
doctype: 'Contact',
|
|
||||||
},
|
|
||||||
transform: (data) => {
|
transform: (data) => {
|
||||||
let allData = data
|
let allData = data
|
||||||
.filter((c) => {
|
|
||||||
return c.description.split(', ')[1]
|
|
||||||
})
|
|
||||||
.map((option) => {
|
.map((option) => {
|
||||||
let email = option.description.split(', ')[1]
|
let fullName = option[0]
|
||||||
|
let email = option[1]
|
||||||
|
let name = option[2]
|
||||||
return {
|
return {
|
||||||
label: option.label || email,
|
label: fullName || name || email,
|
||||||
value: email,
|
value: email,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -177,10 +173,7 @@ const options = computed(() => {
|
|||||||
|
|
||||||
function reload(val) {
|
function reload(val) {
|
||||||
filterOptions.update({
|
filterOptions.update({
|
||||||
params: {
|
params: { txt: val },
|
||||||
txt: val,
|
|
||||||
doctype: 'Contact',
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
filterOptions.reload()
|
filterOptions.reload()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,9 +4,10 @@
|
|||||||
:srcdoc="htmlContent"
|
:srcdoc="htmlContent"
|
||||||
class="prose-f block h-screen max-h-[500px] w-full"
|
class="prose-f block h-screen max-h-[500px] w-full"
|
||||||
style="
|
style="
|
||||||
|
height: 40px;
|
||||||
mask-image: linear-gradient(
|
mask-image: linear-gradient(
|
||||||
to bottom,
|
to bottom,
|
||||||
black calc(100% - 30px),
|
black calc(100% - 20px),
|
||||||
transparent 100%
|
transparent 100%
|
||||||
);
|
);
|
||||||
"
|
"
|
||||||
@ -27,6 +28,90 @@ const files = import.meta.globEager('/src/index.css', { query: '?inline' })
|
|||||||
const css = files['/src/index.css'].default
|
const css = files['/src/index.css'].default
|
||||||
|
|
||||||
const iframeRef = ref(null)
|
const iframeRef = ref(null)
|
||||||
|
const _content = ref(props.content)
|
||||||
|
|
||||||
|
const parser = new DOMParser()
|
||||||
|
const doc = parser.parseFromString(_content.value, 'text/html')
|
||||||
|
|
||||||
|
const gmailReplyToContent = doc.querySelectorAll('div.gmail_quote')
|
||||||
|
const outlookReplyToContent = doc.querySelectorAll('div#appendonsend')
|
||||||
|
const replyToContent = doc.querySelectorAll('p.reply-to-content')
|
||||||
|
|
||||||
|
if (gmailReplyToContent.length) {
|
||||||
|
_content.value = parseReplyToContent(doc, 'div.gmail_quote', true)
|
||||||
|
} else if (outlookReplyToContent.length) {
|
||||||
|
_content.value = parseReplyToContent(doc, 'div#appendonsend')
|
||||||
|
} else if (replyToContent.length) {
|
||||||
|
_content.value = parseReplyToContent(doc, 'p.reply-to-content')
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseReplyToContent(doc, selector, forGmail = false) {
|
||||||
|
function handleAllInstances(doc) {
|
||||||
|
const replyToContentElements = doc.querySelectorAll(selector)
|
||||||
|
if (replyToContentElements.length === 0) return
|
||||||
|
const replyToContentElement = replyToContentElements[0]
|
||||||
|
replaceReplyToContent(replyToContentElement, forGmail)
|
||||||
|
handleAllInstances(doc)
|
||||||
|
}
|
||||||
|
|
||||||
|
handleAllInstances(doc)
|
||||||
|
|
||||||
|
return doc.body.innerHTML
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceReplyToContent(replyToContentElement, forGmail) {
|
||||||
|
if (!replyToContentElement) return
|
||||||
|
let randomId = Math.random().toString(36).substring(2, 7)
|
||||||
|
const wrapper = doc.createElement('div')
|
||||||
|
wrapper.classList.add('replied-content')
|
||||||
|
|
||||||
|
const collapseLabel = doc.createElement('label')
|
||||||
|
collapseLabel.classList.add('collapse')
|
||||||
|
collapseLabel.setAttribute('for', randomId)
|
||||||
|
collapseLabel.innerHTML = '...'
|
||||||
|
wrapper.appendChild(collapseLabel)
|
||||||
|
|
||||||
|
const collapseInput = doc.createElement('input')
|
||||||
|
collapseInput.setAttribute('id', randomId)
|
||||||
|
collapseInput.setAttribute('class', 'replyCollapser')
|
||||||
|
collapseInput.setAttribute('type', 'checkbox')
|
||||||
|
wrapper.appendChild(collapseInput)
|
||||||
|
|
||||||
|
if (forGmail) {
|
||||||
|
const prevSibling = replyToContentElement.previousElementSibling
|
||||||
|
if (prevSibling && prevSibling.tagName === 'BR') {
|
||||||
|
prevSibling.remove()
|
||||||
|
}
|
||||||
|
let cloned = replyToContentElement.cloneNode(true)
|
||||||
|
cloned.classList.remove('gmail_quote')
|
||||||
|
wrapper.appendChild(cloned)
|
||||||
|
} else {
|
||||||
|
const allSiblings = Array.from(replyToContentElement.parentElement.children)
|
||||||
|
const replyToContentIndex = allSiblings.indexOf(replyToContentElement)
|
||||||
|
const followingSiblings = allSiblings.slice(replyToContentIndex + 1)
|
||||||
|
|
||||||
|
if (followingSiblings.length === 0) return
|
||||||
|
|
||||||
|
let clonedFollowingSiblings = followingSiblings.map((sibling) =>
|
||||||
|
sibling.cloneNode(true),
|
||||||
|
)
|
||||||
|
|
||||||
|
const div = doc.createElement('div')
|
||||||
|
div.append(...clonedFollowingSiblings)
|
||||||
|
|
||||||
|
wrapper.append(div)
|
||||||
|
|
||||||
|
// Remove all siblings after the reply-to-content element
|
||||||
|
for (let i = replyToContentIndex + 1; i < allSiblings.length; i++) {
|
||||||
|
replyToContentElement.parentElement.removeChild(allSiblings[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
replyToContentElement.parentElement.replaceChild(
|
||||||
|
wrapper,
|
||||||
|
replyToContentElement,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const htmlContent = `
|
const htmlContent = `
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
@ -35,6 +120,35 @@ const htmlContent = `
|
|||||||
<style>
|
<style>
|
||||||
${css}
|
${css}
|
||||||
|
|
||||||
|
.replied-content .collapse {
|
||||||
|
margin: 10px 0 10px 0;
|
||||||
|
visibility: visible;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
font-size: larger;
|
||||||
|
font-weight: 700;
|
||||||
|
height: 12px;
|
||||||
|
line-height: 0.1;
|
||||||
|
background: #e8eaed;
|
||||||
|
width: 23px;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.replied-content .collapse:hover {
|
||||||
|
background: #dadce0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.replied-content .collapse + input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.replied-content .collapse + input + div {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.replied-content .collapse + input:checked + div {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
.email-content {
|
.email-content {
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
@ -110,7 +224,7 @@ const htmlContent = `
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div ref="emailContentRef" class="email-content prose-f">${props.content}</div>
|
<div ref="emailContentRef" class="email-content prose-f">${_content.value}</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
`
|
`
|
||||||
@ -120,7 +234,18 @@ watch(iframeRef, (iframe) => {
|
|||||||
iframe.onload = () => {
|
iframe.onload = () => {
|
||||||
const emailContent =
|
const emailContent =
|
||||||
iframe.contentWindow.document.querySelector('.email-content')
|
iframe.contentWindow.document.querySelector('.email-content')
|
||||||
iframe.style.height = emailContent.offsetHeight + 25 + 'px'
|
let parent = emailContent.closest('html')
|
||||||
|
|
||||||
|
iframe.style.height = parent.offsetHeight + 1 + 'px'
|
||||||
|
|
||||||
|
let replyCollapsers = emailContent.querySelectorAll('.replyCollapser')
|
||||||
|
if (replyCollapsers.length) {
|
||||||
|
replyCollapsers.forEach((replyCollapser) => {
|
||||||
|
replyCollapser.addEventListener('change', () => {
|
||||||
|
iframe.style.height = parent.offsetHeight + 1 + 'px'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,68 +1,90 @@
|
|||||||
<template>
|
<template>
|
||||||
<TextEditor
|
<TextEditor
|
||||||
ref="textEditor"
|
ref="textEditor"
|
||||||
:editor-class="['prose-sm max-w-none', editable && 'min-h-[7rem]']"
|
:editor-class="[
|
||||||
|
'prose-sm max-w-none',
|
||||||
|
editable && 'min-h-[7rem]',
|
||||||
|
'[&_p.reply-to-content]:hidden',
|
||||||
|
]"
|
||||||
:content="content"
|
:content="content"
|
||||||
@change="editable ? (content = $event) : null"
|
@change="editable ? (content = $event) : null"
|
||||||
:starterkit-options="{ heading: { levels: [2, 3, 4, 5, 6] } }"
|
:starterkit-options="{
|
||||||
|
heading: { levels: [2, 3, 4, 5, 6] },
|
||||||
|
paragraph: false,
|
||||||
|
}"
|
||||||
:placeholder="placeholder"
|
:placeholder="placeholder"
|
||||||
:editable="editable"
|
:editable="editable"
|
||||||
|
:extensions="[CustomParagraph]"
|
||||||
>
|
>
|
||||||
<template #top>
|
<template #top>
|
||||||
<div class="sm:mx-10 mx-4 flex items-center gap-2 border-t py-2.5">
|
<div class="flex flex-col gap-3">
|
||||||
<span class="text-xs text-gray-500">{{ __('SUBJECT') }}:</span>
|
<div class="sm:mx-10 mx-4 flex items-center gap-2 border-t pt-2.5">
|
||||||
<TextInput
|
<span class="text-xs text-gray-500">{{ __('TO') }}:</span>
|
||||||
class="flex-1 border-none bg-white hover:bg-white focus:border-none focus:!shadow-none focus-visible:!ring-0"
|
<MultiselectInput
|
||||||
v-model="subject"
|
class="flex-1"
|
||||||
/>
|
v-model="toEmails"
|
||||||
</div>
|
:validate="validateEmail"
|
||||||
<div
|
:error-message="
|
||||||
class="sm:mx-10 mx-4 flex items-center gap-2 border-t py-2.5"
|
(value) => __('{0} is an invalid email address', [value])
|
||||||
:class="[cc || bcc ? 'border-b' : '']"
|
"
|
||||||
>
|
/>
|
||||||
<span class="text-xs text-gray-500">{{ __('TO') }}:</span>
|
<div class="flex gap-1.5">
|
||||||
<MultiselectInput
|
<Button
|
||||||
class="flex-1"
|
:label="__('CC')"
|
||||||
v-model="toEmails"
|
variant="ghost"
|
||||||
:validate="validateEmail"
|
@click="toggleCC()"
|
||||||
:error-message="
|
:class="[
|
||||||
(value) => __('{0} is an invalid email address', [value])
|
cc ? '!bg-gray-300 hover:bg-gray-200' : '!text-gray-500',
|
||||||
"
|
]"
|
||||||
/>
|
/>
|
||||||
</div>
|
<Button
|
||||||
<div
|
:label="__('BCC')"
|
||||||
v-if="cc"
|
variant="ghost"
|
||||||
class="sm:mx-10 mx-4 flex items-center gap-2 py-2.5"
|
@click="toggleBCC()"
|
||||||
:class="bcc ? 'border-b' : ''"
|
:class="[
|
||||||
>
|
bcc ? '!bg-gray-300 hover:bg-gray-200' : '!text-gray-500',
|
||||||
<span class="text-xs text-gray-500">{{ __('CC') }}:</span>
|
]"
|
||||||
<MultiselectInput
|
/>
|
||||||
ref="ccInput"
|
</div>
|
||||||
class="flex-1"
|
</div>
|
||||||
v-model="ccEmails"
|
<div v-if="cc" class="sm:mx-10 mx-4 flex items-center gap-2">
|
||||||
:validate="validateEmail"
|
<span class="text-xs text-gray-500">{{ __('CC') }}:</span>
|
||||||
:error-message="
|
<MultiselectInput
|
||||||
(value) => __('{0} is an invalid email address', [value])
|
ref="ccInput"
|
||||||
"
|
class="flex-1"
|
||||||
/>
|
v-model="ccEmails"
|
||||||
</div>
|
:validate="validateEmail"
|
||||||
<div v-if="bcc" class="sm:mx-10 mx-4 flex items-center gap-2 py-2.5">
|
:error-message="
|
||||||
<span class="text-xs text-gray-500">{{ __('BCC') }}:</span>
|
(value) => __('{0} is an invalid email address', [value])
|
||||||
<MultiselectInput
|
"
|
||||||
ref="bccInput"
|
/>
|
||||||
class="flex-1"
|
</div>
|
||||||
v-model="bccEmails"
|
<div v-if="bcc" class="sm:mx-10 mx-4 flex items-center gap-2">
|
||||||
:validate="validateEmail"
|
<span class="text-xs text-gray-500">{{ __('BCC') }}:</span>
|
||||||
:error-message="
|
<MultiselectInput
|
||||||
(value) => __('{0} is an invalid email address', [value])
|
ref="bccInput"
|
||||||
"
|
class="flex-1"
|
||||||
/>
|
v-model="bccEmails"
|
||||||
|
:validate="validateEmail"
|
||||||
|
:error-message="
|
||||||
|
(value) => __('{0} is an invalid email address', [value])
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="sm:mx-10 mx-4 flex items-center gap-2 pb-2.5">
|
||||||
|
<span class="text-xs text-gray-500">{{ __('SUBJECT') }}:</span>
|
||||||
|
<TextInput
|
||||||
|
class="flex-1 border-none bg-white hover:bg-white focus:border-none focus:!shadow-none focus-visible:!ring-0"
|
||||||
|
v-model="subject"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-slot:editor="{ editor }">
|
<template v-slot:editor="{ editor }">
|
||||||
<EditorContent
|
<EditorContent
|
||||||
:class="[
|
:class="[
|
||||||
editable && 'sm:mx-10 mx-4 max-h-[35vh] overflow-y-auto border-t py-3',
|
editable &&
|
||||||
|
'sm:mx-10 mx-4 max-h-[35vh] overflow-y-auto border-t py-3',
|
||||||
]"
|
]"
|
||||||
:editor="editor"
|
:editor="editor"
|
||||||
/>
|
/>
|
||||||
@ -87,37 +109,43 @@
|
|||||||
<div
|
<div
|
||||||
class="flex justify-between gap-2 overflow-hidden border-t sm:px-10 px-4 py-2.5"
|
class="flex justify-between gap-2 overflow-hidden border-t sm:px-10 px-4 py-2.5"
|
||||||
>
|
>
|
||||||
<div class="flex items-center overflow-x-auto">
|
<div class="flex gap-1 items-center overflow-x-auto">
|
||||||
<TextEditorFixedMenu
|
<TextEditorBubbleMenu :buttons="textEditorMenuButtons" />
|
||||||
class="-ml-1"
|
<IconPicker
|
||||||
:buttons="textEditorMenuButtons"
|
v-model="emoji"
|
||||||
/>
|
v-slot="{ togglePopover }"
|
||||||
<div class="flex gap-1">
|
@update:modelValue="() => appendEmoji()"
|
||||||
<FileUploader
|
>
|
||||||
:upload-args="{
|
<Button variant="ghost" @click="togglePopover()">
|
||||||
doctype: doctype,
|
|
||||||
docname: modelValue.name,
|
|
||||||
private: true,
|
|
||||||
}"
|
|
||||||
@success="(f) => attachments.push(f)"
|
|
||||||
>
|
|
||||||
<template #default="{ openFileSelector }">
|
|
||||||
<Button variant="ghost" @click="openFileSelector()">
|
|
||||||
<template #icon>
|
|
||||||
<AttachmentIcon class="h-4" />
|
|
||||||
</template>
|
|
||||||
</Button>
|
|
||||||
</template>
|
|
||||||
</FileUploader>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
@click="showEmailTemplateSelectorModal = true"
|
|
||||||
>
|
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<EmailIcon class="h-4" />
|
<SmileIcon class="h-4" />
|
||||||
</template>
|
</template>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</IconPicker>
|
||||||
|
<FileUploader
|
||||||
|
:upload-args="{
|
||||||
|
doctype: doctype,
|
||||||
|
docname: modelValue.name,
|
||||||
|
private: true,
|
||||||
|
}"
|
||||||
|
@success="(f) => attachments.push(f)"
|
||||||
|
>
|
||||||
|
<template #default="{ openFileSelector }">
|
||||||
|
<Button variant="ghost" @click="openFileSelector()">
|
||||||
|
<template #icon>
|
||||||
|
<AttachmentIcon class="h-4" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</FileUploader>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
@click="showEmailTemplateSelectorModal = true"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<EmailIcon class="h-4" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 flex items-center justify-end space-x-2 sm:mt-0">
|
<div class="mt-2 flex items-center justify-end space-x-2 sm:mt-0">
|
||||||
<Button v-bind="discardButtonProps || {}" :label="__('Discard')" />
|
<Button v-bind="discardButtonProps || {}" :label="__('Discard')" />
|
||||||
@ -139,15 +167,18 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import IconPicker from '@/components/IconPicker.vue'
|
||||||
|
import SmileIcon from '@/components/Icons/SmileIcon.vue'
|
||||||
import EmailIcon from '@/components/Icons/EmailIcon.vue'
|
import EmailIcon from '@/components/Icons/EmailIcon.vue'
|
||||||
import AttachmentIcon from '@/components/Icons/AttachmentIcon.vue'
|
import AttachmentIcon from '@/components/Icons/AttachmentIcon.vue'
|
||||||
import AttachmentItem from '@/components/AttachmentItem.vue'
|
import AttachmentItem from '@/components/AttachmentItem.vue'
|
||||||
import MultiselectInput from '@/components/Controls/MultiselectInput.vue'
|
import MultiselectInput from '@/components/Controls/MultiselectInput.vue'
|
||||||
import EmailTemplateSelectorModal from '@/components/Modals/EmailTemplateSelectorModal.vue'
|
import EmailTemplateSelectorModal from '@/components/Modals/EmailTemplateSelectorModal.vue'
|
||||||
import { TextEditorFixedMenu, TextEditor, FileUploader, call } from 'frappe-ui'
|
import { TextEditorBubbleMenu, TextEditor, FileUploader, call } from 'frappe-ui'
|
||||||
import { validateEmail } from '@/utils'
|
import { validateEmail } from '@/utils'
|
||||||
|
import Paragraph from '@tiptap/extension-paragraph'
|
||||||
import { EditorContent } from '@tiptap/vue-3'
|
import { EditorContent } from '@tiptap/vue-3'
|
||||||
import { ref, computed, defineModel } from 'vue'
|
import { ref, computed, defineModel, nextTick } from 'vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
placeholder: {
|
placeholder: {
|
||||||
@ -180,6 +211,24 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const CustomParagraph = Paragraph.extend({
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
class: {
|
||||||
|
default: null,
|
||||||
|
renderHTML: (attributes) => {
|
||||||
|
if (!attributes.class) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
class: `${attributes.class}`,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const modelValue = defineModel()
|
const modelValue = defineModel()
|
||||||
const attachments = defineModel('attachments')
|
const attachments = defineModel('attachments')
|
||||||
const content = defineModel('content')
|
const content = defineModel('content')
|
||||||
@ -187,6 +236,7 @@ const content = defineModel('content')
|
|||||||
const textEditor = ref(null)
|
const textEditor = ref(null)
|
||||||
const cc = ref(false)
|
const cc = ref(false)
|
||||||
const bcc = ref(false)
|
const bcc = ref(false)
|
||||||
|
const emoji = ref('')
|
||||||
|
|
||||||
const subject = ref(props.subject)
|
const subject = ref(props.subject)
|
||||||
const toEmails = ref(modelValue.value.email ? [modelValue.value.email] : [])
|
const toEmails = ref(modelValue.value.email ? [modelValue.value.email] : [])
|
||||||
@ -211,7 +261,7 @@ async function applyEmailTemplate(template) {
|
|||||||
{
|
{
|
||||||
template_name: template.name,
|
template_name: template.name,
|
||||||
doc: modelValue.value,
|
doc: modelValue.value,
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
if (template.subject) {
|
if (template.subject) {
|
||||||
@ -225,6 +275,22 @@ async function applyEmailTemplate(template) {
|
|||||||
showEmailTemplateSelectorModal.value = false
|
showEmailTemplateSelectorModal.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function appendEmoji() {
|
||||||
|
editor.value.commands.insertContent(emoji.value)
|
||||||
|
editor.value.commands.focus()
|
||||||
|
emoji.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCC() {
|
||||||
|
cc.value = !cc.value
|
||||||
|
cc.value && nextTick(() => ccInput.value.setFocus())
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleBCC() {
|
||||||
|
bcc.value = !bcc.value
|
||||||
|
bcc.value && nextTick(() => bccInput.value.setFocus())
|
||||||
|
}
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
editor,
|
editor,
|
||||||
subject,
|
subject,
|
||||||
@ -233,8 +299,6 @@ defineExpose({
|
|||||||
toEmails,
|
toEmails,
|
||||||
ccEmails,
|
ccEmails,
|
||||||
bccEmails,
|
bccEmails,
|
||||||
ccInput,
|
|
||||||
bccInput,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const textEditorMenuButtons = [
|
const textEditorMenuButtons = [
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
:list="columns"
|
:list="columns"
|
||||||
item-key="column"
|
item-key="column"
|
||||||
@end="updateColumn"
|
@end="updateColumn"
|
||||||
|
:delay="isTouchScreenDevice() ? 200 : 0"
|
||||||
class="flex sm:mx-2.5 mx-2 pb-3.5"
|
class="flex sm:mx-2.5 mx-2 pb-3.5"
|
||||||
>
|
>
|
||||||
<template #item="{ element: column }">
|
<template #item="{ element: column }">
|
||||||
@ -73,6 +74,7 @@
|
|||||||
item-key="name"
|
item-key="name"
|
||||||
class="flex flex-col gap-3.5 flex-1"
|
class="flex flex-col gap-3.5 flex-1"
|
||||||
@end="updateColumn"
|
@end="updateColumn"
|
||||||
|
:delay="isTouchScreenDevice() ? 200 : 0"
|
||||||
:data-column="column.column.name"
|
:data-column="column.column.name"
|
||||||
>
|
>
|
||||||
<template #item="{ element: fields }">
|
<template #item="{ element: fields }">
|
||||||
@ -166,6 +168,7 @@
|
|||||||
import Autocomplete from '@/components/frappe-ui/Autocomplete.vue'
|
import Autocomplete from '@/components/frappe-ui/Autocomplete.vue'
|
||||||
import NestedPopover from '@/components/NestedPopover.vue'
|
import NestedPopover from '@/components/NestedPopover.vue'
|
||||||
import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue'
|
import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue'
|
||||||
|
import { isTouchScreenDevice } from '@/utils'
|
||||||
import Draggable from 'vuedraggable'
|
import Draggable from 'vuedraggable'
|
||||||
import { Dropdown } from 'frappe-ui'
|
import { Dropdown } from 'frappe-ui'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|||||||
@ -1808,6 +1808,11 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-paragraph/-/extension-paragraph-2.2.6.tgz#0e66d2ce21116e43fd006961c42f187ee5e5beab"
|
resolved "https://registry.yarnpkg.com/@tiptap/extension-paragraph/-/extension-paragraph-2.2.6.tgz#0e66d2ce21116e43fd006961c42f187ee5e5beab"
|
||||||
integrity sha512-M2rM3pfzziUb7xS9x2dANCokO89okbqg5IqU4VPkZhk0Mfq9czyCatt58TYkAsE3ccsGhdTYtFBTDeKBtsHUqg==
|
integrity sha512-M2rM3pfzziUb7xS9x2dANCokO89okbqg5IqU4VPkZhk0Mfq9czyCatt58TYkAsE3ccsGhdTYtFBTDeKBtsHUqg==
|
||||||
|
|
||||||
|
"@tiptap/extension-paragraph@^2.4.0":
|
||||||
|
version "2.4.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@tiptap/extension-paragraph/-/extension-paragraph-2.4.0.tgz#5b9aea8775937b327bbe6754be12ae3144fb09ff"
|
||||||
|
integrity sha512-+yse0Ow67IRwcACd9K/CzBcxlpr9OFnmf0x9uqpaWt1eHck1sJnti6jrw5DVVkyEBHDh/cnkkV49gvctT/NyCw==
|
||||||
|
|
||||||
"@tiptap/extension-placeholder@^2.0.3":
|
"@tiptap/extension-placeholder@^2.0.3":
|
||||||
version "2.2.6"
|
version "2.2.6"
|
||||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-placeholder/-/extension-placeholder-2.2.6.tgz#7cb63e398a5301d1e132d4145daef3acb87e7dc5"
|
resolved "https://registry.yarnpkg.com/@tiptap/extension-placeholder/-/extension-placeholder-2.2.6.tgz#7cb63e398a5301d1e132d4145daef3acb87e7dc5"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user