Merge pull request #45 from shariquerik/custom-actions

feat: Custom Actions on Lead/Deal Page
This commit is contained in:
Shariq Ansari 2023-12-28 17:37:21 +05:30 committed by GitHub
commit d7796c040c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 251 additions and 75 deletions

View File

@ -2,6 +2,7 @@ import frappe
from frappe import _
from crm.api.doc import get_doctype_fields
from crm.fcrm.doctype.crm_form_script.crm_form_script import get_form_script
@frappe.whitelist()
def get_deal(name):
@ -27,5 +28,6 @@ def get_deal(name):
)
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

View 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) {
// },
// });

View 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": []
}

View 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

View 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

View File

@ -2,6 +2,7 @@ import frappe
from frappe import _
from crm.api.doc import get_doctype_fields
from crm.fcrm.doctype.crm_form_script.crm_form_script import get_form_script
@frappe.whitelist()
def get_lead(name):
@ -16,4 +17,5 @@ def get_lead(name):
lead["doctype_fields"] = get_doctype_fields("CRM Lead")
lead["doctype"] = "CRM Lead"
return lead
lead["_form_script"] = get_form_script('CRM Lead')
return lead

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

View File

@ -4,33 +4,10 @@
<Breadcrumbs :items="breadcrumbs" />
</template>
<template #right-header>
<Dropdown
:options="[
{
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>
<CustomActions
v-if="deal.data._customActions"
:actions="deal.data._customActions"
/>
<component :is="deal.data._assignedTo?.length == 1 ? 'Button' : 'div'">
<MultipleAvatar
:avatars="deal.data._assignedTo"
@ -310,8 +287,13 @@ import Link from '@/components/Controls/Link.vue'
import Section from '@/components/Section.vue'
import SectionFields from '@/components/SectionFields.vue'
import SLASection from '@/components/SLASection.vue'
import { openWebsite, createToast } from '@/utils'
import { usersStore } from '@/stores/users'
import CustomActions from '@/components/CustomActions.vue'
import {
openWebsite,
createToast,
setupAssignees,
setupCustomActions,
} from '@/utils'
import { contactsStore } from '@/stores/contacts'
import { organizationsStore } from '@/stores/organizations'
import { statusesStore } from '@/stores/statuses'
@ -329,7 +311,6 @@ import {
import { ref, computed, h } from 'vue'
import { useRouter } from 'vue-router'
const { getUser } = usersStore()
const { getContactByName, contacts } = contactsStore()
const { organizations, getOrganization } = organizationsStore()
const { statusOptions, getDealStatus } = statusesStore()
@ -348,12 +329,15 @@ const deal = createResource({
cache: ['deal', props.dealId],
auto: true,
onSuccess: (data) => {
let assignees = JSON.parse(data._assign) || []
data._assignedTo = assignees.map((user) => ({
name: user,
image: getUser(user).user_image,
label: getUser(user).full_name,
}))
setupAssignees(data)
setupCustomActions(data, {
doc: data,
$dialog,
router,
updateField,
deleteDoc: deleteDeal,
call,
})
},
})

View File

@ -4,33 +4,10 @@
<Breadcrumbs :items="breadcrumbs" />
</template>
<template #right-header>
<Dropdown
:options="[
{
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>
<CustomActions
v-if="lead.data._customActions"
:actions="lead.data._customActions"
/>
<component :is="lead.data._assignedTo?.length == 1 ? 'Button' : 'div'">
<MultipleAvatar
:avatars="lead.data._assignedTo"
@ -216,8 +193,13 @@ import MultipleAvatar from '@/components/MultipleAvatar.vue'
import Section from '@/components/Section.vue'
import SectionFields from '@/components/SectionFields.vue'
import SLASection from '@/components/SLASection.vue'
import { openWebsite, createToast } from '@/utils'
import { usersStore } from '@/stores/users'
import CustomActions from '@/components/CustomActions.vue'
import {
openWebsite,
createToast,
setupAssignees,
setupCustomActions,
} from '@/utils'
import { contactsStore } from '@/stores/contacts'
import { organizationsStore } from '@/stores/organizations'
import { statusesStore } from '@/stores/statuses'
@ -236,7 +218,6 @@ import {
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
const { getUser } = usersStore()
const { contacts } = contactsStore()
const { organizations, getOrganization } = organizationsStore()
const { statusOptions, getLeadStatus } = statusesStore()
@ -255,12 +236,15 @@ const lead = createResource({
cache: ['lead', props.leadId],
auto: true,
onSuccess: (data) => {
let assignees = JSON.parse(data._assign) || []
data._assignedTo = assignees.map((user) => ({
name: user,
image: getUser(user).user_image,
label: getUser(user).full_name,
}))
setupAssignees(data)
setupCustomActions(data, {
doc: data,
$dialog,
router,
updateField,
deleteDoc: deleteLead,
call,
})
},
})

View File

@ -1,8 +1,9 @@
import TaskStatusIcon from '@/components/Icons/TaskStatusIcon.vue'
import TaskPriorityIcon from '@/components/Icons/TaskPriorityIcon.vue'
import { useDateFormat, useTimeAgo } from '@vueuse/core'
import { usersStore } from '@/stores/users'
import { toast } from 'frappe-ui'
import { h, computed } from 'vue'
import { h } from 'vue'
export function createToast(options) {
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,}))$/
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 || []
}