Merge pull request #485 from shariquerik/child-table-support

This commit is contained in:
Shariq Ansari 2024-12-29 21:35:05 +05:30 committed by GitHub
commit 2be808b511
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 1129 additions and 243 deletions

View File

@ -1,10 +1,11 @@
import frappe
import json
import frappe
from frappe import _
from frappe.model.document import get_controller
from frappe.model import no_value_fields
from pypika import Criterion
from frappe.model.document import get_controller
from frappe.utils import make_filter_tuple
from pypika import Criterion
from crm.api.views import get_views
from crm.fcrm.doctype.crm_form_script.crm_form_script import get_form_script
@ -557,6 +558,9 @@ def get_fields_meta(doctype, restricted_fieldtypes=None, as_array=False):
fields_meta = {}
for field in fields:
fields_meta[field.get("fieldname")] = field
if field.get("fieldtype") == "Table":
_fields = frappe.get_meta(field.get("options")).fields
fields_meta[field.get("fieldname")] = {"df": field, "fields": _fields}
return fields_meta
@ -672,7 +676,7 @@ def get_assigned_users(doctype, name, default_assigned_to=None):
@frappe.whitelist()
def get_fields(doctype: str, allow_all_fieldtypes: bool = False):
not_allowed_fieldtypes = list(frappe.model.no_value_fields) + ["Read Only"]
not_allowed_fieldtypes = [*list(frappe.model.no_value_fields), "Read Only"]
if allow_all_fieldtypes:
not_allowed_fieldtypes = []
fields = frappe.get_meta(doctype).fields

View File

@ -1,6 +1,5 @@
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import json
import frappe
from frappe import _
@ -8,7 +7,9 @@ from frappe.desk.form.assign_to import add as assign
from frappe.model.document import Document
from crm.fcrm.doctype.crm_service_level_agreement.utils import get_sla
from crm.fcrm.doctype.crm_status_change_log.crm_status_change_log import add_status_change_log
from crm.fcrm.doctype.crm_status_change_log.crm_status_change_log import (
add_status_change_log,
)
class CRMDeal(Document):
@ -94,19 +95,26 @@ class CRMDeal(Document):
shared_with = [d.user for d in docshares] + [agent]
for user in shared_with:
if user == agent and not frappe.db.exists("DocShare", {"user": agent, "share_name": self.name, "share_doctype": self.doctype}):
if user == agent and not frappe.db.exists(
"DocShare",
{"user": agent, "share_name": self.name, "share_doctype": self.doctype},
):
frappe.share.add_docshare(
self.doctype, self.name, agent, write=1, flags={"ignore_share_permission": True}
self.doctype,
self.name,
agent,
write=1,
flags={"ignore_share_permission": True},
)
elif user != agent:
frappe.share.remove(self.doctype, self.name, user)
def set_sla(self):
"""
Find an SLA to apply to the deal.
"""
if self.sla: return
if self.sla:
return
sla = get_sla(self)
if not sla:
@ -129,48 +137,48 @@ class CRMDeal(Document):
def default_list_data():
columns = [
{
'label': 'Organization',
'type': 'Link',
'key': 'organization',
'options': 'CRM Organization',
'width': '11rem',
"label": "Organization",
"type": "Link",
"key": "organization",
"options": "CRM Organization",
"width": "11rem",
},
{
'label': 'Amount',
'type': 'Currency',
'key': 'annual_revenue',
'align': 'right',
'width': '9rem',
"label": "Annual Revenue",
"type": "Currency",
"key": "annual_revenue",
"align": "right",
"width": "9rem",
},
{
'label': 'Status',
'type': 'Select',
'key': 'status',
'width': '10rem',
"label": "Status",
"type": "Select",
"key": "status",
"width": "10rem",
},
{
'label': 'Email',
'type': 'Data',
'key': 'email',
'width': '12rem',
"label": "Email",
"type": "Data",
"key": "email",
"width": "12rem",
},
{
'label': 'Mobile No',
'type': 'Data',
'key': 'mobile_no',
'width': '11rem',
"label": "Mobile No",
"type": "Data",
"key": "mobile_no",
"width": "11rem",
},
{
'label': 'Assigned To',
'type': 'Text',
'key': '_assign',
'width': '10rem',
"label": "Assigned To",
"type": "Text",
"key": "_assign",
"width": "10rem",
},
{
'label': 'Last Modified',
'type': 'Datetime',
'key': 'modified',
'width': '8rem',
"label": "Last Modified",
"type": "Datetime",
"key": "modified",
"width": "8rem",
},
]
rows = [
@ -189,16 +197,17 @@ class CRMDeal(Document):
"modified",
"_assign",
]
return {'columns': columns, 'rows': rows}
return {"columns": columns, "rows": rows}
@staticmethod
def default_kanban_settings():
return {
"column_field": "status",
"title_field": "organization",
"kanban_fields": '["annual_revenue", "email", "mobile_no", "_assign", "modified"]'
"kanban_fields": '["annual_revenue", "email", "mobile_no", "_assign", "modified"]',
}
@frappe.whitelist()
def add_contact(deal, contact):
if not frappe.has_permission("CRM Deal", "write", deal):
@ -209,6 +218,7 @@ def add_contact(deal, contact):
deal.save()
return True
@frappe.whitelist()
def remove_contact(deal, contact):
if not frappe.has_permission("CRM Deal", "write", deal):
@ -219,6 +229,7 @@ def remove_contact(deal, contact):
deal.save()
return True
@frappe.whitelist()
def set_primary_contact(deal, contact):
if not frappe.has_permission("CRM Deal", "write", deal):
@ -229,11 +240,14 @@ def set_primary_contact(deal, contact):
deal.save()
return True
def create_organization(doc):
if not doc.get("organization_name"):
return
existing_organization = frappe.db.exists("CRM Organization", {"organization_name": doc.get("organization_name")})
existing_organization = frappe.db.exists(
"CRM Organization", {"organization_name": doc.get("organization_name")}
)
if existing_organization:
return existing_organization
@ -250,6 +264,7 @@ def create_organization(doc):
organization.insert(ignore_permissions=True)
return organization.name
def contact_exists(doc):
email_exist = frappe.db.exists("Contact Email", {"email_id": doc.get("email")})
mobile_exist = frappe.db.exists("Contact Phone", {"phone": doc.get("mobile_no")})
@ -262,6 +277,7 @@ def contact_exists(doc):
return False
def create_contact(doc):
existing_contact = contact_exists(doc)
if existing_contact:
@ -288,18 +304,23 @@ def create_contact(doc):
return contact.name
@frappe.whitelist()
def create_deal(args):
deal = frappe.new_doc("CRM Deal")
contact = args.get("contact")
if not contact and (args.get("first_name") or args.get("last_name") or args.get("email") or args.get("mobile_no")):
if not contact and (
args.get("first_name") or args.get("last_name") or args.get("email") or args.get("mobile_no")
):
contact = create_contact(args)
deal.update({
"organization": args.get("organization") or create_organization(args),
"contacts": [{"contact": contact, "is_primary": 1}] if contact else [],
})
deal.update(
{
"organization": args.get("organization") or create_organization(args),
"contacts": [{"contact": contact, "is_primary": 1}] if contact else [],
}
)
args.pop("organization", None)

