Merge pull request #61 from shariquerik/email-templates
feat: Email Templates
This commit is contained in:
commit
945d657be6
@ -76,7 +76,7 @@ after_install = "crm.install.after_install"
|
||||
# Uninstallation
|
||||
# ------------
|
||||
|
||||
# before_uninstall = "crm.uninstall.before_uninstall"
|
||||
before_uninstall = "crm.uninstall.before_uninstall"
|
||||
# after_uninstall = "crm.uninstall.after_uninstall"
|
||||
|
||||
# Integration Setup
|
||||
@ -118,7 +118,8 @@ after_install = "crm.install.after_install"
|
||||
# Override standard doctype classes
|
||||
|
||||
override_doctype_class = {
|
||||
"Contact": "crm.overrides.contact.CustomContact"
|
||||
"Contact": "crm.overrides.contact.CustomContact",
|
||||
"Email Template": "crm.overrides.email_template.CustomEmailTemplate",
|
||||
}
|
||||
|
||||
# Document Events
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# MIT License. See license.txt
|
||||
|
||||
from __future__ import unicode_literals
|
||||
import click
|
||||
import frappe
|
||||
|
||||
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
|
||||
|
||||
def before_install():
|
||||
pass
|
||||
|
||||
@ -12,6 +14,7 @@ def after_install():
|
||||
add_default_deal_statuses()
|
||||
add_default_communication_statuses()
|
||||
add_property_setter()
|
||||
add_email_template_custom_fields()
|
||||
frappe.db.commit()
|
||||
|
||||
def add_default_lead_statuses():
|
||||
@ -113,4 +116,31 @@ def add_property_setter():
|
||||
doc.property = "search_fields"
|
||||
doc.property_type = "Data"
|
||||
doc.value = "email_id"
|
||||
doc.insert()
|
||||
doc.insert()
|
||||
|
||||
def add_email_template_custom_fields():
|
||||
if not frappe.get_meta("Email Template").has_field("enabled"):
|
||||
click.secho("* Installing Custom Fields in Email Template")
|
||||
|
||||
create_custom_fields(
|
||||
{
|
||||
"Email Template": [
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "enabled",
|
||||
"fieldtype": "Check",
|
||||
"label": "Enabled",
|
||||
"insert_after": "",
|
||||
},
|
||||
{
|
||||
"fieldname": "reference_doctype",
|
||||
"fieldtype": "Link",
|
||||
"label": "Doctype",
|
||||
"options": "DocType",
|
||||
"insert_after": "enabled",
|
||||
},
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
frappe.clear_cache(doctype="Email Template")
|
||||
|
||||
49
crm/overrides/email_template.py
Normal file
49
crm/overrides/email_template.py
Normal file
@ -0,0 +1,49 @@
|
||||
# import frappe
|
||||
from frappe import _
|
||||
from frappe.email.doctype.email_template.email_template import EmailTemplate
|
||||
|
||||
|
||||
class CustomEmailTemplate(EmailTemplate):
|
||||
@staticmethod
|
||||
def default_list_data():
|
||||
columns = [
|
||||
{
|
||||
'label': 'Name',
|
||||
'type': 'Data',
|
||||
'key': 'name',
|
||||
'width': '17rem',
|
||||
},
|
||||
{
|
||||
'label': 'Subject',
|
||||
'type': 'Data',
|
||||
'key': 'subject',
|
||||
'width': '12rem',
|
||||
},
|
||||
{
|
||||
'label': 'Enabled',
|
||||
'type': 'Check',
|
||||
'key': 'enabled',
|
||||
'width': '6rem',
|
||||
},
|
||||
{
|
||||
'label': 'Doctype',
|
||||
'type': 'Link',
|
||||
'key': 'reference_doctype',
|
||||
'width': '12rem',
|
||||
},
|
||||
{
|
||||
'label': 'Last Modified',
|
||||
'type': 'Datetime',
|
||||
'key': 'modified',
|
||||
'width': '8rem',
|
||||
},
|
||||
]
|
||||
rows = [
|
||||
"name",
|
||||
"enabled",
|
||||
"reference_doctype",
|
||||
"subject",
|
||||
"response",
|
||||
"modified",
|
||||
]
|
||||
return {'columns': columns, 'rows': rows}
|
||||
22
crm/uninstall.py
Normal file
22
crm/uninstall.py
Normal file
@ -0,0 +1,22 @@
|
||||
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# MIT License. See license.txt
|
||||
from __future__ import unicode_literals
|
||||
import click
|
||||
import frappe
|
||||
|
||||
def before_uninstall():
|
||||
delete_email_template_custom_fields()
|
||||
|
||||
def delete_email_template_custom_fields():
|
||||
if frappe.get_meta("Email Template").has_field("enabled"):
|
||||
click.secho("* Uninstalling Custom Fields from Email Template")
|
||||
|
||||
fieldnames = (
|
||||
"enabled",
|
||||
"reference_doctype",
|
||||
)
|
||||
|
||||
for fieldname in fieldnames:
|
||||
frappe.db.delete("Custom Field", {"name": "Email Template-" + fieldname})
|
||||
|
||||
frappe.clear_cache(doctype="Email Template")
|
||||
@ -2,8 +2,8 @@
|
||||
<TextEditor
|
||||
ref="textEditor"
|
||||
:editor-class="['prose-sm max-w-none', editable && 'min-h-[7rem]']"
|
||||
:content="value"
|
||||
@change="editable ? $emit('change', $event) : null"
|
||||
:content="content"
|
||||
@change="editable ? (content = $event) : null"
|
||||
:starterkit-options="{ heading: { levels: [2, 3, 4, 5, 6] } }"
|
||||
:placeholder="placeholder"
|
||||
:editable="editable"
|
||||
@ -85,10 +85,6 @@ import { EditorContent } from '@tiptap/vue-3'
|
||||
import { ref, computed, defineModel } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
value: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: null,
|
||||
@ -115,9 +111,9 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['change'])
|
||||
const modelValue = defineModel()
|
||||
const attachments = defineModel('attachments')
|
||||
const content = defineModel('content')
|
||||
|
||||
const { users: usersList } = usersStore()
|
||||
|
||||
|
||||
@ -43,8 +43,7 @@
|
||||
>
|
||||
<EmailEditor
|
||||
ref="newEmailEditor"
|
||||
:value="newEmail"
|
||||
@change="onNewEmailChange"
|
||||
v-model:content="newEmail"
|
||||
:submitButtonProps="{
|
||||
variant: 'solid',
|
||||
onClick: submitEmail,
|
||||
@ -67,8 +66,7 @@
|
||||
<div v-show="showCommentBox">
|
||||
<CommentBox
|
||||
ref="newCommentEditor"
|
||||
:value="newComment"
|
||||
@change="onNewCommentChange"
|
||||
v-model:content="newComment"
|
||||
:submitButtonProps="{
|
||||
variant: 'solid',
|
||||
onClick: submitComment,
|
||||
@ -158,14 +156,6 @@ const emailEmpty = computed(() => {
|
||||
return !newEmail.value || newEmail.value === '<p></p>'
|
||||
})
|
||||
|
||||
const onNewEmailChange = (value) => {
|
||||
newEmail.value = value
|
||||
}
|
||||
|
||||
const onNewCommentChange = (value) => {
|
||||
newComment.value = value
|
||||
}
|
||||
|
||||
async function sendMail() {
|
||||
let recipients = newEmailEditor.value.toEmails
|
||||
let subject = newEmailEditor.value.subject
|
||||
@ -187,7 +177,7 @@ async function sendMail() {
|
||||
}
|
||||
|
||||
async function sendComment() {
|
||||
await call("frappe.desk.form.utils.add_comment", {
|
||||
await call('frappe.desk.form.utils.add_comment', {
|
||||
reference_doctype: props.doctype,
|
||||
reference_name: doc.value.data.name,
|
||||
content: newComment.value,
|
||||
|
||||
@ -2,8 +2,8 @@
|
||||
<TextEditor
|
||||
ref="textEditor"
|
||||
:editor-class="['prose-sm max-w-none', editable && 'min-h-[7rem]']"
|
||||
:content="value"
|
||||
@change="editable ? $emit('change', $event) : null"
|
||||
:content="content"
|
||||
@change="editable ? (content = $event) : null"
|
||||
:starterkit-options="{ heading: { levels: [2, 3, 4, 5, 6] } }"
|
||||
:placeholder="placeholder"
|
||||
:editable="editable"
|
||||
@ -55,7 +55,9 @@
|
||||
</template>
|
||||
<template v-slot:editor="{ editor }">
|
||||
<EditorContent
|
||||
:class="[editable && 'mx-10 max-h-[50vh] overflow-y-auto py-3 border-t']"
|
||||
:class="[
|
||||
editable && 'mx-10 max-h-[50vh] overflow-y-auto border-t py-3',
|
||||
]"
|
||||
:editor="editor"
|
||||
/>
|
||||
</template>
|
||||
@ -84,26 +86,32 @@
|
||||
class="-ml-1"
|
||||
:buttons="textEditorMenuButtons"
|
||||
/>
|
||||
<FileUploader
|
||||
:upload-args="{
|
||||
doctype: doctype,
|
||||
docname: modelValue.name,
|
||||
private: true,
|
||||
}"
|
||||
@success="(f) => attachments.push(f)"
|
||||
>
|
||||
<template #default="{ openFileSelector }">
|
||||
<Button
|
||||
theme="gray"
|
||||
variant="ghost"
|
||||
@click="openFileSelector()"
|
||||
>
|
||||
<template #icon>
|
||||
<AttachmentIcon class="h-4" />
|
||||
</template>
|
||||
</Button>
|
||||
</template>
|
||||
</FileUploader>
|
||||
<div class="flex gap-1">
|
||||
<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">
|
||||
<Button v-bind="discardButtonProps || {}" label="Discard" />
|
||||
@ -117,22 +125,25 @@
|
||||
</div>
|
||||
</template>
|
||||
</TextEditor>
|
||||
<EmailTemplateSelectorModal
|
||||
v-model="showEmailTemplateSelectorModal"
|
||||
:doctype="doctype"
|
||||
@apply="applyEmailTemplate"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import EmailIcon from '@/components/Icons/EmailIcon.vue'
|
||||
import AttachmentIcon from '@/components/Icons/AttachmentIcon.vue'
|
||||
import AttachmentItem from '@/components/AttachmentItem.vue'
|
||||
import MultiselectInput from '@/components/Controls/MultiselectInput.vue'
|
||||
import { TextEditorFixedMenu, TextEditor, FileUploader } from 'frappe-ui'
|
||||
import EmailTemplateSelectorModal from '@/components/Modals/EmailTemplateSelectorModal.vue'
|
||||
import { TextEditorFixedMenu, TextEditor, FileUploader, call } from 'frappe-ui'
|
||||
import { validateEmail } from '@/utils'
|
||||
import { EditorContent } from '@tiptap/vue-3'
|
||||
import { ref, computed, defineModel } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
value: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: null,
|
||||
@ -163,9 +174,9 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['change'])
|
||||
const modelValue = defineModel()
|
||||
const attachments = defineModel('attachments')
|
||||
const content = defineModel('content')
|
||||
|
||||
const textEditor = ref(null)
|
||||
const cc = ref(false)
|
||||
@ -186,6 +197,28 @@ function removeAttachment(attachment) {
|
||||
attachments.value = attachments.value.filter((a) => a !== attachment)
|
||||
}
|
||||
|
||||
const showEmailTemplateSelectorModal = ref(false)
|
||||
|
||||
async function applyEmailTemplate(template) {
|
||||
let data = await call(
|
||||
'frappe.email.doctype.email_template.email_template.get_email_template',
|
||||
{
|
||||
template_name: template.name,
|
||||
doc: modelValue.value,
|
||||
}
|
||||
)
|
||||
|
||||
if (template.subject) {
|
||||
subject.value = data.subject
|
||||
}
|
||||
|
||||
if (template.response) {
|
||||
content.value = data.message
|
||||
editor.value.commands.setContent(data.message)
|
||||
}
|
||||
showEmailTemplateSelectorModal.value = false
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
editor,
|
||||
subject,
|
||||
|
||||
@ -99,6 +99,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import EmailIcon from '@/components/Icons/EmailIcon.vue'
|
||||
import PinIcon from '@/components/Icons/PinIcon.vue'
|
||||
import UserDropdown from '@/components/UserDropdown.vue'
|
||||
import LeadsIcon from '@/components/Icons/LeadsIcon.vue'
|
||||
@ -146,6 +147,11 @@ const links = [
|
||||
icon: PhoneIcon,
|
||||
to: 'Call Logs',
|
||||
},
|
||||
{
|
||||
label: 'Email Templates',
|
||||
icon: EmailIcon,
|
||||
to: 'Email Templates',
|
||||
},
|
||||
]
|
||||
|
||||
function getIcon(routeName) {
|
||||
|
||||
138
frontend/src/components/ListViews/EmailTemplatesListView.vue
Normal file
138
frontend/src/components/ListViews/EmailTemplatesListView.vue
Normal file
@ -0,0 +1,138 @@
|
||||
<template>
|
||||
<ListView
|
||||
:columns="columns"
|
||||
:rows="rows"
|
||||
:options="{
|
||||
onRowClick: (row) => emit('showEmailTemplate', row.name),
|
||||
selectable: options.selectable,
|
||||
}"
|
||||
row-key="name"
|
||||
>
|
||||
<ListHeader class="mx-5" />
|
||||
<ListRows id="list-rows">
|
||||
<ListRow
|
||||
class="mx-5"
|
||||
v-for="row in rows"
|
||||
:key="row.name"
|
||||
v-slot="{ column, item }"
|
||||
:row="row"
|
||||
>
|
||||
<ListRowItem :item="item">
|
||||
<!-- <template #prefix>
|
||||
|
||||
</template> -->
|
||||
<div
|
||||
v-if="['modified', 'creation'].includes(column.key)"
|
||||
class="truncate text-base"
|
||||
>
|
||||
{{ item.timeAgo }}
|
||||
</div>
|
||||
<div v-else-if="column.key === 'status'" class="truncate text-base">
|
||||
<Badge
|
||||
:variant="'subtle'"
|
||||
:theme="item.color"
|
||||
size="md"
|
||||
:label="item.label"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="column.type === 'Check'">
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
:modelValue="item"
|
||||
:disabled="true"
|
||||
class="text-gray-900"
|
||||
/>
|
||||
</div>
|
||||
</ListRowItem>
|
||||
</ListRow>
|
||||
</ListRows>
|
||||
<ListSelectBanner>
|
||||
<template #actions="{ selections, unselectAll }">
|
||||
<Button
|
||||
theme="red"
|
||||
variant="subtle"
|
||||
label="Delete"
|
||||
@click="deleteEmailTemplate(selections, unselectAll)"
|
||||
/>
|
||||
</template>
|
||||
</ListSelectBanner>
|
||||
</ListView>
|
||||
<ListFooter
|
||||
class="border-t px-5 py-2"
|
||||
v-model="pageLengthCount"
|
||||
:options="{
|
||||
rowCount: options.rowCount,
|
||||
totalCount: options.totalCount,
|
||||
}"
|
||||
@loadMore="emit('loadMore')"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
ListView,
|
||||
ListHeader,
|
||||
ListRows,
|
||||
ListRow,
|
||||
ListSelectBanner,
|
||||
ListRowItem,
|
||||
ListFooter,
|
||||
call,
|
||||
} from 'frappe-ui'
|
||||
import { defineModel } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
rows: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
columns: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
options: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
selectable: true,
|
||||
totalCount: 0,
|
||||
rowCount: 0,
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['loadMore', 'showEmailTemplate', 'reload'])
|
||||
|
||||
const pageLengthCount = defineModel()
|
||||
|
||||
function deleteEmailTemplate(selections, unselectAll) {
|
||||
let title = 'Delete email template'
|
||||
let message = 'Are you sure you want to delete this email template?'
|
||||
|
||||
if (selections.size > 1) {
|
||||
title = 'Delete email templates'
|
||||
message = 'Are you sure you want to delete these email templates?'
|
||||
}
|
||||
|
||||
$dialog({
|
||||
title: title,
|
||||
message: message,
|
||||
actions: [
|
||||
{
|
||||
label: 'Delete',
|
||||
theme: 'red',
|
||||
variant: 'solid',
|
||||
async onClick(close) {
|
||||
for (const selection of selections) {
|
||||
await call('frappe.client.delete', {
|
||||
doctype: 'Email Template',
|
||||
name: selection,
|
||||
})
|
||||
}
|
||||
close()
|
||||
unselectAll()
|
||||
emit('reload')
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
</script>
|
||||
199
frontend/src/components/Modals/EmailTemplateModal.vue
Normal file
199
frontend/src/components/Modals/EmailTemplateModal.vue
Normal file
@ -0,0 +1,199 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: editMode ? emailTemplate.name : 'Create Email Template',
|
||||
size: 'xl',
|
||||
actions: [
|
||||
{
|
||||
label: editMode ? 'Update' : 'Create',
|
||||
variant: 'solid',
|
||||
onClick: () => (editMode ? updateEmailTemplate() : callInsertDoc()),
|
||||
},
|
||||
],
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex gap-4">
|
||||
<div class="flex-1">
|
||||
<div class="mb-1.5 text-sm text-gray-600">
|
||||
Name
|
||||
<span class="text-red-500">*</span>
|
||||
</div>
|
||||
<TextInput
|
||||
ref="nameRef"
|
||||
variant="outline"
|
||||
v-model="_emailTemplate.name"
|
||||
placeholder="Add name"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="mb-1.5 text-sm text-gray-600">Doctype</div>
|
||||
<Select
|
||||
variant="outline"
|
||||
v-model="_emailTemplate.reference_doctype"
|
||||
:options="['CRM Deal', 'CRM Lead']"
|
||||
placeholder="Select Doctype"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1.5 text-sm text-gray-600">
|
||||
Subject
|
||||
<span class="text-red-500">*</span>
|
||||
</div>
|
||||
<TextInput
|
||||
ref="subjectRef"
|
||||
variant="outline"
|
||||
v-model="_emailTemplate.subject"
|
||||
placeholder="Add subject"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1.5 text-sm text-gray-600">
|
||||
Content
|
||||
<span class="text-red-500">*</span>
|
||||
</div>
|
||||
<TextEditor
|
||||
variant="outline"
|
||||
ref="content"
|
||||
editor-class="!prose-sm overflow-auto min-h-[180px] max-h-80 py-1.5 px-2 rounded border border-gray-300 bg-white hover:border-gray-400 hover:shadow-sm focus:bg-white focus:border-gray-500 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-gray-400 text-gray-800 transition-colors"
|
||||
:bubbleMenu="true"
|
||||
:content="_emailTemplate.response"
|
||||
@change="(val) => (_emailTemplate.response = val)"
|
||||
placeholder="Type a Content"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Checkbox v-model="_emailTemplate.enabled" label="Enabled" />
|
||||
</div>
|
||||
<ErrorMessage :message="errorMessage" />
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Checkbox, Select, TextEditor, call } from 'frappe-ui'
|
||||
import { ref, defineModel, nextTick, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
emailTemplate: {
|
||||
type: Object,
|
||||
default: {},
|
||||
},
|
||||
})
|
||||
|
||||
const show = defineModel()
|
||||
const emailTemplates = defineModel('reloadEmailTemplates')
|
||||
const errorMessage = ref('')
|
||||
|
||||
const emit = defineEmits(['after'])
|
||||
|
||||
const subjectRef = ref(null)
|
||||
const nameRef = ref(null)
|
||||
const editMode = ref(false)
|
||||
let _emailTemplate = ref({})
|
||||
|
||||
async function updateEmailTemplate() {
|
||||
if (!validate()) return
|
||||
const old = { ...props.emailTemplate }
|
||||
const newEmailTemplate = { ..._emailTemplate.value }
|
||||
|
||||
const nameChanged = old.name !== newEmailTemplate.name
|
||||
delete old.name
|
||||
delete newEmailTemplate.name
|
||||
|
||||
const otherFieldChanged =
|
||||
JSON.stringify(old) !== JSON.stringify(newEmailTemplate)
|
||||
const values = newEmailTemplate
|
||||
|
||||
if (!nameChanged && !otherFieldChanged) {
|
||||
show.value = false
|
||||
return
|
||||
}
|
||||
|
||||
let name
|
||||
if (nameChanged) {
|
||||
name = await callRenameDoc()
|
||||
}
|
||||
if (otherFieldChanged) {
|
||||
name = await callSetValue(values)
|
||||
}
|
||||
handleEmailTemplateUpdate({ name })
|
||||
}
|
||||
|
||||
async function callRenameDoc() {
|
||||
const d = await call('frappe.client.rename_doc', {
|
||||
doctype: 'Email Template',
|
||||
old_name: props.emailTemplate.name,
|
||||
new_name: _emailTemplate.value.name,
|
||||
})
|
||||
return d
|
||||
}
|
||||
|
||||
async function callSetValue(values) {
|
||||
const d = await call('frappe.client.set_value', {
|
||||
doctype: 'Email Template',
|
||||
name: _emailTemplate.value.name,
|
||||
fieldname: values,
|
||||
})
|
||||
return d.name
|
||||
}
|
||||
|
||||
async function callInsertDoc() {
|
||||
if (!validate()) return
|
||||
const doc = await call('frappe.client.insert', {
|
||||
doc: {
|
||||
doctype: 'Email Template',
|
||||
..._emailTemplate.value,
|
||||
},
|
||||
})
|
||||
doc.name && handleEmailTemplateUpdate(doc)
|
||||
}
|
||||
|
||||
function handleEmailTemplateUpdate(doc) {
|
||||
emailTemplates.value?.reload()
|
||||
show.value = false
|
||||
}
|
||||
|
||||
function validate() {
|
||||
if (!_emailTemplate.value.name) {
|
||||
errorMessage.value = 'Name is required'
|
||||
return false
|
||||
}
|
||||
if (!_emailTemplate.value.subject) {
|
||||
errorMessage.value = 'Subject is required'
|
||||
return false
|
||||
}
|
||||
if (
|
||||
!_emailTemplate.value.response ||
|
||||
_emailTemplate.value.response === '<p></p>'
|
||||
) {
|
||||
errorMessage.value = 'Content is required'
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
watch(
|
||||
() => show.value,
|
||||
(value) => {
|
||||
if (!value) return
|
||||
editMode.value = false
|
||||
errorMessage.value = ''
|
||||
nextTick(() => {
|
||||
if (_emailTemplate.value.name) {
|
||||
subjectRef.value.el.focus()
|
||||
} else {
|
||||
nameRef.value.el.focus()
|
||||
}
|
||||
_emailTemplate.value = { ...props.emailTemplate }
|
||||
if (_emailTemplate.value.name) {
|
||||
editMode.value = true
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
</script>
|
||||
@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<Dialog v-model="show" :options="{ title: 'Email Templates', size: '4xl' }">
|
||||
<template #body-content>
|
||||
<TextInput
|
||||
ref="searchInput"
|
||||
v-model="search"
|
||||
type="text"
|
||||
class="mb-2 w-full"
|
||||
placeholder="Search"
|
||||
/>
|
||||
<div class="grid max-h-[560px] grid-cols-3 gap-2 overflow-y-auto">
|
||||
<div
|
||||
v-for="template in filteredTemplates"
|
||||
:key="template.name"
|
||||
class="flex h-56 cursor-pointer flex-col gap-2 rounded-lg border p-3 hover:bg-gray-100"
|
||||
@click="emit('apply', template)"
|
||||
>
|
||||
<div class="border-b pb-2 text-base font-semibold">
|
||||
{{ template.name }}
|
||||
</div>
|
||||
<div v-if="template.subject" class="text-sm text-gray-600">
|
||||
Subject: {{ template.subject }}
|
||||
</div>
|
||||
<TextEditor
|
||||
v-if="template.response"
|
||||
:content="template.response"
|
||||
:editable="false"
|
||||
editor-class="!prose-sm max-w-none !text-sm text-gray-600 focus:outline-none"
|
||||
class="flex-1 overflow-hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { TextEditor, createListResource } from 'frappe-ui'
|
||||
import { defineModel, ref, computed, nextTick, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
doctype: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
const show = defineModel()
|
||||
const searchInput = ref('')
|
||||
|
||||
const emit = defineEmits(['apply'])
|
||||
|
||||
const search = ref('')
|
||||
|
||||
const templates = createListResource({
|
||||
type: 'list',
|
||||
doctype: 'Email Template',
|
||||
cache: ['Email Templates', props.doctype],
|
||||
fields: [
|
||||
'name',
|
||||
'enabled',
|
||||
'reference_doctype',
|
||||
'subject',
|
||||
'response',
|
||||
'modified',
|
||||
'owner',
|
||||
],
|
||||
filters: { enabled: 1, reference_doctype: props.doctype },
|
||||
orderBy: 'modified desc',
|
||||
pageLength: 99999,
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const filteredTemplates = computed(() => {
|
||||
return (
|
||||
templates.data?.filter((template) => {
|
||||
return (
|
||||
template.name.toLowerCase().includes(search.value.toLowerCase()) ||
|
||||
template.subject.toLowerCase().includes(search.value.toLowerCase())
|
||||
)
|
||||
}) ?? []
|
||||
)
|
||||
})
|
||||
|
||||
watch(show, (value) => value && nextTick(() => searchInput.value?.el?.focus()))
|
||||
</script>
|
||||
6
frontend/src/pages/EmailTemplate.vue
Normal file
6
frontend/src/pages/EmailTemplate.vue
Normal file
@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1>Email Templates</h1>
|
||||
<p>Here is a list of email templates</p>
|
||||
</div>
|
||||
</template>
|
||||
107
frontend/src/pages/EmailTemplates.vue
Normal file
107
frontend/src/pages/EmailTemplates.vue
Normal file
@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<LayoutHeader>
|
||||
<template #left-header>
|
||||
<Breadcrumbs :items="breadcrumbs" />
|
||||
</template>
|
||||
<template #right-header>
|
||||
<Button variant="solid" label="Create" @click="showEmailTemplateModal = true">
|
||||
<template #prefix><FeatherIcon name="plus" class="h-4" /></template>
|
||||
</Button>
|
||||
</template>
|
||||
</LayoutHeader>
|
||||
<ViewControls
|
||||
v-model="emailTemplates"
|
||||
v-model:loadMore="loadMore"
|
||||
doctype="Email Template"
|
||||
/>
|
||||
<EmailTemplatesListView
|
||||
v-if="emailTemplates.data && rows.length"
|
||||
v-model="emailTemplates.data.page_length_count"
|
||||
:rows="rows"
|
||||
:columns="emailTemplates.data.columns"
|
||||
:options="{
|
||||
rowCount: emailTemplates.data.row_count,
|
||||
totalCount: emailTemplates.data.total_count,
|
||||
}"
|
||||
@loadMore="() => loadMore++"
|
||||
@showEmailTemplate="showEmailTemplate"
|
||||
@reload="() => emailTemplates.reload()"
|
||||
/>
|
||||
<div
|
||||
v-else-if="emailTemplates.data"
|
||||
class="flex h-full items-center justify-center"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col items-center gap-3 text-xl font-medium text-gray-500"
|
||||
>
|
||||
<EmailIcon class="h-10 w-10" />
|
||||
<span>No Email Templates Found</span>
|
||||
</div>
|
||||
</div>
|
||||
<EmailTemplateModal
|
||||
v-model="showEmailTemplateModal"
|
||||
v-model:reloadEmailTemplates="emailTemplates"
|
||||
:emailTemplate="emailTemplate"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import EmailIcon from '@/components/Icons/EmailIcon.vue'
|
||||
import LayoutHeader from '@/components/LayoutHeader.vue'
|
||||
import ViewControls from '@/components/ViewControls.vue'
|
||||
import EmailTemplatesListView from '@/components/ListViews/EmailTemplatesListView.vue'
|
||||
import EmailTemplateModal from '@/components/Modals/EmailTemplateModal.vue'
|
||||
import { dateFormat, dateTooltipFormat, timeAgo } from '@/utils'
|
||||
import { Breadcrumbs } from 'frappe-ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const breadcrumbs = [
|
||||
{ label: 'Email Templates', route: { name: 'Email Templates' } },
|
||||
]
|
||||
|
||||
// emailTemplates data is loaded in the ViewControls component
|
||||
const emailTemplates = ref({})
|
||||
const loadMore = ref(1)
|
||||
|
||||
const rows = computed(() => {
|
||||
if (!emailTemplates.value?.data?.data) return []
|
||||
return emailTemplates.value?.data.data.map((emailTemplate) => {
|
||||
let _rows = {}
|
||||
emailTemplates.value?.data.rows.forEach((row) => {
|
||||
_rows[row] = emailTemplate[row]
|
||||
|
||||
if (['modified', 'creation'].includes(row)) {
|
||||
_rows[row] = {
|
||||
label: dateFormat(emailTemplate[row], dateTooltipFormat),
|
||||
timeAgo: timeAgo(emailTemplate[row]),
|
||||
}
|
||||
}
|
||||
})
|
||||
return _rows
|
||||
})
|
||||
})
|
||||
|
||||
const showEmailTemplateModal = ref(false)
|
||||
|
||||
const emailTemplate = ref({
|
||||
subject: '',
|
||||
response: '',
|
||||
name: '',
|
||||
enabled: 1,
|
||||
owner: '',
|
||||
reference_doctype: 'CRM Deal',
|
||||
})
|
||||
|
||||
function showEmailTemplate(name) {
|
||||
let et = rows.value?.find((row) => row.name === name)
|
||||
emailTemplate.value = {
|
||||
subject: et.subject,
|
||||
response: et.response,
|
||||
name: et.name,
|
||||
enabled: et.enabled,
|
||||
owner: et.owner,
|
||||
reference_doctype: et.reference_doctype,
|
||||
}
|
||||
showEmailTemplateModal.value = true
|
||||
}
|
||||
</script>
|
||||
@ -73,6 +73,18 @@ const routes = [
|
||||
component: () => import('@/pages/CallLog.vue'),
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: '/email-templates',
|
||||
name: 'Email Templates',
|
||||
component: () => import('@/pages/EmailTemplates.vue'),
|
||||
meta: { scrollPos: { top: 0, left: 0 } },
|
||||
},
|
||||
{
|
||||
path: '/email-templates/:emailTemplateId',
|
||||
name: 'Email Template',
|
||||
component: () => import('@/pages/EmailTemplate.vue'),
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: '/dashboard',
|
||||
name: 'Dashboard',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user