1
0
forked from test/crm

Merge pull request #808 from frappe/mergify/bp/main-hotfix/pr-806

This commit is contained in:
Shariq Ansari 2025-05-09 20:30:07 +05:30 committed by GitHub
commit b82362a869
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 699 additions and 153 deletions

View File

@ -47,7 +47,10 @@ def get_fields_layout(doctype: str, type: str, parent_doctype: str | None = None
for tab in tabs: for tab in tabs:
for section in tab.get("sections"): for section in tab.get("sections"):
if section.get("columns"):
section["columns"] = [column for column in section.get("columns") if column]
for column in section.get("columns") if section.get("columns") else []: for column in section.get("columns") if section.get("columns") else []:
column["fields"] = [field for field in column.get("fields") if field]
for field in column.get("fields") if column.get("fields") else []: for field in column.get("fields") if column.get("fields") else []:
field = next((f for f in fields if f.fieldname == field), None) field = next((f for f in fields if f.fieldname == field), None)
if field: if field:

View File

@ -5,7 +5,7 @@
<div class="flex h-8 items-center text-xl font-semibold text-ink-gray-8"> <div class="flex h-8 items-center text-xl font-semibold text-ink-gray-8">
{{ __('Data') }} {{ __('Data') }}
<Badge <Badge
v-if="data.isDirty" v-if="document.isDirty"
class="ml-3" class="ml-3"
:label="'Not Saved'" :label="'Not Saved'"
theme="orange" theme="orange"
@ -20,15 +20,15 @@
</Button> </Button>
<Button <Button
label="Save" label="Save"
:disabled="!data.isDirty" :disabled="!document.isDirty"
variant="solid" variant="solid"
:loading="data.save.loading" :loading="document.save.loading"
@click="saveChanges" @click="saveChanges"
/> />
</div> </div>
</div> </div>
<div <div
v-if="data.get.loading" v-if="document.get.loading"
class="flex flex-1 flex-col items-center justify-center gap-3 text-xl font-medium text-gray-500" class="flex flex-1 flex-col items-center justify-center gap-3 text-xl font-medium text-gray-500"
> >
<LoadingIndicator class="h-6 w-6" /> <LoadingIndicator class="h-6 w-6" />
@ -38,7 +38,7 @@
<FieldLayout <FieldLayout
v-if="tabs.data" v-if="tabs.data"
:tabs="tabs.data" :tabs="tabs.data"
:data="data.doc" :data="document.doc"
:doctype="doctype" :doctype="doctype"
/> />
</div> </div>
@ -49,7 +49,7 @@
@reload=" @reload="
() => { () => {
tabs.reload() tabs.reload()
data.reload() document.reload()
} }
" "
/> />
@ -59,10 +59,10 @@
import EditIcon from '@/components/Icons/EditIcon.vue' import EditIcon from '@/components/Icons/EditIcon.vue'
import DataFieldsModal from '@/components/Modals/DataFieldsModal.vue' import DataFieldsModal from '@/components/Modals/DataFieldsModal.vue'
import FieldLayout from '@/components/FieldLayout/FieldLayout.vue' import FieldLayout from '@/components/FieldLayout/FieldLayout.vue'
import { Badge, createResource, createDocumentResource } from 'frappe-ui' import { Badge, createResource } from 'frappe-ui'
import LoadingIndicator from '@/components/Icons/LoadingIndicator.vue' import LoadingIndicator from '@/components/Icons/LoadingIndicator.vue'
import { createToast } from '@/utils'
import { usersStore } from '@/stores/users' import { usersStore } from '@/stores/users'
import { useDocument } from '@/data/document'
import { isMobileView } from '@/composables/settings' import { isMobileView } from '@/composables/settings'
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
@ -76,33 +76,11 @@ const props = defineProps({
required: true, required: true,
}, },
}) })
const { isManager } = usersStore() const { isManager } = usersStore()
const showDataFieldsModal = ref(false) const showDataFieldsModal = ref(false)
const data = createDocumentResource({ const { document } = useDocument(props.doctype, props.docname)
doctype: props.doctype,
name: props.docname,
setValue: {
onSuccess: () => {
data.reload()
createToast({
title: 'Data Updated',
icon: 'check',
iconClasses: 'text-ink-green-3',
})
},
onError: (err) => {
createToast({
title: 'Error',
text: err.messages[0],
icon: 'x',
iconClasses: 'text-red-600',
})
},
},
})
const tabs = createResource({ const tabs = createResource({
url: 'crm.fcrm.doctype.crm_fields_layout.crm_fields_layout.get_fields_layout', url: 'crm.fcrm.doctype.crm_fields_layout.crm_fields_layout.get_fields_layout',
@ -112,19 +90,19 @@ const tabs = createResource({
}) })
function saveChanges() { function saveChanges() {
data.save.submit() document.save.submit()
} }
watch( watch(
() => data.doc, () => document.doc,
(newValue, oldValue) => { (newValue, oldValue) => {
if (!oldValue) return if (!oldValue) return
if (newValue && oldValue) { if (newValue && oldValue) {
const isDirty = const isDirty =
JSON.stringify(newValue) !== JSON.stringify(data.originalDoc) JSON.stringify(newValue) !== JSON.stringify(document.originalDoc)
data.isDirty = isDirty document.isDirty = isDirty
if (isDirty) { if (isDirty) {
data.save.loading = false document.save.loading = false
} }
} }
}, },

View File

@ -93,7 +93,10 @@
:key="field.fieldname" :key="field.fieldname"
> >
<FormControl <FormControl
v-if="field.read_only && field.fieldtype !== 'Check'" v-if="
field.read_only &&
!['Float', 'Currency', 'Check'].includes(field.fieldtype)
"
type="text" type="text"
:placeholder="field.placeholder" :placeholder="field.placeholder"
v-model="row[field.fieldname]" v-model="row[field.fieldname]"
@ -104,13 +107,14 @@
['Link', 'Dynamic Link'].includes(field.fieldtype) ['Link', 'Dynamic Link'].includes(field.fieldtype)
" "
class="text-sm text-ink-gray-8" class="text-sm text-ink-gray-8"
v-model="row[field.fieldname]" :value="row[field.fieldname]"
:doctype=" :doctype="
field.fieldtype == 'Link' field.fieldtype == 'Link'
? field.options ? field.options
: row[field.options] : row[field.options]
" "
:filters="field.filters" :filters="field.filters"
@change="(v) => fieldChange(v, field, row)"
/> />
<Link <Link
v-else-if="field.fieldtype === 'User'" v-else-if="field.fieldtype === 'User'"
@ -118,7 +122,7 @@
:value="getUser(row[field.fieldname]).full_name" :value="getUser(row[field.fieldname]).full_name"
:doctype="field.options" :doctype="field.options"
:filters="field.filters" :filters="field.filters"
@change="(v) => (row[field.fieldname] = v)" @change="(v) => fieldChange(v, field, row)"
:placeholder="field.placeholder" :placeholder="field.placeholder"
:hideMe="true" :hideMe="true"
> >
@ -148,23 +152,26 @@
class="cursor-pointer duration-300" class="cursor-pointer duration-300"
v-model="row[field.fieldname]" v-model="row[field.fieldname]"
:disabled="!gridSettings.editable_grid" :disabled="!gridSettings.editable_grid"
@change="(e) => fieldChange(e.target.checked, field, row)"
/> />
</div> </div>
<DatePicker <DatePicker
v-else-if="field.fieldtype === 'Date'" v-else-if="field.fieldtype === 'Date'"
v-model="row[field.fieldname]" :value="row[field.fieldname]"
icon-left="" icon-left=""
variant="outline" variant="outline"
:formatter="(date) => getFormat(date, '', true)" :formatter="(date) => getFormat(date, '', true)"
input-class="border-none text-sm text-ink-gray-8" input-class="border-none text-sm text-ink-gray-8"
@change="(v) => fieldChange(v, field, row)"
/> />
<DateTimePicker <DateTimePicker
v-else-if="field.fieldtype === 'Datetime'" v-else-if="field.fieldtype === 'Datetime'"
v-model="row[field.fieldname]" :value="row[field.fieldname]"
icon-left="" icon-left=""
variant="outline" variant="outline"
:formatter="(date) => getFormat(date, '', true, true)" :formatter="(date) => getFormat(date, '', true, true)"
input-class="border-none text-sm text-ink-gray-8" input-class="border-none text-sm text-ink-gray-8"
@change="(v) => fieldChange(v, field, row)"
/> />
<FormControl <FormControl
v-else-if=" v-else-if="
@ -175,13 +182,8 @@
rows="1" rows="1"
type="textarea" type="textarea"
variant="outline" variant="outline"
v-model="row[field.fieldname]" :value="row[field.fieldname]"
/> @change="fieldChange($event.target.value, field, row)"
<FormControl
v-else-if="['Int'].includes(field.fieldtype)"
type="number"
variant="outline"
v-model="row[field.fieldname]"
/> />
<FormControl <FormControl
v-else-if="field.fieldtype === 'Select'" v-else-if="field.fieldtype === 'Select'"
@ -190,6 +192,38 @@
variant="outline" variant="outline"
v-model="row[field.fieldname]" v-model="row[field.fieldname]"
:options="field.options" :options="field.options"
@change="(e) => fieldChange(e.target.value, field, row)"
/>
<FormControl
v-else-if="['Int'].includes(field.fieldtype)"
type="number"
variant="outline"
:value="row[field.fieldname]"
@change="fieldChange($event.target.value, field, row)"
/>
<FormControl
v-else-if="field.fieldtype === 'Percent'"
type="text"
variant="outline"
:value="getFormattedPercent(field.fieldname, row)"
:disabled="Boolean(field.read_only)"
@change="fieldChange(flt($event.target.value), field, row)"
/>
<FormControl
v-else-if="field.fieldtype === 'Float'"
type="text"
variant="outline"
:value="getFormattedFloat(field.fieldname, row)"
:disabled="Boolean(field.read_only)"
@change="fieldChange(flt($event.target.value), field, row)"
/>
<FormControl
v-else-if="field.fieldtype === 'Currency'"
type="text"
variant="outline"
:value="getFormattedCurrency(field.fieldname, row)"
:disabled="Boolean(field.read_only)"
@change="fieldChange(flt($event.target.value), field, row)"
/> />
<FormControl <FormControl
v-else v-else
@ -198,6 +232,7 @@
variant="outline" variant="outline"
v-model="row[field.fieldname]" v-model="row[field.fieldname]"
:options="field.options" :options="field.options"
@change="fieldChange($event.target.value, field, row)"
/> />
</div> </div>
</div> </div>
@ -265,6 +300,7 @@ import EditIcon from '@/components/Icons/EditIcon.vue'
import Link from '@/components/Controls/Link.vue' import Link from '@/components/Controls/Link.vue'
import UserAvatar from '@/components/UserAvatar.vue' import UserAvatar from '@/components/UserAvatar.vue'
import { getRandom, getFormat, isTouchScreenDevice } from '@/utils' import { getRandom, getFormat, isTouchScreenDevice } from '@/utils'
import { flt } from '@/utils/numberFormat.js'
import { usersStore } from '@/stores/users' import { usersStore } from '@/stores/users'
import { getMeta } from '@/stores/meta' import { getMeta } from '@/stores/meta'
import { import {
@ -274,9 +310,10 @@ import {
DateTimePicker, DateTimePicker,
DatePicker, DatePicker,
Tooltip, Tooltip,
dayjs,
} from 'frappe-ui' } from 'frappe-ui'
import Draggable from 'vuedraggable' import Draggable from 'vuedraggable'
import { ref, reactive, computed } from 'vue' import { ref, reactive, computed, inject } from 'vue'
const props = defineProps({ const props = defineProps({
label: { label: {
@ -291,11 +328,24 @@ const props = defineProps({
type: String, type: String,
required: true, required: true,
}, },
parentFieldname: {
type: String,
required: true,
},
}) })
const { getGridViewSettings, getFields, getGridSettings } = getMeta( const triggerOnChange = inject('triggerOnChange')
props.doctype, const triggerOnRowAdd = inject('triggerOnRowAdd')
) const triggerOnRowRemove = inject('triggerOnRowRemove')
const {
getGridViewSettings,
getFields,
getFormattedPercent,
getFormattedFloat,
getFormattedCurrency,
getGridSettings,
} = getMeta(props.doctype)
getMeta(props.parentDoctype) getMeta(props.parentDoctype)
const { getUser } = usersStore() const { getUser } = usersStore()
@ -322,6 +372,10 @@ const fields = computed(() => {
) )
}) })
const allFields = computed(() => {
return getFields()?.map((f) => getFieldObj(f)) || []
})
function getFieldObj(field) { function getFieldObj(field) {
return { return {
...field, ...field,
@ -367,21 +421,71 @@ const toggleSelectRow = (row) => {
const addRow = () => { const addRow = () => {
const newRow = {} const newRow = {}
fields.value?.forEach((field) => { allFields.value?.forEach((field) => {
if (field.fieldtype === 'Check') newRow[field.fieldname] = false if (field.fieldtype === 'Check') {
else newRow[field.fieldname] = '' newRow[field.fieldname] = false
} else {
newRow[field.fieldname] = ''
}
if (field.default) {
newRow[field.fieldname] = getDefaultValue(field.default, field.fieldtype)
}
}) })
newRow.name = getRandom(10) newRow.name = getRandom(10)
showRowList.value.push(false) showRowList.value.push(false)
newRow['__islocal'] = true newRow['__islocal'] = true
newRow['idx'] = rows.value.length + 1
newRow['doctype'] = props.doctype
newRow['parentfield'] = props.parentFieldname
newRow['parenttype'] = props.parentDoctype
rows.value.push(newRow) rows.value.push(newRow)
triggerOnRowAdd(newRow)
} }
const deleteRows = () => { const deleteRows = () => {
rows.value = rows.value.filter((row) => !selectedRows.has(row.name)) rows.value = rows.value.filter((row) => !selectedRows.has(row.name))
triggerOnRowRemove(selectedRows, rows.value)
showRowList.value.pop() showRowList.value.pop()
selectedRows.clear() selectedRows.clear()
} }
function fieldChange(value, field, row) {
row[field.fieldname] = value
triggerOnChange(field.fieldname, row)
}
function getDefaultValue(defaultValue, fieldtype) {
if (['Float', 'Currency', 'Percent'].includes(fieldtype)) {
return flt(defaultValue)
} else if (fieldtype === 'Check') {
if (['1', 'true', 'True'].includes(defaultValue)) {
return true
} else if (['0', 'false', 'False'].includes(defaultValue)) {
return false
}
} else if (fieldtype === 'Int') {
return parseInt(defaultValue)
} else if (defaultValue === 'Today' && fieldtype === 'Date') {
return dayjs().format('YYYY-MM-DD')
} else if (
['Now', 'now'].includes(defaultValue) &&
fieldtype === 'Datetime'
) {
return dayjs().format('YYYY-MM-DD HH:mm:ss')
} else if (['Now', 'now'].includes(defaultValue) && fieldtype === 'Time') {
return dayjs().format('HH:mm:ss')
} else if (fieldtype === 'Date') {
return dayjs(defaultValue).format('YYYY-MM-DD')
} else if (fieldtype === 'Datetime') {
return dayjs(defaultValue).format('YYYY-MM-DD HH:mm:ss')
} else if (fieldtype === 'Time') {
return dayjs(defaultValue).format('HH:mm:ss')
}
return defaultValue
}
</script> </script>
<style scoped> <style scoped>

View File

@ -139,9 +139,14 @@ const oldFields = computed(() => {
const fields = ref(JSON.parse(JSON.stringify(oldFields.value || []))) const fields = ref(JSON.parse(JSON.stringify(oldFields.value || [])))
const dropdownFields = computed(() => { const dropdownFields = computed(() => {
return getFields()?.filter( return getFields()?.filter((field) => {
(field) => !fields.value.find((f) => f.fieldname === field.fieldname), return (
) !fields.value.find((f) => f.fieldname === field.fieldname) &&
!['Tab Break', 'Section Break', 'Column Break', 'Table'].includes(
field.fieldtype,
)
)
})
}) })
function reset() { function reset() {

View File

@ -23,7 +23,13 @@
</div> </div>
</div> </div>
<div> <div>
<FieldLayout v-if="tabs.data" :tabs="tabs.data" :data="data" /> <FieldLayout
v-if="tabs.data"
:tabs="tabs.data"
:data="data"
:doctype="doctype"
:isGridRow="true"
/>
</div> </div>
</div> </div>
</template> </template>

View File

@ -60,6 +60,8 @@ const props = defineProps({
}, },
}) })
const emit = defineEmits(['change'])
const { getFields } = getMeta(props.doctype) const { getFields } = getMeta(props.doctype)
const values = defineModel() const values = defineModel()
@ -109,14 +111,16 @@ const addValue = (value) => {
if (value) { if (value) {
values.value.push({ [linkField.value.fieldname]: value }) values.value.push({ [linkField.value.fieldname]: value })
emit('change', values.value)
!error.value && (query.value = '') !error.value && (query.value = '')
} }
} }
const removeValue = (value) => { const removeValue = (value) => {
values.value = values.value.filter( let _value = values.value.filter(
(row) => row[linkField.value.fieldname] !== value, (row) => row[linkField.value.fieldname] !== value,
) )
emit('change', _value)
} }
const removeLastValue = () => { const removeLastValue = () => {
@ -125,12 +129,11 @@ const removeLastValue = () => {
let valueRef = valuesRef.value[valuesRef.value.length - 1]?.$el let valueRef = valuesRef.value[valuesRef.value.length - 1]?.$el
if (document.activeElement === valueRef) { if (document.activeElement === valueRef) {
values.value.pop() values.value.pop()
emit('change', values.value)
nextTick(() => { nextTick(() => {
if (values.value.length) { if (values.value.length) {
valueRef = valuesRef.value[valuesRef.value.length - 1].$el valueRef = valuesRef.value[valuesRef.value.length - 1].$el
valueRef?.focus() valueRef?.focus()
} else {
setFocus()
} }
}) })
} else { } else {

View File

@ -37,8 +37,8 @@ import { isMobileView } from '@/composables/settings'
const props = defineProps({ const props = defineProps({
actions: { actions: {
type: Object, type: [Object, Array, undefined],
required: true, default: () => [],
}, },
}) })
@ -85,7 +85,7 @@ const groupedActions = computed(() => {
}) })
} }
_actions = _actions.concat( _actions = _actions.concat(
props.actions.filter((action) => action.group && !action.buttonLabel) props.actions.filter((action) => action.group && !action.buttonLabel),
) )
return _actions return _actions
}) })

View File

@ -12,7 +12,10 @@
> >
</div> </div>
<FormControl <FormControl
v-if="field.read_only && field.fieldtype !== 'Check'" v-if="
field.read_only &&
!['Float', 'Currency', 'Check'].includes(field.fieldtype)
"
type="text" type="text"
:placeholder="getPlaceholder(field)" :placeholder="getPlaceholder(field)"
v-model="data[field.fieldname]" v-model="data[field.fieldname]"
@ -23,6 +26,7 @@
v-model="data[field.fieldname]" v-model="data[field.fieldname]"
:doctype="field.options" :doctype="field.options"
:parentDoctype="doctype" :parentDoctype="doctype"
:parentFieldname="field.fieldname"
/> />
<FormControl <FormControl
v-else-if="field.fieldtype === 'Select'" v-else-if="field.fieldtype === 'Select'"
@ -31,6 +35,7 @@
:class="field.prefix ? 'prefix' : ''" :class="field.prefix ? 'prefix' : ''"
:options="field.options" :options="field.options"
v-model="data[field.fieldname]" v-model="data[field.fieldname]"
@change="(e) => fieldChange(e.target.value, field)"
:placeholder="getPlaceholder(field)" :placeholder="getPlaceholder(field)"
> >
<template v-if="field.prefix" #prefix> <template v-if="field.prefix" #prefix>
@ -42,7 +47,7 @@
class="form-control" class="form-control"
type="checkbox" type="checkbox"
v-model="data[field.fieldname]" v-model="data[field.fieldname]"
@change="(e) => (data[field.fieldname] = e.target.checked)" @change="(e) => fieldChange(e.target.checked, field)"
:disabled="Boolean(field.read_only)" :disabled="Boolean(field.read_only)"
/> />
<label <label
@ -70,7 +75,7 @@
field.fieldtype == 'Link' ? field.options : data[field.options] field.fieldtype == 'Link' ? field.options : data[field.options]
" "
:filters="field.filters" :filters="field.filters"
@change="(v) => (data[field.fieldname] = v)" @change="(v) => fieldChange(v, field)"
:placeholder="getPlaceholder(field)" :placeholder="getPlaceholder(field)"
:onCreate="field.create" :onCreate="field.create"
/> />
@ -90,6 +95,7 @@
v-else-if="field.fieldtype === 'Table MultiSelect'" v-else-if="field.fieldtype === 'Table MultiSelect'"
v-model="data[field.fieldname]" v-model="data[field.fieldname]"
:doctype="field.options" :doctype="field.options"
@change="(v) => fieldChange(v, field)"
/> />
<Link <Link
@ -98,7 +104,7 @@
:value="data[field.fieldname] && getUser(data[field.fieldname]).full_name" :value="data[field.fieldname] && getUser(data[field.fieldname]).full_name"
:doctype="field.options" :doctype="field.options"
:filters="field.filters" :filters="field.filters"
@change="(v) => (data[field.fieldname] = v)" @change="(v) => fieldChange(v, field)"
:placeholder="getPlaceholder(field)" :placeholder="getPlaceholder(field)"
:hideMe="true" :hideMe="true"
> >
@ -123,19 +129,21 @@
</Link> </Link>
<DateTimePicker <DateTimePicker
v-else-if="field.fieldtype === 'Datetime'" v-else-if="field.fieldtype === 'Datetime'"
v-model="data[field.fieldname]" :value="data[field.fieldname]"
icon-left="" icon-left=""
:formatter="(date) => getFormat(date, '', true, true)" :formatter="(date) => getFormat(date, '', true, true)"
:placeholder="getPlaceholder(field)" :placeholder="getPlaceholder(field)"
input-class="border-none" input-class="border-none"
@change="(v) => fieldChange(v, field)"
/> />
<DatePicker <DatePicker
v-else-if="field.fieldtype === 'Date'" v-else-if="field.fieldtype === 'Date'"
icon-left="" icon-left=""
v-model="data[field.fieldname]" :value="data[field.fieldname]"
:formatter="(date) => getFormat(date, '', true)" :formatter="(date) => getFormat(date, '', true)"
:placeholder="getPlaceholder(field)" :placeholder="getPlaceholder(field)"
input-class="border-none" input-class="border-none"
@change="(v) => fieldChange(v, field)"
/> />
<FormControl <FormControl
v-else-if=" v-else-if="
@ -143,13 +151,15 @@
" "
type="textarea" type="textarea"
:placeholder="getPlaceholder(field)" :placeholder="getPlaceholder(field)"
v-model="data[field.fieldname]" :value="data[field.fieldname]"
@change="fieldChange($event.target.value, field)"
/> />
<FormControl <FormControl
v-else-if="['Int'].includes(field.fieldtype)" v-else-if="['Int'].includes(field.fieldtype)"
type="number" type="number"
:placeholder="getPlaceholder(field)" :placeholder="getPlaceholder(field)"
v-model="data[field.fieldname]" :value="data[field.fieldname]"
@change="fieldChange($event.target.value, field)"
/> />
<FormControl <FormControl
v-else-if="field.fieldtype === 'Percent'" v-else-if="field.fieldtype === 'Percent'"
@ -157,7 +167,7 @@
:value="getFormattedPercent(field.fieldname, data)" :value="getFormattedPercent(field.fieldname, data)"
:placeholder="getPlaceholder(field)" :placeholder="getPlaceholder(field)"
:disabled="Boolean(field.read_only)" :disabled="Boolean(field.read_only)"
@change="data[field.fieldname] = flt($event.target.value)" @change="fieldChange(flt($event.target.value), field)"
/> />
<FormControl <FormControl
v-else-if="field.fieldtype === 'Float'" v-else-if="field.fieldtype === 'Float'"
@ -165,7 +175,7 @@
:value="getFormattedFloat(field.fieldname, data)" :value="getFormattedFloat(field.fieldname, data)"
:placeholder="getPlaceholder(field)" :placeholder="getPlaceholder(field)"
:disabled="Boolean(field.read_only)" :disabled="Boolean(field.read_only)"
@change="data[field.fieldname] = flt($event.target.value)" @change="fieldChange(flt($event.target.value), field)"
/> />
<FormControl <FormControl
v-else-if="field.fieldtype === 'Currency'" v-else-if="field.fieldtype === 'Currency'"
@ -173,14 +183,15 @@
:value="getFormattedCurrency(field.fieldname, data)" :value="getFormattedCurrency(field.fieldname, data)"
:placeholder="getPlaceholder(field)" :placeholder="getPlaceholder(field)"
:disabled="Boolean(field.read_only)" :disabled="Boolean(field.read_only)"
@change="data[field.fieldname] = flt($event.target.value)" @change="fieldChange(flt($event.target.value), field)"
/> />
<FormControl <FormControl
v-else v-else
type="text" type="text"
:placeholder="getPlaceholder(field)" :placeholder="getPlaceholder(field)"
v-model="data[field.fieldname]" :value="data[field.fieldname]"
:disabled="Boolean(field.read_only)" :disabled="Boolean(field.read_only)"
@change="fieldChange($event.target.value, field)"
/> />
</div> </div>
</template> </template>
@ -195,8 +206,9 @@ import { getFormat, evaluateDependsOnValue } from '@/utils'
import { flt } from '@/utils/numberFormat.js' import { flt } from '@/utils/numberFormat.js'
import { getMeta } from '@/stores/meta' import { getMeta } from '@/stores/meta'
import { usersStore } from '@/stores/users' import { usersStore } from '@/stores/users'
import { useDocument } from '@/data/document'
import { Tooltip, DatePicker, DateTimePicker } from 'frappe-ui' import { Tooltip, DatePicker, DateTimePicker } from 'frappe-ui'
import { computed, inject } from 'vue' import { computed, provide, inject } from 'vue'
const props = defineProps({ const props = defineProps({
field: Object, field: Object,
@ -205,11 +217,30 @@ const props = defineProps({
const data = inject('data') const data = inject('data')
const doctype = inject('doctype') const doctype = inject('doctype')
const preview = inject('preview') const preview = inject('preview')
const isGridRow = inject('isGridRow')
const { getFormattedPercent, getFormattedFloat, getFormattedCurrency } = const { getFormattedPercent, getFormattedFloat, getFormattedCurrency } =
getMeta(doctype) getMeta(doctype)
const { getUser } = usersStore() const { getUser } = usersStore()
let triggerOnChange
if (!isGridRow) {
const {
triggerOnChange: trigger,
triggerOnRowAdd,
triggerOnRowRemove,
} = useDocument(doctype, data.value.name)
triggerOnChange = trigger
provide('triggerOnChange', triggerOnChange)
provide('triggerOnRowAdd', triggerOnRowAdd)
provide('triggerOnRowRemove', triggerOnRowRemove)
} else {
triggerOnChange = inject('triggerOnChange')
}
const field = computed(() => { const field = computed(() => {
let field = props.field let field = props.field
if (field.fieldtype == 'Select' && typeof field.options === 'string') { if (field.fieldtype == 'Select' && typeof field.options === 'string') {
@ -265,6 +296,16 @@ const getPlaceholder = (field) => {
return __('Enter {0}', [__(field.label)]) return __('Enter {0}', [__(field.label)])
} }
} }
function fieldChange(value, df) {
data.value[df.fieldname] = value
if (isGridRow) {
triggerOnChange(df.fieldname, data.value)
} else {
triggerOnChange(df.fieldname)
}
}
</script> </script>
<style scoped> <style scoped>
:deep(.form-control.prefix select) { :deep(.form-control.prefix select) {

View File

@ -34,6 +34,10 @@ const props = defineProps({
type: String, type: String,
default: 'CRM Lead', default: 'CRM Lead',
}, },
isGridRow: {
type: Boolean,
default: false,
},
preview: { preview: {
type: Boolean, type: Boolean,
default: false, default: false,
@ -55,6 +59,7 @@ provide(
provide('hasTabs', hasTabs) provide('hasTabs', hasTabs)
provide('doctype', props.doctype) provide('doctype', props.doctype)
provide('preview', props.preview) provide('preview', props.preview)
provide('isGridRow', props.isGridRow)
</script> </script>
<style scoped> <style scoped>
.section:not(:has(.field)) { .section:not(:has(.field)) {

View File

@ -101,6 +101,7 @@
v-model="settings.doc.dropdown_items" v-model="settings.doc.dropdown_items"
doctype="CRM Dropdown Item" doctype="CRM Dropdown Item"
parentDoctype="FCRM Settings" parentDoctype="FCRM Settings"
parentFieldname="dropdown_items"
/> />
</div> </div>
</div> </div>

View File

@ -1,5 +1,8 @@
<template> <template>
<div class="sections flex flex-col overflow-y-auto"> <div
v-if="!document.get.loading"
class="sections flex flex-col overflow-y-auto"
>
<template v-for="(section, i) in _sections" :key="section.name"> <template v-for="(section, i) in _sections" :key="section.name">
<div v-if="section.visible" class="section flex flex-col"> <div v-if="section.visible" class="section flex flex-col">
<div <div
@ -67,21 +70,21 @@
class="flex h-7 cursor-pointer items-center px-2 py-1 text-ink-gray-5" class="flex h-7 cursor-pointer items-center px-2 py-1 text-ink-gray-5"
> >
<Tooltip :text="__(field.tooltip)"> <Tooltip :text="__(field.tooltip)">
<div>{{ data[field.fieldname] }}</div> <div>{{ document.doc[field.fieldname] }}</div>
</Tooltip> </Tooltip>
</div> </div>
<div v-else-if="field.fieldtype === 'Dropdown'"> <div v-else-if="field.fieldtype === 'Dropdown'">
<NestedPopover> <NestedPopover>
<template #target="{ open }"> <template #target="{ open }">
<Button <Button
:label="data[field.fieldname]" :label="document.doc[field.fieldname]"
class="dropdown-button flex w-full items-center justify-between rounded border border-gray-100 bg-surface-gray-2 px-2 py-1.5 text-base text-ink-gray-8 placeholder-ink-gray-4 transition-colors hover:border-outline-gray-modals hover:bg-surface-gray-3 focus:border-outline-gray-4 focus:bg-surface-white focus:shadow-sm focus:outline-none focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3" class="dropdown-button flex w-full items-center justify-between rounded border border-gray-100 bg-surface-gray-2 px-2 py-1.5 text-base text-ink-gray-8 placeholder-ink-gray-4 transition-colors hover:border-outline-gray-modals hover:bg-surface-gray-3 focus:border-outline-gray-4 focus:bg-surface-white focus:shadow-sm focus:outline-none focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3"
> >
<div <div
v-if="data[field.fieldname]" v-if="document.doc[field.fieldname]"
class="truncate" class="truncate"
> >
{{ data[field.fieldname] }} {{ document.doc[field.fieldname] }}
</div> </div>
<div <div
v-else v-else
@ -138,13 +141,9 @@
v-else-if="field.fieldtype == 'Check'" v-else-if="field.fieldtype == 'Check'"
class="form-control" class="form-control"
type="checkbox" type="checkbox"
v-model="data[field.fieldname]" v-model="document.doc[field.fieldname]"
@change.stop=" @change.stop="
emit( fieldChange($event.target.checked, field)
'update',
field.fieldname,
$event.target.checked,
)
" "
:disabled="Boolean(field.read_only)" :disabled="Boolean(field.read_only)"
/> />
@ -159,43 +158,40 @@
" "
class="form-control" class="form-control"
type="textarea" type="textarea"
:value="data[field.fieldname]" :value="document.doc[field.fieldname]"
:placeholder="field.placeholder" :placeholder="field.placeholder"
:debounce="500" :debounce="500"
@change.stop=" @change.stop="fieldChange($event.target.value, field)"
emit('update', field.fieldname, $event.target.value)
"
/> />
<FormControl <FormControl
v-else-if="field.fieldtype === 'Select'" v-else-if="field.fieldtype === 'Select'"
class="form-control cursor-pointer [&_select]:cursor-pointer truncate" class="form-control cursor-pointer [&_select]:cursor-pointer truncate"
type="select" type="select"
v-model="data[field.fieldname]" v-model="document.doc[field.fieldname]"
:options="field.options" :options="field.options"
:placeholder="field.placeholder" :placeholder="field.placeholder"
@change.stop=" @change.stop="fieldChange($event.target.value, field)"
emit('update', field.fieldname, $event.target.value)
"
/> />
<Link <Link
v-else-if="field.fieldtype === 'User'" v-else-if="field.fieldtype === 'User'"
class="form-control" class="form-control"
:value=" :value="
data[field.fieldname] && document.doc[field.fieldname] &&
getUser(data[field.fieldname]).full_name getUser(document.doc[field.fieldname]).full_name
" "
doctype="User" doctype="User"
:filters="field.filters" :filters="field.filters"
@change=" @change="(v) => fieldChange(v, field)"
(data) => emit('update', field.fieldname, data)
"
:placeholder="'Select' + ' ' + field.label + '...'" :placeholder="'Select' + ' ' + field.label + '...'"
:hideMe="true" :hideMe="true"
> >
<template v-if="data[field.fieldname]" #prefix> <template
v-if="document.doc[field.fieldname]"
#prefix
>
<UserAvatar <UserAvatar
class="mr-1.5" class="mr-1.5"
:user="data[field.fieldname]" :user="document.doc[field.fieldname]"
size="sm" size="sm"
/> />
</template> </template>
@ -219,17 +215,15 @@
['Link', 'Dynamic Link'].includes(field.fieldtype) ['Link', 'Dynamic Link'].includes(field.fieldtype)
" "
class="form-control select-text" class="form-control select-text"
:value="data[field.fieldname]" :value="document.doc[field.fieldname]"
:doctype=" :doctype="
field.fieldtype == 'Link' field.fieldtype == 'Link'
? field.options ? field.options
: data[field.options] : document.doc[field.options]
" "
:filters="field.filters" :filters="field.filters"
:placeholder="field.placeholder" :placeholder="field.placeholder"
@change=" @change="(v) => fieldChange(v, field)"
(data) => emit('update', field.fieldname, data)
"
:onCreate="field.create" :onCreate="field.create"
/> />
<div <div
@ -238,15 +232,13 @@
> >
<DateTimePicker <DateTimePicker
icon-left="" icon-left=""
:value="data[field.fieldname]" :value="document.doc[field.fieldname]"
:formatter=" :formatter="
(date) => getFormat(date, '', true, true) (date) => getFormat(date, '', true, true)
" "
:placeholder="field.placeholder" :placeholder="field.placeholder"
placement="left-start" placement="left-start"
@change=" @change="(v) => fieldChange(v, field)"
(data) => emit('update', field.fieldname, data)
"
/> />
</div> </div>
<div <div
@ -255,81 +247,69 @@
> >
<DatePicker <DatePicker
icon-left="" icon-left=""
:value="data[field.fieldname]" :value="document.doc[field.fieldname]"
:formatter="(date) => getFormat(date, '', true)" :formatter="(date) => getFormat(date, '', true)"
:placeholder="field.placeholder" :placeholder="field.placeholder"
placement="left-start" placement="left-start"
@change=" @change="(v) => fieldChange(v, field)"
(data) => emit('update', field.fieldname, data)
"
/> />
</div> </div>
<FormControl <FormControl
v-else-if="field.fieldtype === 'Percent'" v-else-if="field.fieldtype === 'Percent'"
class="form-control" class="form-control"
type="text" type="text"
:value="getFormattedPercent(field.fieldname, data)" :value="
getFormattedPercent(field.fieldname, document.doc)
"
:placeholder="field.placeholder" :placeholder="field.placeholder"
:debounce="500" :debounce="500"
@change.stop=" @change.stop="
emit( fieldChange(flt($event.target.value), field)
'update',
field.fieldname,
flt($event.target.value),
)
" "
/> />
<FormControl <FormControl
v-else-if="field.fieldtype === 'Int'" v-else-if="field.fieldtype === 'Int'"
class="form-control" class="form-control"
type="number" type="number"
v-model="data[field.fieldname]" v-model="document.doc[field.fieldname]"
:placeholder="field.placeholder" :placeholder="field.placeholder"
:debounce="500" :debounce="500"
@change.stop=" @change.stop="fieldChange($event.target.value, field)"
emit('update', field.fieldname, $event.target.value)
"
/> />
<FormControl <FormControl
v-else-if="field.fieldtype === 'Float'" v-else-if="field.fieldtype === 'Float'"
class="form-control" class="form-control"
type="text" type="text"
:value="getFormattedFloat(field.fieldname, data)" :value="
getFormattedFloat(field.fieldname, document.doc)
"
:placeholder="field.placeholder" :placeholder="field.placeholder"
:debounce="500" :debounce="500"
@change.stop=" @change.stop="
emit( fieldChange(flt($event.target.value), field)
'update',
field.fieldname,
flt($event.target.value),
)
" "
/> />
<FormControl <FormControl
v-else-if="field.fieldtype === 'Currency'" v-else-if="field.fieldtype === 'Currency'"
class="form-control" class="form-control"
type="text" type="text"
:value="getFormattedCurrency(field.fieldname, data)" :value="
getFormattedCurrency(field.fieldname, document.doc)
"
:placeholder="field.placeholder" :placeholder="field.placeholder"
:debounce="500" :debounce="500"
@change.stop=" @change.stop="
emit( fieldChange(flt($event.target.value), field)
'update',
field.fieldname,
flt($event.target.value),
)
" "
/> />
<FormControl <FormControl
v-else v-else
class="form-control" class="form-control"
type="text" type="text"
:value="data[field.fieldname]" :value="document.doc[field.fieldname]"
:placeholder="field.placeholder" :placeholder="field.placeholder"
:debounce="500" :debounce="500"
@change.stop=" @change.stop="fieldChange($event.target.value, field)"
emit('update', field.fieldname, $event.target.value)
"
/> />
</div> </div>
<div class="ml-1"> <div class="ml-1">
@ -337,19 +317,23 @@
v-if=" v-if="
field.fieldtype === 'Link' && field.fieldtype === 'Link' &&
field.link && field.link &&
data[field.fieldname] document.doc[field.fieldname]
" "
class="h-4 w-4 shrink-0 cursor-pointer text-ink-gray-5 hover:text-ink-gray-8" class="h-4 w-4 shrink-0 cursor-pointer text-ink-gray-5 hover:text-ink-gray-8"
@click.stop="field.link(data[field.fieldname])" @click.stop="
field.link(document.doc[field.fieldname])
"
/> />
<EditIcon <EditIcon
v-if=" v-if="
field.fieldtype === 'Link' && field.fieldtype === 'Link' &&
field.edit && field.edit &&
data[field.fieldname] document.doc[field.fieldname]
" "
class="size-3.5 shrink-0 cursor-pointer text-ink-gray-5 hover:text-ink-gray-8" class="size-3.5 shrink-0 cursor-pointer text-ink-gray-5 hover:text-ink-gray-8"
@click.stop="field.edit(data[field.fieldname])" @click.stop="
field.edit(document.doc[field.fieldname])
"
/> />
</div> </div>
</div> </div>
@ -386,6 +370,7 @@ import { isMobileView } from '@/composables/settings'
import { getFormat, evaluateDependsOnValue } from '@/utils' import { getFormat, evaluateDependsOnValue } from '@/utils'
import { flt } from '@/utils/numberFormat.js' import { flt } from '@/utils/numberFormat.js'
import { Tooltip, DateTimePicker, DatePicker } from 'frappe-ui' import { Tooltip, DateTimePicker, DatePicker } from 'frappe-ui'
import { useDocument } from '@/data/document'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
const props = defineProps({ const props = defineProps({
@ -395,6 +380,11 @@ const props = defineProps({
doctype: { doctype: {
type: String, type: String,
default: 'CRM Lead', default: 'CRM Lead',
required: true,
},
docname: {
type: String,
required: true,
}, },
preview: { preview: {
type: Boolean, type: Boolean,
@ -407,13 +397,15 @@ const props = defineProps({
const { getFormattedPercent, getFormattedFloat, getFormattedCurrency } = const { getFormattedPercent, getFormattedFloat, getFormattedCurrency } =
getMeta(props.doctype) getMeta(props.doctype)
const { isManager, getUser } = usersStore() const { isManager, getUser } = usersStore()
const emit = defineEmits(['update', 'reload']) const emit = defineEmits(['update', 'reload'])
const data = defineModel()
const showSidePanelModal = ref(false) const showSidePanelModal = ref(false)
const { document, triggerOnChange } = useDocument(props.doctype, props.docname)
const _sections = computed(() => { const _sections = computed(() => {
if (!props.sections?.length) return [] if (!props.sections?.length) return []
let editButtonAdded = false let editButtonAdded = false
@ -453,11 +445,11 @@ function parsedField(field) {
placeholder: field.placeholder || field.label, placeholder: field.placeholder || field.label,
display_via_depends_on: evaluateDependsOnValue( display_via_depends_on: evaluateDependsOnValue(
field.depends_on, field.depends_on,
data.value, document.doc,
), ),
mandatory_via_depends_on: evaluateDependsOnValue( mandatory_via_depends_on: evaluateDependsOnValue(
field.mandatory_depends_on, field.mandatory_depends_on,
data.value, document.doc,
), ),
} }
@ -465,6 +457,14 @@ function parsedField(field) {
return _field return _field
} }
async function fieldChange(value, df) {
document.doc[df.fieldname] = value
await triggerOnChange(df.fieldname)
document.save.submit()
}
function parsedSection(section, editButtonAdded) { function parsedSection(section, editButtonAdded) {
let isContactSection = section.name == 'contacts_section' let isContactSection = section.name == 'contacts_section'
section.showEditButton = !( section.showEditButton = !(
@ -485,7 +485,7 @@ function isFieldVisible(field) {
if (props.preview) return true if (props.preview) return true
return ( return (
(field.fieldtype == 'Check' || (field.fieldtype == 'Check' ||
(field.read_only && data.value[field.fieldname]) || (field.read_only && document.doc?.[field.fieldname]) ||
!field.read_only) && !field.read_only) &&
(!field.depends_on || field.display_via_depends_on) && (!field.depends_on || field.display_via_depends_on) &&
!field.hidden !field.hidden

View File

@ -0,0 +1,136 @@
import { getScript } from '@/data/script'
import { createToast, runSequentially } from '@/utils'
import { createDocumentResource } from 'frappe-ui'
const documentsCache = {}
const controllersCache = {}
export function useDocument(doctype, docname) {
const { setupScript } = getScript(doctype)
documentsCache[doctype] = documentsCache[doctype] || {}
if (!documentsCache[doctype][docname]) {
documentsCache[doctype][docname] = createDocumentResource({
doctype: doctype,
name: docname,
onSuccess: () => setupFormScript(),
setValue: {
onSuccess: () => {
createToast({
title: __('Document updated successfully'),
icon: 'check',
iconClasses: 'text-ink-green-3',
})
},
onError: (err) => {
createToast({
title: __('Error updating document'),
text: err.messages[0],
icon: 'x',
iconClasses: 'text-red-600',
})
},
},
})
}
function setupFormScript() {
if (controllersCache[doctype]) return
controllersCache[doctype] = setupScript(documentsCache[doctype][docname])
}
function getControllers(row = null) {
const _doctype = row?.doctype || doctype
return (controllersCache[doctype] || []).filter(
(c) => c.constructor.name === _doctype.replace(/\s+/g, ''),
)
}
async function triggerOnRefresh() {
const handler = async function () {
await this.refresh()
}
await trigger(handler)
}
async function triggerOnChange(fieldname, row) {
const handler = async function () {
if (row) {
this.currentRowIdx = row.idx
this.value = row[fieldname]
this.oldValue = getOldValue(fieldname, row)
} else {
this.value = documentsCache[doctype][docname].doc[fieldname]
this.oldValue = getOldValue(fieldname)
}
await this[fieldname]?.()
}
await trigger(handler, row)
}
async function triggerOnRowAdd(row) {
const handler = async function () {
this.currentRowIdx = row.idx
this.value = row
await this[row.parentfield + '_add']?.()
}
await trigger(handler, row)
}
async function triggerOnRowRemove(selectedRows, rows) {
const handler = async function () {
if (selectedRows.size === 1) {
const selectedRow = Array.from(selectedRows)[0]
this.currentRowIdx = rows.find((r) => r.name === selectedRow).idx
} else {
delete this.currentRowIdx
}
this.selectedRows = Array.from(selectedRows)
this.rows = rows
await this[rows[0].parentfield + '_remove']?.()
}
await trigger(handler, rows[0])
}
async function trigger(taskFn, row = null) {
const controllers = getControllers(row)
if (!controllers.length) return
const tasks = controllers.map(
(controller) => async () => await taskFn.call(controller),
)
await runSequentially(tasks)
}
function getOldValue(fieldname, row) {
if (!documentsCache[doctype][docname]) return ''
const document = documentsCache[doctype][docname]
const oldDoc = document.originalDoc
if (row?.name) {
return oldDoc?.[row.parentfield]?.find((r) => r.name === row.name)?.[
fieldname
]
}
return oldDoc?.[fieldname] || document.doc[fieldname]
}
return {
document: documentsCache[doctype][docname],
triggerOnChange,
triggerOnRowAdd,
triggerOnRowRemove,
triggerOnRefresh,
setupFormScript,
}
}

257
frontend/src/data/script.js Normal file
View File

@ -0,0 +1,257 @@
import { globalStore } from '@/stores/global'
import { getMeta } from '@/stores/meta'
import { createToast } from '@/utils'
import { call, createListResource } from 'frappe-ui'
import { reactive } from 'vue'
import router from '@/router'
const doctypeScripts = reactive({})
export function getScript(doctype, view = 'Form') {
const scripts = createListResource({
doctype: 'CRM Form Script',
cache: ['Form Scripts', doctype, view],
fields: ['name', 'dt', 'view', 'script'],
filters: { view, dt: doctype, enabled: 1 },
onSuccess: (_scripts) => {
for (let script of _scripts) {
if (!doctypeScripts[doctype]) {
doctypeScripts[doctype] = {}
}
doctypeScripts[doctype][script.name] = script || {}
}
},
})
if (!doctypeScripts[doctype] && !scripts.loading) {
scripts.fetch()
}
function setupScript(document, helpers = {}) {
let scripts = doctypeScripts[doctype]
if (!scripts) return null
const { $dialog, $socket, makeCall } = globalStore()
helpers.createDialog = $dialog
helpers.createToast = createToast
helpers.socket = $socket
helpers.router = router
helpers.call = call
helpers.crm = {
makePhoneCall: makeCall,
}
return setupMultipleFormControllers(scripts, document, helpers)
}
function setupMultipleFormControllers(scriptStrings, document, helpers) {
const controllers = []
let parentInstanceIdx = null
for (let scriptName in scriptStrings) {
let script = scriptStrings[scriptName]?.script
if (!script) continue
try {
const classNames = getClassNames(script)
if (!classNames) continue
classNames.forEach((className) => {
const FormClass = evaluateFormClass(script, className, helpers)
if (!FormClass) return
let parentInstance = null
let doctypeName = doctype.replace(/\s+/g, '')
let { doctypeMeta } = getMeta(doctype)
// if className is not doctype name, then it is a child doctype
let isChildDoctype = className !== doctypeName
if (isChildDoctype) {
if (!controllers.length) {
console.error(
__(
'⚠️ No class found for doctype: {0}, it is mandatory to have a class for the parent doctype. it can be empty, but it should be present.',
[doctype],
),
)
return
}
parentInstance = controllers[parentInstanceIdx]
} else {
parentInstanceIdx = controllers.length || 0
}
const instance = setupFormController(
FormClass,
doctypeMeta,
document,
parentInstance,
isChildDoctype,
)
controllers.push(instance)
})
} catch (err) {
console.error(__('Failed to load form controller: {0}', [err]))
}
}
return controllers
}
function setupFormController(
FormClass,
meta,
document,
parentInstance = null,
isChildDoctype = false,
) {
let instance = new FormClass()
for (const key in document) {
if (document.hasOwnProperty(key)) {
instance[key] = document[key]
}
}
instance.getMeta = async (doctype) => {
if (!meta[doctype]) {
await getMeta(doctype)
return meta[doctype]
}
return meta[doctype]
}
setupHelperMethods(FormClass, instance, parentInstance, document)
if (isChildDoctype) {
instance.doc = createDocProxy(document.doc, parentInstance)
} else {
instance.doc = createDocProxy(document.doc, instance)
}
return instance
}
function setupHelperMethods(FormClass, instance, parentInstance, document) {
if (typeof FormClass.prototype.getRow !== 'function') {
FormClass.prototype.getRow = (parentField, idx) =>
getRow(parentField, idx, document.doc, instance)
}
exposeHiddenMethods(instance, parentInstance, ['getRow'])
}
function getRow(parentField, idx, data, instance) {
idx = idx || instance.currentRowIdx
if (!data[parentField]) {
console.warn(__('⚠️ No data found for parent field: {0}', [parentField]))
return null
}
const row = data[parentField].find((r) => r.idx === idx)
if (!row) {
console.warn(
__('⚠️ No row found for idx: {0} in parent field: {1}', [
idx,
parentField,
]),
)
return null
}
row.parent = row.parent || data.name
return createDocProxy(row, instance)
}
// utility function to setup a form controller
function getClassNames(script) {
const withoutComments = script
.replace(/\/\/.*$/gm, '') // Remove single-line comments
.replace(/\/\*[\s\S]*?\*\//g, '') // Remove multi-line comments
// Match class declarations
return (
[...withoutComments.matchAll(/class\s+([A-Za-z0-9_]+)/g)].map(
(match) => match[1],
) || []
)
}
function evaluateFormClass(script, className, helpers = {}) {
const helperKeys = Object.keys(helpers)
const helperValues = Object.values(helpers)
const wrappedScript = `
${script}
return ${className};
`
const FormClass = new Function(...helperKeys, wrappedScript)(
...helperValues,
)
return FormClass
}
function createDocProxy(data, instance) {
return new Proxy(data, {
get(target, prop) {
if (prop === 'trigger') {
if ('trigger' in data) {
console.warn(
__(
'⚠️ Avoid using "trigger" as a field name — it conflicts with the built-in trigger() method.',
),
)
}
return (methodName, ...args) => {
const method = instance[methodName]
if (typeof method === 'function') {
return method.apply(instance, args)
} else {
console.warn(
__('⚠️ Method "{0}" not found in class.', [methodName]),
)
}
}
}
return target[prop]
},
set(target, prop, value) {
target[prop] = value
return true
},
})
}
function exposeHiddenMethods(instance, parentInstance, methodNames = []) {
for (const name of methodNames) {
// remove the method from parent instance if it exists
if (parentInstance && parentInstance[name]) {
delete instance.doc[name]
}
if (typeof instance[name] === 'function' && !instance.doc[name]) {
// Show as actual method on doc, bound to instance
Object.defineProperty(instance.doc, name, {
value: (...args) => instance[name](...args),
writable: false,
enumerable: false,
configurable: true,
})
}
}
}
return {
scripts,
setupScript,
setupFormController,
}
}

View File

@ -129,10 +129,10 @@
class="flex flex-1 flex-col justify-between overflow-hidden" class="flex flex-1 flex-col justify-between overflow-hidden"
> >
<SidePanelLayout <SidePanelLayout
v-model="deal.data"
:sections="sections.data" :sections="sections.data"
:addContact="addContact" :addContact="addContact"
doctype="CRM Deal" doctype="CRM Deal"
:docname="deal.data.name"
@update="updateField" @update="updateField"
@reload="sections.reload" @reload="sections.reload"
> >

View File

@ -182,9 +182,9 @@
class="flex flex-1 flex-col justify-between overflow-hidden" class="flex flex-1 flex-col justify-between overflow-hidden"
> >
<SidePanelLayout <SidePanelLayout
v-model="lead.data"
:sections="sections.data" :sections="sections.data"
doctype="CRM Lead" doctype="CRM Lead"
:docname="lead.data.name"
@update="updateField" @update="updateField"
@reload="sections.reload" @reload="sections.reload"
/> />

View File

@ -152,6 +152,7 @@ export function setupAssignees(doc) {
} }
async function getFormScript(script, obj) { async function getFormScript(script, obj) {
if (!script.includes('setupForm(')) return {}
let scriptFn = new Function(script + '\nreturn setupForm')() let scriptFn = new Function(script + '\nreturn setupForm')()
let formScript = await scriptFn(obj) let formScript = await scriptFn(obj)
return formScript || {} return formScript || {}
@ -348,3 +349,9 @@ export function getRandom(len = 4) {
return text return text
} }
export function runSequentially(functions) {
return functions.reduce((promise, fn) => {
return promise.then(() => fn())
}, Promise.resolve())
}