View File

@ -27,7 +27,7 @@
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Type",
"options": "Quick Entry\nSide Panel\nData Fields"
"options": "Quick Entry\nSide Panel\nData Fields\nGrid Row"
},
{
"fieldname": "section_break_ttpm",
@ -46,7 +46,7 @@
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-12-05 13:29:37.021412",
"modified": "2024-12-29 12:58:54.280569",
"modified_by": "Administrator",
"module": "FCRM",
"name": "CRM Fields Layout",

View File

@ -1,15 +1,16 @@
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import json
import frappe
from frappe import _
from frappe.desk.form.assign_to import add as assign
from frappe.model.document import Document
from frappe.utils import has_gravatar, validate_email_address
from crm.fcrm.doctype.crm_service_level_agreement.utils import get_sla
from crm.fcrm.doctype.crm_status_change_log.crm_status_change_log import add_status_change_log
from crm.fcrm.doctype.crm_status_change_log.crm_status_change_log import (
add_status_change_log,
)
class CRMLead(Document):
@ -37,7 +38,15 @@ class CRMLead(Document):
def set_full_name(self):
if self.first_name:
self.lead_name = " ".join(
filter(None, [self.salutation, self.first_name, self.middle_name, self.last_name])
filter(
None,
[
self.salutation,
self.first_name,
self.middle_name,
self.last_name,
],
)
)
def set_lead_name(self):
@ -92,9 +101,16 @@ class CRMLead(Document):
shared_with = [d.user for d in docshares] + [agent]
for user in shared_with:
if user == agent and not frappe.db.exists("DocShare", {"user": agent, "share_name": self.name, "share_doctype": self.doctype}):
if user == agent and not frappe.db.exists(
"DocShare",
{"user": agent, "share_name": self.name, "share_doctype": self.doctype},
):
frappe.share.add_docshare(
self.doctype, self.name, agent, write=1, flags={"ignore_share_permission": True}
self.doctype,
self.name,
agent,
write=1,
flags={"ignore_share_permission": True},
)
elif user != agent:
frappe.share.remove(self.doctype, self.name, user)
@ -188,8 +204,36 @@ class CRMLead(Document):
"lead_owner": "deal_owner",
}
restricted_fieldtypes = ["Tab Break", "Section Break", "Column Break", "HTML", "Button", "Attach", "Table"]
restricted_map_fields = ["name", "naming_series", "creation", "owner", "modified", "modified_by", "idx", "docstatus", "status", "email", "mobile_no", "phone", "sla", "sla_status", "response_by", "first_response_time", "first_responded_on", "communication_status", "sla_creation"]
restricted_fieldtypes = [
"Tab Break",
"Section Break",
"Column Break",
"HTML",
"Button",
"Attach",
"Table",
]
restricted_map_fields = [
"name",
"naming_series",
"creation",
"owner",
"modified",
"modified_by",
"idx",
"docstatus",
"status",
"email",
"mobile_no",
"phone",
"sla",
"sla_status",
"response_by",
"first_response_time",
"first_responded_on",
"communication_status",
"sla_creation",
]
for field in self.meta.fields:
if field.fieldtype in restricted_fieldtypes:
@ -222,7 +266,7 @@ class CRMLead(Document):
"sla_status": self.sla_status,
"communication_status": self.communication_status,
"first_response_time": self.first_response_time,
"first_responded_on": self.first_responded_on
"first_responded_on": self.first_responded_on,
}
)
@ -233,7 +277,8 @@ class CRMLead(Document):
"""
Find an SLA to apply to the lead.
"""
if self.sla: return
if self.sla:
return
sla = get_sla(self)
if not sla:
@ -263,47 +308,47 @@ class CRMLead(Document):
def default_list_data():
columns = [
{
'label': 'Name',
'type': 'Data',
'key': 'lead_name',
'width': '12rem',
"label": "Name",
"type": "Data",
"key": "lead_name",
"width": "12rem",
},
{
'label': 'Organization',
'type': 'Link',
'key': 'organization',
'options': 'CRM Organization',
'width': '10rem',
"label": "Organization",
"type": "Link",
"key": "organization",
"options": "CRM Organization",
"width": "10rem",
},
{
'label': 'Status',
'type': 'Select',
'key': 'status',
'width': '8rem',
"label": "Status",
"type": "Select",
"key": "status",
"width": "8rem",
},
{
'label': 'Email',
'type': 'Data',
'key': 'email',
'width': '12rem',
"label": "Email",
"type": "Data",
"key": "email",
"width": "12rem",
},
{
'label': 'Mobile No',
'type': 'Data',
'key': 'mobile_no',
'width': '11rem',
"label": "Mobile No",
"type": "Data",
"key": "mobile_no",
"width": "11rem",
},
{
'label': 'Assigned To',
'type': 'Text',
'key': '_assign',
'width': '10rem',
"label": "Assigned To",
"type": "Text",
"key": "_assign",
"width": "10rem",
},
{
'label': 'Last Modified',
'type': 'Datetime',
'key': 'modified',
'width': '8rem',
"label": "Last Modified",
"type": "Datetime",
"key": "modified",
"width": "8rem",
},
]
rows = [
@ -323,20 +368,22 @@ class CRMLead(Document):
"_assign",
"image",
]
return {'columns': columns, 'rows': rows}
return {"columns": columns, "rows": rows}
@staticmethod
def default_kanban_settings():
return {
"column_field": "status",
"title_field": "lead_name",
"kanban_fields": '["organization", "email", "mobile_no", "_assign", "modified"]'
"kanban_fields": '["organization", "email", "mobile_no", "_assign", "modified"]',
}
@frappe.whitelist()
def convert_to_deal(lead, doc=None):
if not (doc and doc.flags.get("ignore_permissions")) and not frappe.has_permission("CRM Lead", "write", lead):
if not (doc and doc.flags.get("ignore_permissions")) and not frappe.has_permission(
"CRM Lead", "write", lead
):
frappe.throw(_("Not allowed to convert Lead to Deal"), frappe.PermissionError)
lead = frappe.get_cached_doc("CRM Lead", lead)

