Merge pull request #61 from shariquerik/email-templates

feat: Email Templates
This commit is contained in:
Shariq Ansari 2024-01-26 20:43:23 +05:30 committed by GitHub
commit 945d657be6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 728 additions and 53 deletions

View File

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

View File

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

View 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
View 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")

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

@ -0,0 +1,6 @@
<template>
<div>
<h1>Email Templates</h1>
<p>Here is a list of email templates</p>
</div>
</template>

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

View File

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