Merge pull request #45 from shariquerik/custom-actions
feat: Custom Actions on Lead/Deal Page
This commit is contained in:
commit
d7796c040c
@ -2,6 +2,7 @@ import frappe
|
|||||||
from frappe import _
|
from frappe import _
|
||||||
|
|
||||||
from crm.api.doc import get_doctype_fields
|
from crm.api.doc import get_doctype_fields
|
||||||
|
from crm.fcrm.doctype.crm_form_script.crm_form_script import get_form_script
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def get_deal(name):
|
def get_deal(name):
|
||||||
@ -27,5 +28,6 @@ def get_deal(name):
|
|||||||
)
|
)
|
||||||
|
|
||||||
deal["doctype_fields"] = get_doctype_fields("CRM Deal")
|
deal["doctype_fields"] = get_doctype_fields("CRM Deal")
|
||||||
deal["doctype"] = "CRM Deal"
|
deal["doctype"] = "CRM Deal"
|
||||||
|
deal["_form_script"] = get_form_script('CRM Deal')
|
||||||
return deal
|
return deal
|
||||||
|
|||||||
0
crm/fcrm/doctype/crm_form_script/__init__.py
Normal file
0
crm/fcrm/doctype/crm_form_script/__init__.py
Normal file
8
crm/fcrm/doctype/crm_form_script/crm_form_script.js
Normal file
8
crm/fcrm/doctype/crm_form_script/crm_form_script.js
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
|
||||||
|
// For license information, please see license.txt
|
||||||
|
|
||||||
|
// frappe.ui.form.on("CRM Form Script", {
|
||||||
|
// refresh(frm) {
|
||||||
|
|
||||||
|
// },
|
||||||
|
// });
|
||||||
70
crm/fcrm/doctype/crm_form_script/crm_form_script.json
Normal file
70
crm/fcrm/doctype/crm_form_script/crm_form_script.json
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
{
|
||||||
|
"actions": [],
|
||||||
|
"allow_rename": 1,
|
||||||
|
"autoname": "prompt",
|
||||||
|
"creation": "2023-12-28 14:18:09.329868",
|
||||||
|
"doctype": "DocType",
|
||||||
|
"engine": "InnoDB",
|
||||||
|
"field_order": [
|
||||||
|
"dt",
|
||||||
|
"column_break_gboh",
|
||||||
|
"enabled",
|
||||||
|
"section_break_xeox",
|
||||||
|
"script"
|
||||||
|
],
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldname": "column_break_gboh",
|
||||||
|
"fieldtype": "Column Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "section_break_xeox",
|
||||||
|
"fieldtype": "Section Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "dt",
|
||||||
|
"fieldtype": "Link",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"label": "DocType",
|
||||||
|
"options": "DocType",
|
||||||
|
"reqd": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default": "0",
|
||||||
|
"fieldname": "enabled",
|
||||||
|
"fieldtype": "Check",
|
||||||
|
"label": "Enabled"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "script",
|
||||||
|
"fieldtype": "Code",
|
||||||
|
"label": "Script",
|
||||||
|
"options": "JS"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"index_web_pages_for_search": 1,
|
||||||
|
"links": [],
|
||||||
|
"modified": "2023-12-28 16:26:39.967069",
|
||||||
|
"modified_by": "Administrator",
|
||||||
|
"module": "FCRM",
|
||||||
|
"name": "CRM Form Script",
|
||||||
|
"naming_rule": "Set by user",
|
||||||
|
"owner": "Administrator",
|
||||||
|
"permissions": [
|
||||||
|
{
|
||||||
|
"create": 1,
|
||||||
|
"delete": 1,
|
||||||
|
"email": 1,
|
||||||
|
"export": 1,
|
||||||
|
"print": 1,
|
||||||
|
"read": 1,
|
||||||
|
"report": 1,
|
||||||
|
"role": "System Manager",
|
||||||
|
"share": 1,
|
||||||
|
"write": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sort_field": "modified",
|
||||||
|
"sort_order": "DESC",
|
||||||
|
"states": []
|
||||||
|
}
|
||||||
45
crm/fcrm/doctype/crm_form_script/crm_form_script.py
Normal file
45
crm/fcrm/doctype/crm_form_script/crm_form_script.py
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
|
||||||
|
# For license information, please see license.txt
|
||||||
|
|
||||||
|
import frappe
|
||||||
|
from frappe.model.document import Document
|
||||||
|
|
||||||
|
|
||||||
|
class CRMFormScript(Document):
|
||||||
|
def validate(self):
|
||||||
|
self.check_if_duplicate()
|
||||||
|
|
||||||
|
def check_if_duplicate(self):
|
||||||
|
"""Check if there is already a script for this doctype"""
|
||||||
|
if self.dt and self.enabled:
|
||||||
|
filters = {
|
||||||
|
"dt": self.dt,
|
||||||
|
"enabled": 1,
|
||||||
|
}
|
||||||
|
if self.name:
|
||||||
|
filters["name"] = ["!=", self.name]
|
||||||
|
|
||||||
|
if frappe.db.exists("CRM Form Script", filters):
|
||||||
|
frappe.throw(
|
||||||
|
frappe._(
|
||||||
|
"Script already exists for this doctype and is enabled"
|
||||||
|
),
|
||||||
|
frappe.DuplicateEntryError,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_form_script(dt):
|
||||||
|
"""Returns the script for the given doctype"""
|
||||||
|
FormScript = frappe.qb.DocType("CRM Form Script")
|
||||||
|
query = (
|
||||||
|
frappe.qb.from_(FormScript)
|
||||||
|
.select("script")
|
||||||
|
.where(FormScript.dt == dt)
|
||||||
|
.where(FormScript.enabled == 1)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
|
||||||
|
doc = query.run(as_dict=True)
|
||||||
|
if doc:
|
||||||
|
return doc[0].script
|
||||||
|
else:
|
||||||
|
return None
|
||||||
9
crm/fcrm/doctype/crm_form_script/test_crm_form_script.py
Normal file
9
crm/fcrm/doctype/crm_form_script/test_crm_form_script.py
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
|
||||||
|
# See license.txt
|
||||||
|
|
||||||
|
# import frappe
|
||||||
|
from frappe.tests.utils import FrappeTestCase
|
||||||
|
|
||||||
|
|
||||||
|
class TestCRMFormScript(FrappeTestCase):
|
||||||
|
pass
|
||||||
@ -2,6 +2,7 @@ import frappe
|
|||||||
from frappe import _
|
from frappe import _
|
||||||
|
|
||||||
from crm.api.doc import get_doctype_fields
|
from crm.api.doc import get_doctype_fields
|
||||||
|
from crm.fcrm.doctype.crm_form_script.crm_form_script import get_form_script
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def get_lead(name):
|
def get_lead(name):
|
||||||
@ -16,4 +17,5 @@ def get_lead(name):
|
|||||||
|
|
||||||
lead["doctype_fields"] = get_doctype_fields("CRM Lead")
|
lead["doctype_fields"] = get_doctype_fields("CRM Lead")
|
||||||
lead["doctype"] = "CRM Lead"
|
lead["doctype"] = "CRM Lead"
|
||||||
return lead
|
lead["_form_script"] = get_form_script('CRM Lead')
|
||||||
|
return lead
|
||||||
|
|||||||
55
frontend/src/components/CustomActions.vue
Normal file
55
frontend/src/components/CustomActions.vue
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
<template>
|
||||||
|
<Button
|
||||||
|
v-for="action in normalActions"
|
||||||
|
:label="action.label"
|
||||||
|
@click="action.onClick()"
|
||||||
|
>
|
||||||
|
<template v-if="action.icon" #prefix>
|
||||||
|
<FeatherIcon :name="action.icon" class="h-4 w-4" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
<Dropdown v-if="groupedActions.length" :options="groupedActions">
|
||||||
|
<Button>
|
||||||
|
<template #icon>
|
||||||
|
<FeatherIcon name="more-horizontal" class="h-4 w-4" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
</Dropdown>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { Dropdown, FeatherIcon } from 'frappe-ui'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
actions: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const groupedActions = computed(() => {
|
||||||
|
const _actions = props.actions.filter((action) => action.group)
|
||||||
|
const groupedActions = {}
|
||||||
|
|
||||||
|
for (const action of _actions) {
|
||||||
|
if (!groupedActions[action.group]) {
|
||||||
|
groupedActions[action.group] = []
|
||||||
|
}
|
||||||
|
|
||||||
|
groupedActions[action.group].push(action)
|
||||||
|
}
|
||||||
|
|
||||||
|
let _groupedActions = [
|
||||||
|
...Object.keys(groupedActions).map((group) => ({
|
||||||
|
group,
|
||||||
|
items: groupedActions[group],
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
return _groupedActions
|
||||||
|
})
|
||||||
|
|
||||||
|
const normalActions = computed(() => {
|
||||||
|
return props.actions.filter((action) => !action.group)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@ -4,33 +4,10 @@
|
|||||||
<Breadcrumbs :items="breadcrumbs" />
|
<Breadcrumbs :items="breadcrumbs" />
|
||||||
</template>
|
</template>
|
||||||
<template #right-header>
|
<template #right-header>
|
||||||
<Dropdown
|
<CustomActions
|
||||||
:options="[
|
v-if="deal.data._customActions"
|
||||||
{
|
:actions="deal.data._customActions"
|
||||||
icon: 'trash-2',
|
/>
|
||||||
label: 'Delete',
|
|
||||||
onClick: () =>
|
|
||||||
$dialog({
|
|
||||||
title: 'Delete Deal',
|
|
||||||
message: 'Are you sure you want to delete this deal?',
|
|
||||||
actions: [
|
|
||||||
{
|
|
||||||
label: 'Delete',
|
|
||||||
theme: 'red',
|
|
||||||
variant: 'solid',
|
|
||||||
onClick(close) {
|
|
||||||
deleteDeal(deal.data.name)
|
|
||||||
close()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
]"
|
|
||||||
@click.stop
|
|
||||||
>
|
|
||||||
<Button icon="more-horizontal" />
|
|
||||||
</Dropdown>
|
|
||||||
<component :is="deal.data._assignedTo?.length == 1 ? 'Button' : 'div'">
|
<component :is="deal.data._assignedTo?.length == 1 ? 'Button' : 'div'">
|
||||||
<MultipleAvatar
|
<MultipleAvatar
|
||||||
:avatars="deal.data._assignedTo"
|
:avatars="deal.data._assignedTo"
|
||||||
@ -310,8 +287,13 @@ import Link from '@/components/Controls/Link.vue'
|
|||||||
import Section from '@/components/Section.vue'
|
import Section from '@/components/Section.vue'
|
||||||
import SectionFields from '@/components/SectionFields.vue'
|
import SectionFields from '@/components/SectionFields.vue'
|
||||||
import SLASection from '@/components/SLASection.vue'
|
import SLASection from '@/components/SLASection.vue'
|
||||||
import { openWebsite, createToast } from '@/utils'
|
import CustomActions from '@/components/CustomActions.vue'
|
||||||
import { usersStore } from '@/stores/users'
|
import {
|
||||||
|
openWebsite,
|
||||||
|
createToast,
|
||||||
|
setupAssignees,
|
||||||
|
setupCustomActions,
|
||||||
|
} from '@/utils'
|
||||||
import { contactsStore } from '@/stores/contacts'
|
import { contactsStore } from '@/stores/contacts'
|
||||||
import { organizationsStore } from '@/stores/organizations'
|
import { organizationsStore } from '@/stores/organizations'
|
||||||
import { statusesStore } from '@/stores/statuses'
|
import { statusesStore } from '@/stores/statuses'
|
||||||
@ -329,7 +311,6 @@ import {
|
|||||||
import { ref, computed, h } from 'vue'
|
import { ref, computed, h } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
const { getUser } = usersStore()
|
|
||||||
const { getContactByName, contacts } = contactsStore()
|
const { getContactByName, contacts } = contactsStore()
|
||||||
const { organizations, getOrganization } = organizationsStore()
|
const { organizations, getOrganization } = organizationsStore()
|
||||||
const { statusOptions, getDealStatus } = statusesStore()
|
const { statusOptions, getDealStatus } = statusesStore()
|
||||||
@ -348,12 +329,15 @@ const deal = createResource({
|
|||||||
cache: ['deal', props.dealId],
|
cache: ['deal', props.dealId],
|
||||||
auto: true,
|
auto: true,
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
let assignees = JSON.parse(data._assign) || []
|
setupAssignees(data)
|
||||||
data._assignedTo = assignees.map((user) => ({
|
setupCustomActions(data, {
|
||||||
name: user,
|
doc: data,
|
||||||
image: getUser(user).user_image,
|
$dialog,
|
||||||
label: getUser(user).full_name,
|
router,
|
||||||
}))
|
updateField,
|
||||||
|
deleteDoc: deleteDeal,
|
||||||
|
call,
|
||||||
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -4,33 +4,10 @@
|
|||||||
<Breadcrumbs :items="breadcrumbs" />
|
<Breadcrumbs :items="breadcrumbs" />
|
||||||
</template>
|
</template>
|
||||||
<template #right-header>
|
<template #right-header>
|
||||||
<Dropdown
|
<CustomActions
|
||||||
:options="[
|
v-if="lead.data._customActions"
|
||||||
{
|
:actions="lead.data._customActions"
|
||||||
icon: 'trash-2',
|
/>
|
||||||
label: 'Delete',
|
|
||||||
onClick: () =>
|
|
||||||
$dialog({
|
|
||||||
title: 'Delete Lead',
|
|
||||||
message: 'Are you sure you want to delete this lead?',
|
|
||||||
actions: [
|
|
||||||
{
|
|
||||||
label: 'Delete',
|
|
||||||
theme: 'red',
|
|
||||||
variant: 'solid',
|
|
||||||
onClick(close) {
|
|
||||||
deleteLead(lead.data.name)
|
|
||||||
close()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
]"
|
|
||||||
@click.stop
|
|
||||||
>
|
|
||||||
<Button icon="more-horizontal" />
|
|
||||||
</Dropdown>
|
|
||||||
<component :is="lead.data._assignedTo?.length == 1 ? 'Button' : 'div'">
|
<component :is="lead.data._assignedTo?.length == 1 ? 'Button' : 'div'">
|
||||||
<MultipleAvatar
|
<MultipleAvatar
|
||||||
:avatars="lead.data._assignedTo"
|
:avatars="lead.data._assignedTo"
|
||||||
@ -216,8 +193,13 @@ import MultipleAvatar from '@/components/MultipleAvatar.vue'
|
|||||||
import Section from '@/components/Section.vue'
|
import Section from '@/components/Section.vue'
|
||||||
import SectionFields from '@/components/SectionFields.vue'
|
import SectionFields from '@/components/SectionFields.vue'
|
||||||
import SLASection from '@/components/SLASection.vue'
|
import SLASection from '@/components/SLASection.vue'
|
||||||
import { openWebsite, createToast } from '@/utils'
|
import CustomActions from '@/components/CustomActions.vue'
|
||||||
import { usersStore } from '@/stores/users'
|
import {
|
||||||
|
openWebsite,
|
||||||
|
createToast,
|
||||||
|
setupAssignees,
|
||||||
|
setupCustomActions,
|
||||||
|
} from '@/utils'
|
||||||
import { contactsStore } from '@/stores/contacts'
|
import { contactsStore } from '@/stores/contacts'
|
||||||
import { organizationsStore } from '@/stores/organizations'
|
import { organizationsStore } from '@/stores/organizations'
|
||||||
import { statusesStore } from '@/stores/statuses'
|
import { statusesStore } from '@/stores/statuses'
|
||||||
@ -236,7 +218,6 @@ import {
|
|||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
const { getUser } = usersStore()
|
|
||||||
const { contacts } = contactsStore()
|
const { contacts } = contactsStore()
|
||||||
const { organizations, getOrganization } = organizationsStore()
|
const { organizations, getOrganization } = organizationsStore()
|
||||||
const { statusOptions, getLeadStatus } = statusesStore()
|
const { statusOptions, getLeadStatus } = statusesStore()
|
||||||
@ -255,12 +236,15 @@ const lead = createResource({
|
|||||||
cache: ['lead', props.leadId],
|
cache: ['lead', props.leadId],
|
||||||
auto: true,
|
auto: true,
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
let assignees = JSON.parse(data._assign) || []
|
setupAssignees(data)
|
||||||
data._assignedTo = assignees.map((user) => ({
|
setupCustomActions(data, {
|
||||||
name: user,
|
doc: data,
|
||||||
image: getUser(user).user_image,
|
$dialog,
|
||||||
label: getUser(user).full_name,
|
router,
|
||||||
}))
|
updateField,
|
||||||
|
deleteDoc: deleteLead,
|
||||||
|
call,
|
||||||
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import TaskStatusIcon from '@/components/Icons/TaskStatusIcon.vue'
|
import TaskStatusIcon from '@/components/Icons/TaskStatusIcon.vue'
|
||||||
import TaskPriorityIcon from '@/components/Icons/TaskPriorityIcon.vue'
|
import TaskPriorityIcon from '@/components/Icons/TaskPriorityIcon.vue'
|
||||||
import { useDateFormat, useTimeAgo } from '@vueuse/core'
|
import { useDateFormat, useTimeAgo } from '@vueuse/core'
|
||||||
|
import { usersStore } from '@/stores/users'
|
||||||
import { toast } from 'frappe-ui'
|
import { toast } from 'frappe-ui'
|
||||||
import { h, computed } from 'vue'
|
import { h } from 'vue'
|
||||||
|
|
||||||
export function createToast(options) {
|
export function createToast(options) {
|
||||||
toast({
|
toast({
|
||||||
@ -112,3 +113,19 @@ export function validateEmail(email) {
|
|||||||
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
|
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
|
||||||
return regExp.test(email)
|
return regExp.test(email)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setupAssignees(data) {
|
||||||
|
let { getUser } = usersStore()
|
||||||
|
let assignees = JSON.parse(data._assign) || []
|
||||||
|
data._assignedTo = assignees.map((user) => ({
|
||||||
|
name: user,
|
||||||
|
image: getUser(user).user_image,
|
||||||
|
label: getUser(user).full_name,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setupCustomActions(data, obj) {
|
||||||
|
let script = new Function(data._form_script + '\nreturn setupForm')()
|
||||||
|
let formScript = script(obj)
|
||||||
|
data._customActions = formScript?.actions || []
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user