@ -1 +1 @@
Subproject commit 5a4f3c8d4f12efba37b9a83a51a59b53fa758be0
Subproject commit 46086c524bc218d989c68ca54cd13a37e693fab9

View File

@ -14,7 +14,7 @@
"@vueuse/core": "^10.3.0",
"@vueuse/integrations": "^10.3.0",
"feather-icons": "^4.28.0",
"frappe-ui": "^0.1.91",
"frappe-ui": "^0.1.94",
"gemoji": "^8.1.0",
"lodash": "^4.17.21",
"mime": "^4.0.1",

View File

@ -31,7 +31,7 @@
<LoadingIndicator class="h-6 w-6" />
<span>{{ __('Loading...') }}</span>
</div>
<div v-else>
<div v-else class="pb-8">
<FieldLayout
v-if="tabs.data"
:tabs="tabs.data"
@ -86,7 +86,7 @@ const data = createDocumentResource({
createToast({
title: 'Data Updated',
icon: 'check',
iconClasses: 'text-green-600',
iconClasses: 'text-ink-green-3',
})
},
onError: (err) => {

View File

@ -0,0 +1,366 @@
<template>
<div class="flex flex-col text-base">
<div v-if="label" class="mb-1.5 text-sm text-ink-gray-5">
{{ __(label) }}
</div>
<div
v-if="fields?.length"
class="rounded border border-outline-gray-modals"
>
<!-- Header -->
<div
class="grid-header flex items-center rounded-t-[7px] bg-surface-gray-2 text-ink-gray-5 truncate"
>
<div
class="inline-flex items-center justify-center border-r border-outline-gray-2 h-8 p-2 w-12"
>
<Checkbox
class="cursor-pointer duration-300"
:modelValue="allRowsSelected"
@click.stop="toggleSelectAllRows($event.target.checked)"
/>
</div>
<div
class="inline-flex items-center justify-center border-r border-outline-gray-2 py-2 px-1 w-12"
>
{{ __('No') }}
</div>
<div
class="grid w-full truncate"
:style="{ gridTemplateColumns: gridTemplateColumns }"
>
<div
v-for="field in fields"
class="border-r border-outline-gray-2 p-2 truncate"
:key="field.name"
:title="field.label"
>
{{ __(field.label) }}
</div>
</div>
<div class="w-12">
<Button
class="flex w-full items-center justify-center rounded !bg-surface-gray-2 border-0"
variant="outline"
@click="showGridFieldsEditorModal = true"
>
<FeatherIcon name="settings" class="h-4 w-4 text-ink-gray-7" />
</Button>
</div>
</div>
<!-- Rows -->
<template v-if="rows.length">
<Draggable class="w-full" v-model="rows" group="rows" item-key="name">
<template #item="{ element: row, index }">
<div
class="grid-row flex cursor-pointer items-center border-b border-outline-gray-modals bg-surface-modals last:rounded-b last:border-b-0"
>
<div
class="inline-flex h-9.5 items-center justify-center border-r border-outline-gray-modals p-2 w-12"
>
<Checkbox
class="cursor-pointer duration-300"
:modelValue="selectedRows.has(row.name)"
@click.stop="toggleSelectRow(row)"
/>
</div>
<div
class="flex h-9.5 items-center justify-center border-r border-outline-gray-modals py-2 px-1 text-sm text-ink-gray-8 w-12"
>
{{ index + 1 }}
</div>
<div
class="grid w-full h-9.5"
:style="{ gridTemplateColumns: gridTemplateColumns }"
>
<div
class="border-r border-outline-gray-modals h-full"
v-for="field in fields"
:key="field.name"
>
<Link
v-if="field.type === 'Link'"
class="text-sm text-ink-gray-8"
v-model="row[field.name]"
:doctype="field.options"
:filters="field.filters"
/>
<div
v-else-if="field.type === 'Check'"
class="flex h-full justify-center items-center"
>
<Checkbox
class="cursor-pointer duration-300"
v-model="row[field.name]"
/>
</div>
<DatePicker
v-else-if="field.type === 'Date'"
v-model="row[field.name]"
icon-left=""
variant="outline"
:formatter="(date) => getFormat(date, '', true)"
input-class="border-none text-sm text-ink-gray-8"
/>
<DateTimePicker
v-else-if="field.type === 'Datetime'"
v-model="row[field.name]"
icon-left=""
variant="outline"
:formatter="(date) => getFormat(date, '', true, true)"
input-class="border-none text-sm text-ink-gray-8"
/>
<FormControl
v-else-if="
['Small Text', 'Text', 'Long Text', 'Code'].includes(
field.type,
)
"
rows="1"
type="textarea"
variant="outline"
v-model="row[field.name]"
/>
<FormControl
v-else-if="['Int'].includes(field.type)"
type="number"
variant="outline"
v-model="row[field.name]"
/>
<FormControl
v-else-if="field.type === 'Select'"
class="text-sm text-ink-gray-8"
type="select"
variant="outline"
v-model="row[field.name]"
:options="field.options"
/>
<FormControl
v-else
class="text-sm text-ink-gray-8"
type="text"
variant="outline"
v-model="row[field.name]"
:options="field.options"
/>
</div>
</div>
<div class="edit-row w-12">
<Button
class="flex w-full items-center justify-center rounded border-0"
variant="outline"
@click="showRowList[index] = true"
>
<EditIcon class="h-4 w-4 text-ink-gray-7" />
</Button>
</div>
<GridRowModal
v-if="showRowList[index]"
v-model="showRowList[index]"
v-model:showGridRowFieldsModal="showGridRowFieldsModal"
:index="index"
:data="row"
:doctype="doctype"
/>
</div>
</template>
</Draggable>
</template>
<div
v-else
class="flex flex-col items-center rounded p-5 text-sm text-ink-gray-5"
>
{{ __('No Data') }}
</div>
</div>
<div v-if="fields?.length" class="mt-2 flex flex-row gap-2">
<Button
v-if="showDeleteBtn"
:label="__('Delete')"
variant="solid"
theme="red"
@click="deleteRows"
/>
<Button :label="__('Add Row')" @click="addRow" />
</div>
</div>
<GridRowFieldsModal
v-if="showGridRowFieldsModal"
v-model="showGridRowFieldsModal"
:doctype="doctype"
/>
<GridFieldsEditorModal
v-if="showGridFieldsEditorModal"
v-model="showGridFieldsEditorModal"
:doctype="doctype"
:parentDoctype="parentDoctype"
/>
</template>
<script setup>
import GridFieldsEditorModal from '@/components/Controls/GridFieldsEditorModal.vue'
import GridRowFieldsModal from '@/components/Controls/GridRowFieldsModal.vue'
import GridRowModal from '@/components/Controls/GridRowModal.vue'
import EditIcon from '@/components/Icons/EditIcon.vue'
import Link from '@/components/Controls/Link.vue'
import { getRandom, getFormat } from '@/utils'
import { getMeta } from '@/stores/meta'
import {
FeatherIcon,
FormControl,
Checkbox,
DateTimePicker,
DatePicker,
} from 'frappe-ui'
import Draggable from 'vuedraggable'
import { ref, reactive, computed } from 'vue'
const props = defineProps({
label: {
type: String,
default: '',
},
doctype: {
type: String,
required: true,
},
parentDoctype: {
type: String,
required: true,
},
})
const { getGridSettings, getFields } = getMeta(props.doctype)
const rows = defineModel()
const showRowList = ref(new Array(rows.value.length).fill(false))
const selectedRows = reactive(new Set())
const showGridFieldsEditorModal = ref(false)
const showGridRowFieldsModal = ref(false)
const fields = computed(() => {
let gridSettings = getGridSettings(props.parentDoctype)
let gridFields = getFields()
if (gridSettings.length) {
let d = gridSettings.map((gs) =>
getFieldObj(gridFields.find((f) => f.fieldname === gs.fieldname)),
)
return d
}
return gridFields?.map((f) => getFieldObj(f)) || []
})
function getFieldObj(field) {
return {
label: field.label,
name: field.fieldname,
type: field.fieldtype,
options: field.options,
in_list_view: field.in_list_view,
}
}
const gridTemplateColumns = computed(() => {
if (!fields.value?.length) return '1fr'
// for the checkbox & sr no. columns
let gridSettings = getGridSettings(props.parentDoctype)
if (gridSettings.length) {
return gridSettings.map((gs) => `minmax(0, ${gs.columns || 2}fr)`).join(' ')
}
return fields.value.map(() => `minmax(0, 2fr)`).join(' ')
})
const allRowsSelected = computed(() => {
if (!rows.value.length) return false
return rows.value.length === selectedRows.size
})
const showDeleteBtn = computed(() => selectedRows.size > 0)
const toggleSelectAllRows = (iSelected) => {
if (iSelected) {
rows.value.forEach((row) => selectedRows.add(row.name))
} else {
selectedRows.clear()
}
}
const toggleSelectRow = (row) => {
if (selectedRows.has(row.name)) {
selectedRows.delete(row.name)
} else {
selectedRows.add(row.name)
}
}
const addRow = () => {
const newRow = {}
fields.value?.forEach((field) => {
if (field.type === 'Check') newRow[field.name] = false
else newRow[field.name] = ''
})
newRow.name = getRandom(10)
showRowList.value.push(false)
newRow['__islocal'] = true
rows.value.push(newRow)
}
const deleteRows = () => {
rows.value = rows.value.filter((row) => !selectedRows.has(row.name))
showRowList.value.pop()
selectedRows.clear()
}
</script>
<style scoped>
/* For Input fields */
:deep(.grid-row input:not([type='checkbox'])),
:deep(.grid-row textarea) {
border: none;
border-radius: 0;
height: 38px;
}
:deep(.grid-row input:focus),
:deep(.grid-row input:hover),
:deep(.grid-row textarea:focus),
:deep(.grid-row textarea:hover) {
box-shadow: none;
}
:deep(.grid-row input:focus-within) :deep(.grid-row textarea:focus-within) {
border: 1px solid var(--outline-gray-2);
}
/* For select field */
:deep(.grid-row select) {
border: none;
border-radius: 0;
height: 38px;
}
/* For Autocomplete */
:deep(.grid-row button) {
border: none;
border-radius: 0;
background-color: var(--surface-white);
height: 38px;
}
:deep(.grid-row .edit-row button) {
border-bottom-right-radius: 7px;
}
:deep(.grid-row button:focus) :deep(.grid-row button:hover) {
box-shadow: none;
background-color: var(--surface-white);
}
:deep(.grid-row button:focus-within) {
border: 1px solid var(--outline-gray-2);
}
</style>

View File

@ -0,0 +1,193 @@
<template>
<Dialog v-model="show">
<template #body-title>
<h3
class="flex items-center gap-2 text-2xl font-semibold leading-6 text-ink-gray-9"
>
<div>{{ __('Edit Grid Fields Layout') }}</div>
<Badge
v-if="dirty"
:label="__('Not Saved')"
variant="subtle"
theme="orange"
/>
</h3>
</template>
<template #body-content>
<div class="mt-4">
<div class="text-base text-ink-gray-8 mb-2">
{{ __('Fields Order') }}
</div>
<Draggable
v-if="oldFields?.length"
:list="fields"
@end="reorder"
group="fields"
item-key="fieldname"
class="flex flex-col gap-1"
>
<template #item="{ element: field }">
<div
class="px-1 py-0.5 bg-surface-gray-2 border border-outline-gray-modals rounded text-base text-ink-gray-8 flex items-center justify-between gap-2"
>
<div class="flex items-center gap-2">
<DragVerticalIcon class="h-3.5 cursor-grab" />
<div>{{ field.label }}</div>
</div>
<div class="flex items-center gap-2">
<TextInput
variant="outline"
type="number"
v-model="field.columns"
class="w-20"
/>
<Button variant="ghost" icon="x" @click="removeField(field)" />
</div>
</div>
</template>
</Draggable>
<Autocomplete
v-if="dropdownFields?.length"
value=""
:options="dropdownFields"
@change="(e) => addField(e)"
>
<template #target="{ togglePopover }">
<Button
class="w-full mt-2"
@click="togglePopover()"
:label="__('Add Field')"
>
<template #prefix>
<FeatherIcon name="plus" class="h-4" />
</template>
</Button>
</template>
<template #item-label="{ option }">
<div class="flex flex-col gap-1 text-ink-gray-9">
<div>{{ option.label }}</div>
<div class="text-ink-gray-4 text-sm">
{{ `${option.fieldname} - ${option.fieldtype}` }}
</div>
</div>
</template>
</Autocomplete>
<ErrorMessage class="mt-3" v-if="error" :message="error" />
</div>
</template>
<template #actions>
<div class="flex flex-col gap-2">
<Button
v-if="dirty"
class="w-full"
:label="__('Reset')"
@click="reset"
/>
<Button
class="w-full"
:label="__('Save')"
variant="solid"
@click="update"
:loading="loading"
:disabled="!dirty"
/>
</div>
</template>
</Dialog>
</template>
<script setup>
import DragVerticalIcon from '@/components/Icons/DragVerticalIcon.vue'
import Autocomplete from '@/components/frappe-ui/Autocomplete.vue'
import { getMeta } from '@/stores/meta'
import Draggable from 'vuedraggable'
import { Dialog, ErrorMessage } from 'frappe-ui'
import { ref, computed } from 'vue'
const props = defineProps({
doctype: String,
parentDoctype: String,
})
const { userSettings, getFields, getGridSettings, saveUserSettings } = getMeta(
props.doctype,
)
const show = defineModel()
const loading = ref(false)
const error = ref(null)
const dirty = computed(() => {
return JSON.stringify(fields.value) !== JSON.stringify(oldFields.value)
})
const oldFields = computed(() => {
let _fields = getFields()
let gridSettings = getGridSettings(props.parentDoctype)
if (gridSettings.length) {
return gridSettings.map((field) => {
let f = _fields.find((f) => f.fieldname === field.fieldname)
if (f) {
f.columns = field.columns
return fieldObj(f)
}
})
}
return _fields?.filter((field) => field.in_list_view).map((f) => fieldObj(f))
})
const fields = ref(JSON.parse(JSON.stringify(oldFields.value || [])))
const dropdownFields = computed(() => {
return getFields()?.filter(
(field) => !fields.value.find((f) => f.fieldname === field.fieldname),
)
})
function reset() {
fields.value = JSON.parse(JSON.stringify(oldFields.value || []))
}
function addField(field) {
fields.value.push(fieldObj(field))
}
function removeField(field) {
const index = fields.value.findIndex((f) => f.fieldname === field.fieldname)
fields.value.splice(index, 1)
}
function update() {
loading.value = true
let updateFields = fields.value.map((field) => {
return {
fieldname: field.fieldname,
columns: field.columns,
}
})
if (updateFields.length === 0) {
error.value = __('At least one field is required')
return
}
saveUserSettings(props.parentDoctype, 'GridView', updateFields, () => {
loading.value = false
show.value = false
userSettings[props.parentDoctype]['GridView'][props.doctype] = updateFields
})
}
function fieldObj(field) {
return {
label: field.label,
fieldname: field.fieldname,
fieldtype: field.fieldtype,
options: field.options,
in_list_view: field.in_list_view,
columns: field.columns || 2,
}
}
</script>

View File

@ -0,0 +1,125 @@
<template>
<Dialog v-model="show" :options="{ size: '3xl' }">
<template #body-title>
<h3
class="flex items-center gap-2 text-2xl font-semibold leading-6 text-ink-gray-9"
>
<div>{{ __('Edit Grid Row Fields Layout') }}</div>
<Badge
v-if="dirty"
:label="__('Not Saved')"
variant="subtle"
theme="orange"
/>
</h3>
</template>
<template #body-content>
<div class="flex flex-col gap-3">
<div class="flex justify-between gap-2">
<Button
:label="preview ? __('Hide preview') : __('Show preview')"
@click="preview = !preview"
/>
<div class="flex flex-row-reverse gap-2">
<Button
:loading="loading"
:label="__('Save')"
variant="solid"
@click="saveChanges"
/>
<Button :label="__('Reset')" @click="reload" />
</div>
</div>
<div v-if="tabs?.data">
<FieldLayoutEditor
v-if="!preview"
:tabs="tabs.data"
:doctype="_doctype"
/>
<FieldLayout v-else :tabs="tabs.data" :data="{}" :modal="true" />
</div>
</div>
</template>
</Dialog>
</template>
<script setup>
import FieldLayout from '@/components/FieldLayout.vue'
import FieldLayoutEditor from '@/components/FieldLayoutEditor.vue'
import { useDebounceFn } from '@vueuse/core'
import { capture } from '@/telemetry'
import { Dialog, Badge, call, createResource } from 'frappe-ui'
import { ref, watch, onMounted, nextTick } from 'vue'
const props = defineProps({
doctype: {
type: String,
default: 'CRM Lead',
},
})
const emit = defineEmits(['reload'])
const show = defineModel()
const _doctype = ref(props.doctype)
const loading = ref(false)
const dirty = ref(false)
const preview = ref(false)
function getParams() {
return { doctype: _doctype.value, type: 'Grid Row' }
}
const tabs = createResource({
url: 'crm.fcrm.doctype.crm_fields_layout.crm_fields_layout.get_fields_layout',
cache: ['GridRowFieldsModal', _doctype.value],
params: getParams(),
onSuccess(data) {
tabs.originalData = JSON.parse(JSON.stringify(data))
},
})
watch(
() => tabs?.data,
() => {
dirty.value =
JSON.stringify(tabs?.data) !== JSON.stringify(tabs?.originalData)
},
{ deep: true },
)
onMounted(() => useDebounceFn(reload, 100)())
function reload() {
nextTick(() => {
tabs.params = getParams()
tabs.reload()
})
}
function saveChanges() {
let _tabs = JSON.parse(JSON.stringify(tabs.data))
_tabs.forEach((tab) => {
if (!tab.sections) return
tab.sections.forEach((section) => {
if (!section.fields) return
section.fields = section.fields.map(
(field) => field.fieldname || field.name,
)
})
})
loading.value = true
call(
'crm.fcrm.doctype.crm_fields_layout.crm_fields_layout.save_fields_layout',
{
doctype: _doctype.value,
type: 'Grid Row',
layout: JSON.stringify(_tabs),
},
).then(() => {
loading.value = false
show.value = false
capture('data_fields_layout_builder', { doctype: _doctype.value })
emit('reload')
})
}
</script>

View File

@ -0,0 +1,62 @@
<template>
<Dialog v-model="show" :options="{ size: '4xl' }">
<template #body>
<div class="bg-surface-modal px-4 pb-6 pt-5 sm:px-6">
<div class="mb-5 flex items-center justify-between">
<div>
<h3 class="text-2xl font-semibold leading-6 text-ink-gray-9">
{{ __('Editing Row {0}', [index + 1]) }}
</h3>
</div>
<div class="flex items-center gap-1">
<Button
v-if="isManager()"
variant="ghost"
class="w-7"
@click="openGridRowFieldsModal"
>
<EditIcon class="h-4 w-4" />
</Button>
<Button variant="ghost" class="w-7" @click="show = false">
<FeatherIcon name="x" class="h-4 w-4" />
</Button>
</div>
</div>
<div>
<FieldLayout v-if="tabs.data" :tabs="tabs.data" :data="data" />
</div>
</div>
</template>
</Dialog>
</template>
<script setup>
import EditIcon from '@/components/Icons/EditIcon.vue'
import FieldLayout from '@/components/FieldLayout.vue'
import { usersStore } from '@/stores/users'
import { createResource } from 'frappe-ui'
import { nextTick } from 'vue'
const props = defineProps({
index: Number,
data: Object,
doctype: String,
})
const { isManager } = usersStore()
const show = defineModel()
const showGridRowFieldsModal = defineModel('showGridRowFieldsModal')
const tabs = createResource({
url: 'crm.fcrm.doctype.crm_fields_layout.crm_fields_layout.get_fields_layout',
cache: ['GridRow', props.doctype],
params: { doctype: props.doctype, type: 'Grid Row' },
auto: true,
})
function openGridRowFieldsModal() {
showGridRowFieldsModal.value = true
nextTick(() => (show.value = false))
}
</script>

View File

@ -15,7 +15,7 @@
!hasTabs ? 'hidden' : modal ? 'border-outline-gray-modals' : ''
"
>
<div :class="{ 'my-4 sm:my-6': hasTabs }">
<div class="overflow-hidden" :class="{ 'my-4 sm:my-6': hasTabs }">
<div
v-for="(section, i) in tab.sections"
:key="section.label"
@ -76,6 +76,13 @@
v-model="data[field.name]"
:disabled="true"
/>
<Grid
v-else-if="field.type === 'Table'"
v-model="data[field.name]"
:fields="field.fields"
:doctype="field.options"
:parentDoctype="doctype"
/>
<FormControl
v-else-if="field.type === 'Select'"
type="select"
@ -178,7 +185,9 @@
/>
<FormControl
v-else-if="
['Small Text', 'Text', 'Long Text'].includes(field.type)
['Small Text', 'Text', 'Long Text', 'Code'].includes(
field.type,
)
"
type="textarea"
:placeholder="getPlaceholder(field)"
@ -237,7 +246,8 @@ import EditIcon from '@/components/Icons/EditIcon.vue'
import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import Link from '@/components/Controls/Link.vue'
import { getMeta } from '../stores/meta'
import Grid from '@/components/Controls/Grid.vue'
import { getMeta } from '@/stores/meta'
import { usersStore } from '@/stores/users'
import { getFormat } from '@/utils'
import { flt } from '@/utils/numberFormat.js'

View File

@ -1,70 +1,70 @@
<template>
<div class="flex flex-col gap-5.5">
<div
class="flex justify-between items-center gap-1 text-base bg-surface-gray-2 rounded py-2 px-2.5"
class="flex items-center gap-2 text-base bg-surface-gray-2 rounded py-2 px-2.5"
>
<div class="flex items-center gap-1">
<Draggable
v-if="tabs.length && !tabs[tabIndex].no_tabs"
:list="tabs"
item-key="label"
class="flex items-center gap-1"
@end="(e) => (tabIndex = e.newIndex)"
>
<template #item="{ element: tab, index: i }">
<div
class="cursor-pointer rounded"
:class="[
tabIndex == i
? 'text-ink-gray-9 bg-surface-white shadow-sm'
: 'text-ink-gray-5 hover:text-ink-gray-9 hover:bg-surface-white hover:shadow-sm',
tab.editingLabel ? 'p-1' : 'px-2 py-1',
]"
@click="tabIndex = i"
>
<div @dblclick="() => (tab.editingLabel = true)">
<div v-if="!tab.editingLabel" class="flex items-center gap-2">
{{ __(tab.label) || __('Untitled') }}
</div>
<div v-else class="flex gap-1 items-center">
<Input
v-model="tab.label"
@keydown.enter="tab.editingLabel = false"
@blur="tab.editingLabel = false"
@click.stop
/>
<Button
v-if="tab.editingLabel"
icon="check"
variant="ghost"
@click="tab.editingLabel = false"
/>
</div>
<Draggable
v-if="tabs.length && !tabs[tabIndex].no_tabs"
:list="tabs"
item-key="label"
class="flex items-center gap-2"
@end="(e) => (tabIndex = e.newIndex)"
>
<template #item="{ element: tab, index: i }">
<div
class="flex items-center gap-2 cursor-pointer rounded"
:class="[
tabIndex == i
? 'text-ink-gray-9 bg-surface-white shadow-sm'
: 'text-ink-gray-5 hover:text-ink-gray-9 hover:bg-surface-white hover:shadow-sm',
tab.editingLabel ? 'p-1' : 'px-2 py-1',
]"
@click="tabIndex = i"
>
<div @dblclick="() => (tab.editingLabel = true)">
<div v-if="!tab.editingLabel" class="flex items-center gap-2">
{{ __(tab.label) || __('Untitled') }}
</div>
<div v-else class="flex gap-1 items-center">
<Input
v-model="tab.label"
@keydown.enter="tab.editingLabel = false"
@blur="tab.editingLabel = false"
@click.stop
/>
<Button
v-if="tab.editingLabel"
icon="check"
variant="ghost"
@click="tab.editingLabel = false"
/>
</div>
</div>
</template>
</Draggable>
<Button
variant="ghost"
class="!h-6.5 !text-ink-gray-5 hover:!text-ink-gray-9"
@click="addTab"
:label="__('Add Tab')"
>
<template #prefix>
<FeatherIcon name="plus" class="h-4" />
</template>
</Button>
</div>
<Dropdown
v-if="tabs.length && !tabs[tabIndex].no_tabs"
:options="getTabOptions(tabs[tabIndex])"
>
<template #default>
<Button variant="ghost">
<FeatherIcon name="more-horizontal" class="h-4" />
</Button>
<Dropdown
v-if="!tab.no_tabs && tabIndex == i"
:options="getTabOptions(tab)"
class="!h-4"
@click.stop
>
<template #default>
<Button variant="ghost" class="!p-1 !h-4">
<FeatherIcon name="more-horizontal" class="h-4" />
</Button>
</template>
</Dropdown>
</div>
</template>
</Dropdown>
</Draggable>
<Button
variant="ghost"
class="!h-6.5 !text-ink-gray-5 hover:!text-ink-gray-9"
@click="addTab"
:label="__('Add Tab')"
>
<template v-slot:[slotName]>
<FeatherIcon name="plus" class="h-4" />
</template>
</Button>
</div>
<div v-show="tabIndex == i" v-for="(tab, i) in tabs" :key="tab.label">
<Draggable
@ -126,9 +126,9 @@
<div
class="px-2.5 py-2 border border-outline-gray-2 rounded text-base bg-surface-modal text-ink-gray-8 flex items-center leading-4 justify-between gap-2"
>
<div class="flex items-center gap-2">
<div class="flex items-center gap-2 truncate">
<DragVerticalIcon class="h-3.5 cursor-grab" />
<div>{{ field.label }}</div>
<div class="truncate">{{ field.label }}</div>
</div>
<Button
variant="ghost"
@ -207,9 +207,14 @@ const props = defineProps({
})
const tabIndex = ref(0)
const slotName = computed(() => {
if (props.tabs.length == 1 && props.tabs[0].no_tabs) {
return 'prefix'
}
return 'default'
})
const restrictedFieldTypes = [
'Table',
'Geolocation',
'Attach',
'Attach Image',
@ -230,6 +235,34 @@ const fields = createResource({
params: params.value,
cache: ['fieldsMeta', props.doctype],
auto: true,
transform: (data) => {
let restrictedFields = [
'name',
'owner',
'creation',
'modified',
'modified_by',
'docstatus',
'_comments',
'_user_tags',
'_assign',
'_liked_by',
]
let existingFields = []
for (let tab of props.tabs) {
for (let section of tab.sections) {
existingFields = existingFields.concat(section.fields)
}
}
return data.filter((field) => {
return (
!existingFields.find((f) => f.name === field.fieldname) &&
!restrictedFields.includes(field.fieldname)
)
})
},
})
function addTab() {
@ -243,7 +276,12 @@ function addTab() {
function addField(section, field) {
if (!field) return
section.fields.push(field)
let newFieldObj = {
...field,
name: field.fieldname,
type: field.fieldtype,
}
section.fields.push(newFieldObj)
}
function getTabOptions(tab) {

View File

@ -89,7 +89,7 @@
</Button>
</template>
<template #item-label="{ option }">
<div class="flex flex-col gap-1">
<div class="flex flex-col gap-1 text-ink-gray-9">
<div>{{ option.label }}</div>
<div class="text-ink-gray-4 text-sm">
{{ `${option.fieldname} - ${option.fieldtype}` }}
@ -166,9 +166,6 @@ const fields = createResource({
params: { doctype: props.doctype, as_array: true },
cache: ['kanban_fields', props.doctype],
auto: true,
onSuccess: (data) => {
data
},
})
const allFields = computed({

View File

@ -16,18 +16,19 @@
<template #body-content>
<div class="flex flex-col gap-3">
<div class="flex justify-between gap-2">
<FormControl
type="select"
class="w-1/4"
v-model="_doctype"
:options="['CRM Lead', 'CRM Deal']"
@change="reload"
/>
<Switch
v-model="preview"
<Button
:label="preview ? __('Hide preview') : __('Show preview')"
size="sm"
@click="preview = !preview"
/>
<div class="flex flex-row-reverse gap-2">
<Button
:loading="loading"
:label="__('Save')"
variant="solid"
@click="saveChanges"
/>
<Button :label="__('Reset')" @click="reload" />
</div>
</div>
<div v-if="tabs?.data">
<FieldLayoutEditor
@ -39,17 +40,6 @@
</div>
</div>
</template>
<template #actions>
<div class="flex flex-row-reverse gap-2">
<Button
:loading="loading"
:label="__('Save')"
variant="solid"
@click="saveChanges"
/>
<Button :label="__('Reset')" @click="reload" />
</div>
</template>
</Dialog>
</template>
<script setup>
@ -57,7 +47,7 @@ import FieldLayout from '@/components/FieldLayout.vue'
import FieldLayoutEditor from '@/components/FieldLayoutEditor.vue'
import { useDebounceFn } from '@vueuse/core'
import { capture } from '@/telemetry'
import { Dialog, Badge, Switch, call, createResource } from 'frappe-ui'
import { Dialog, Badge, call, createResource } from 'frappe-ui'
import { ref, watch, onMounted, nextTick } from 'vue'
const props = defineProps({

View File

@ -16,24 +16,19 @@
<template #body-content>
<div class="flex flex-col gap-3">
<div class="flex justify-between gap-2">
<FormControl
type="select"
class="w-1/4"
v-model="_doctype"
:options="[
'CRM Lead',
'CRM Deal',
'Contact',
'CRM Organization',
'Address',
]"
@change="reload"
/>
<Switch
v-model="preview"
<Button
:label="preview ? __('Hide preview') : __('Show preview')"
size="sm"
@click="preview = !preview"
/>
<div class="flex flex-row-reverse gap-2">
<Button
:loading="loading"
:label="__('Save')"
variant="solid"
@click="saveChanges"
/>
<Button :label="__('Reset')" @click="reload" />
</div>
</div>
<div v-if="tabs?.data">
<FieldLayoutEditor
@ -45,17 +40,6 @@
</div>
</div>
</template>
<template #actions>
<div class="flex flex-row-reverse gap-2">
<Button
:loading="loading"
:label="__('Save')"
variant="solid"
@click="saveChanges"
/>
<Button :label="__('Reset')" @click="reload" />
</div>
</template>
</Dialog>
</template>
<script setup>

View File

@ -16,18 +16,19 @@
<template #body-content>
<div class="flex flex-col gap-5.5">
<div class="flex justify-between gap-2">
<FormControl
type="select"
class="w-1/4"
v-model="_doctype"
:options="['CRM Lead', 'CRM Deal', 'Contact', 'CRM Organization']"
@change="reload"
/>
<Switch
v-model="preview"
<Button
:label="preview ? __('Hide preview') : __('Show preview')"
size="sm"
@click="preview = !preview"
/>
<div class="flex flex-row-reverse gap-2">
<Button
:loading="loading"
:label="__('Save')"
variant="solid"
@click="saveChanges"
/>
<Button :label="__('Reset')" @click="reload" />
</div>
</div>
<div v-if="tabs.data?.[0]?.sections" class="flex gap-4">
<SidePanelLayoutEditor
@ -66,17 +67,6 @@
</div>
</div>
</template>
<template #actions>
<div class="flex flex-row-reverse gap-2">
<Button
:loading="loading"
:label="__('Save')"
variant="solid"
@click="saveChanges"
/>
<Button :label="__('Reset')" @click="reload" />
</div>
</template>
</Dialog>
</template>
<script setup>
@ -85,7 +75,7 @@ import SidePanelLayout from '@/components/SidePanelLayout.vue'
import SidePanelLayoutEditor from '@/components/SidePanelLayoutEditor.vue'
import { useDebounceFn } from '@vueuse/core'
import { capture } from '@/telemetry'
import { Dialog, Badge, Switch, call, createResource } from 'frappe-ui'
import { Dialog, Badge, call, createResource } from 'frappe-ui'
import { ref, watch, onMounted, nextTick } from 'vue'
const props = defineProps({

View File

@ -1,8 +1,9 @@
import { createResource } from 'frappe-ui'
import { formatCurrency, formatNumber } from '@/utils/numberFormat.js'
import { reactive } from 'vue'
import { ref, reactive } from 'vue'
const doctypeMeta = reactive({})
const userSettings = reactive({})
export function getMeta(doctype) {
const meta = createResource({
@ -18,6 +19,8 @@ export function getMeta(doctype) {
for (let dtMeta of dtMetas) {
doctypeMeta[dtMeta.name] = dtMeta
}
userSettings[doctype] = JSON.parse(res.user_settings)
},
})
@ -52,9 +55,54 @@ export function getMeta(doctype) {
return formatCurrency(doc[fieldname], '', currency, precision)
}
function getGridSettings(parentDoctype, dt = null) {
dt = dt || doctype
if (!userSettings[parentDoctype]['GridView']?.[doctype]) return {}
return userSettings[parentDoctype]['GridView'][doctype]
}
function getFields(dt = null) {
dt = dt || doctype
return doctypeMeta[dt]?.fields.map((f) => {
if (f.fieldtype === 'Select' && typeof f.options === 'string') {
f.options = f.options.split('\n').map((option) => {
return {
label: option,
value: option,
}
})
}
return f
})
}
function saveUserSettings(parentDoctype, key, value, callback) {
let oldUserSettings = userSettings[parentDoctype]
let newUserSettings = JSON.parse(JSON.stringify(oldUserSettings))
newUserSettings[key][doctype] = value
if (JSON.stringify(oldUserSettings) !== JSON.stringify(newUserSettings)) {
return createResource({
url: 'frappe.model.utils.user_settings.save',
params: {
doctype: parentDoctype,
user_settings: JSON.stringify(newUserSettings),
},
auto: true,
onSuccess: () => callback?.(),
})
}
return callback?.()
}
return {
meta,
doctypeMeta,
userSettings,
getFields,
getGridSettings,
saveUserSettings,
getFormattedFloat,
getFormattedPercent,
getFormattedCurrency,

View File

@ -305,3 +305,14 @@ export function isImage(extention) {
extention.toLowerCase(),
)
}
export function getRandom(len) {
let text = ''
const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
Array.from({ length: len }).forEach(() => {
text += possible.charAt(Math.floor(Math.random() * possible.length))
})
return text
}

View File

@ -2388,10 +2388,10 @@ fraction.js@^4.3.7:
resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.7.tgz#06ca0085157e42fda7f9e726e79fefc4068840f7"
integrity sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==
frappe-ui@^0.1.91:
version "0.1.93"
resolved "https://registry.yarnpkg.com/frappe-ui/-/frappe-ui-0.1.93.tgz#0443800e195cddcff88ba875989148a92a270206"
integrity sha512-21VUcBBB9g1o7Iv/TW8ng3SdCxsm9G/g0rEdC9Y1Vqx1A1Ucf8VtVFECX2MgoDrLAwVOBKayU5BMaS1fuphcnA==
frappe-ui@^0.1.94:
version "0.1.94"
resolved "https://registry.yarnpkg.com/frappe-ui/-/frappe-ui-0.1.94.tgz#574a193ce51b47cb2747a5d5f9e9fe91b164e3a6"
integrity sha512-WV7nApCrDBqtKPPiVVxqFgA0JdWQz9kATZylfmMJQrSWszHCx0k0eriTSvT9+0vmtT0T9UYqLfXnhNj0lnqYNA==
dependencies:
"@headlessui/vue" "^1.7.14"
"@popperjs/core" "^2.11.2"