Hi everyone,I’m customizing the CRM Portal Task Form in a Frappe + ERPNext setup, and I’ve run into several issues while trying to modify the Task creation UI. I’m hoping for guidance on whether my approach is correct, or if there’s a better recommended pattern. Since CRM has a completely different form rendering frontend technique than the ERPNext form engine, there’s a bit of a confusion.We need to customize the Task form inside the CRM Portal so that:
-
A file/image can be uploaded directly from the portal (Attach field).
-
The Task modal UI changes dynamically based on the “Task Type.”
-
Custom fields appear or hide depending on the selected Task Type.
-
The Task creation UX behaves differently from Desk, but still binds to the same underlying Task DocType.
We want to do this without modifying core Frappe/ERPNext files, to maintain forward compatibility.Problems We Encountered
- Built-in FileUploader Component Didn’t Work
The default inside Field.vue was not setting the attachment on the Task record.
-
File chooser appeared
-
But the uploaded file was always NULL in the database
-
No errors in console
-
A separate custom component (custom-fileuploader.vue) also didn’t work reliably
We then tried replacing the file upload block entirely with:
-
uploadFile from frappe-ui (not exported)
-
call(“upload_file”) manually (worked partially)
-
Finally attempted using the full FilesUploader.vue from the desk codebase
- Replacing the Field Component Broke ALL Fields
After integrating FilesUploader.vue and editing fieldChange() inside Field.vue, the entire CRM Task form went blank.
No fields rendered at all.
We suspect it was triggered by one of these:
-
Injected data becoming undefined
-
field.visible computed returning false for everything
-
A runtime error in the Field template (Vue aborts rendering on error)
-
Mistakes while passing doctype, docname, or the v-model binding
-
Modification of props.field directly inside a computed property
Desk-side Task Form still worked fine, only CRM portal broke.
- Dynamic UI Logic Became Fragile
Beyond uploading files, we also customized Task fields dynamically:
-
Show/hide fields based on Task Type
-
Auto-fill certain fields
-
Custom grids and conditional fields
But modifying Field.vue became very fragile because even small changes to the component caused rendering failures.We Already Tried
-
Injecting the core FilesUploader logic
-
Rewriting the Attach field logic
-
Using call(‘upload_file’) with FormData
-
Adding extra guards around triggerOnChange, fieldChange, and injected props
-
Comparing the CRM Portal version of Field.vue with Desk version
Questions for the Community
-
What is the correct/safe way to implement Attach fields in CRM Portal?
-
Should we reuse Desk’s FilesUploader, use call(“upload_file”), or something else?
-
Is it recommended to override Field.vue for the CRM Portal? Or is there a cleaner extension mechanism?
-
Is there a standard way to add dynamic UI logic (conditional fields, custom Task behavior) without modifying core CRM Portal components?
-
Are CRM Portal components meant to be customized directly, or should we fork / override at the app level?
-
Has anyone successfully extended or replaced Task creation UI in CRM Portal with complex behaviors? Any best practices?
We want to heavily customize Task behavior for our portal users, but the moment we modify the Field.vue internals, updates become risky and form stability becomes unpredictable.We want to understand the best long-term approach before we proceed further.Thanks in advance to anyone who has tackled similar deep CRM Portal customizations!
Below are some screenshots of the custom CRM Task form, as well as the edited Fields.vue for your reference
Field.vue
<template>
<div v-if="field.visible" class="field">
<div v-if="field.fieldtype != 'Check'" class="mb-2 text-sm text-ink-gray-5">
{{ __(field.label) }}
<span
v-if="
field.reqd ||
(field.mandatory_depends_on && field.mandatory_via_depends_on)
"
class="text-ink-red-2"
>*</span
>
</div>
<FormControl
v-if="
field.read_only &&
!['Int', 'Float', 'Currency', 'Percent', 'Check'].includes(
field.fieldtype,
)
"
type="text"
:placeholder="getPlaceholder(field)"
v-model="data[field.fieldname]"
:disabled="true"
:description="field.description"
/>
<Grid
v-else-if="field.fieldtype === 'Table'"
v-model="data[field.fieldname]"
v-model:parent="data"
:doctype="field.options"
:parentDoctype="doctype"
:parentFieldname="field.fieldname"
/>
<FormControl
v-else-if="field.fieldtype === 'Select'"
type="select"
class="form-control"
:class="field.prefix ? 'prefix' : ''"
:options="field.options"
v-model="data[field.fieldname]"
@change="(e) => fieldChange(e.target.value, field)"
:placeholder="getPlaceholder(field)"
:description="field.description"
>
<template v-if="field.prefix" #prefix>
<IndicatorIcon :class="field.prefix" />
</template>
</FormControl>
<div v-else-if="field.fieldtype == 'Check'" class="flex items-center gap-2">
<FormControl
class="form-control"
type="checkbox"
v-model="data[field.fieldname]"
@change="(e) => fieldChange(e.target.checked, field)"
:disabled="Boolean(field.read_only)"
:description="field.description"
/>
<label
class="text-sm text-ink-gray-5"
@click="
() => {
if (!Boolean(field.read_only)) {
data[field.fieldname] = !data[field.fieldname]
}
}
"
>
{{ __(field.label) }}
<span class="text-ink-red-3" v-if="field.mandatory">*</span>
</label>
</div>
<div
class="flex gap-1"
v-else-if="['Link', 'Dynamic Link'].includes(field.fieldtype)"
>
<Link
class="form-control flex-1 truncate"
:value="data[field.fieldname]"
:doctype="
field.fieldtype == 'Link' ? field.options : data[field.options]
"
:filters="field.filters"
@change="(v) => fieldChange(v, field)"
:placeholder="getPlaceholder(field)"
:onCreate="field.create"
/>
<Button
v-if="data[field.fieldname] && field.edit"
class="shrink-0"
:label="__('Edit')"
:iconLeft="EditIcon"
@click="field.edit(data[field.fieldname])"
/>
</div>
<TableMultiselectInput
v-else-if="field.fieldtype === 'Table MultiSelect'"
v-model="data[field.fieldname]"
:doctype="field.options"
@change="(v) => fieldChange(v, field)"
/>
<Link
v-else-if="field.fieldtype === 'User'"
class="form-control"
:value="data[field.fieldname] && getUser(data[field.fieldname]).full_name"
:doctype="field.options"
:filters="field.filters"
@change="(v) => fieldChange(v, field)"
:placeholder="getPlaceholder(field)"
:hideMe="true"
>
<template #prefix>
<UserAvatar
v-if="data[field.fieldname]"
class="mr-2"
:user="data[field.fieldname]"
size="sm"
/>
</template>
<template #item-prefix="{ option }">
<UserAvatar class="mr-2" :user="option.value" size="sm" />
</template>
<template #item-label="{ option }">
<Tooltip :text="option.value">
<div class="cursor-pointer">
{{ getUser(option.value).full_name }}
</div>
</Tooltip>
</template>
</Link>
<TimePicker
v-else-if="field.fieldtype === 'Time'"
:value="data[field.fieldname]"
:format="getFormat('', '', false, true, false)"
:placeholder="getPlaceholder(field)"
input-class="border-none"
@change="(v) => fieldChange(v, field)"
/>
<DateTimePicker
v-else-if="field.fieldtype === 'Datetime'"
:value="data[field.fieldname]"
:format="getFormat('', '', true, true, false)"
:placeholder="getPlaceholder(field)"
input-class="border-none"
@change="(v) => fieldChange(v, field)"
/>
<DatePicker
v-else-if="field.fieldtype === 'Date'"
:value="data[field.fieldname]"
:format="getFormat('', '', true, false, false)"
:placeholder="getPlaceholder(field)"
input-class="border-none"
@change="(v) => fieldChange(v, field)"
/>
<FileUploader
v-else-if="field.fieldtype === 'Attach' || field.fieldtype === 'Attach Image'"
v-model="data[field.fieldname]"
:doctype="doctype"
:fieldname="field.fieldname"
:label="field.label"
@change="(v) => fieldChange(v, field)"
/>
<FormControl
v-else-if="
['Small Text', 'Text', 'Long Text', 'Code'].includes(field.fieldtype)
"
type="textarea"
:value="data[field.fieldname]"
:placeholder="getPlaceholder(field)"
:description="field.description"
@change="fieldChange($event.target.value, field)"
/>
<Password
v-else-if="field.fieldtype === 'Password'"
:value="data[field.fieldname]"
:placeholder="getPlaceholder(field)"
:description="field.description"
@change="fieldChange($event.target.value, field)"
/>
<FormattedInput
v-else-if="field.fieldtype === 'Int'"
type="text"
:placeholder="getPlaceholder(field)"
:value="data[field.fieldname] || '0'"
:disabled="Boolean(field.read_only)"
:description="field.description"
@change="fieldChange($event.target.value, field)"
/>
<FormattedInput
v-else-if="field.fieldtype === 'Percent'"
type="text"
:value="getFormattedPercent(field.fieldname, data)"
:placeholder="getPlaceholder(field)"
:disabled="Boolean(field.read_only)"
:description="field.description"
@change="fieldChange(flt($event.target.value), field)"
/>
<FormattedInput
v-else-if="field.fieldtype === 'Float'"
type="text"
:value="getFormattedFloat(field.fieldname, data)"
:placeholder="getPlaceholder(field)"
:disabled="Boolean(field.read_only)"
:description="field.description"
@change="fieldChange(flt($event.target.value), field)"
/>
<FormattedInput
v-else-if="field.fieldtype === 'Currency'"
type="text"
:value="getFormattedCurrency(field.fieldname, data, parentDoc)"
:placeholder="getPlaceholder(field)"
:disabled="Boolean(field.read_only)"
:description="field.description"
@change="fieldChange(flt($event.target.value), field)"
/>
<FormControl
v-else
type="text"
:placeholder="getPlaceholder(field)"
:value="getDataValue(data[field.fieldname], field)"
:disabled="Boolean(field.read_only)"
:description="field.description"
@change="fieldChange($event.target.value, field)"
/>
</div>
</template>
<script setup>
import Password from '@/components/Controls/Password.vue'
import FormattedInput from '@/components/Controls/FormattedInput.vue'
import EditIcon from '@/components/Icons/EditIcon.vue'
import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import TableMultiselectInput from '@/components/Controls/TableMultiselectInput.vue'
import Link from '@/components/Controls/Link.vue'
import Grid from '@/components/Controls/Grid.vue'
import FileUploader from '@/components/Controls/FileUploader.vue'
import { createDocument } from '@/composables/document'
import { getFormat, evaluateDependsOnValue } from '@/utils'
import { flt } from '@/utils/numberFormat.js'
import { getMeta } from '@/stores/meta'
import { usersStore } from '@/stores/users'
import { useDocument } from '@/data/document'
import { Tooltip, DatePicker, DateTimePicker, TimePicker } from 'frappe-ui'
import { computed, provide, inject } from 'vue'
const props = defineProps({
field: Object,
})
const data = inject('data')
const doctype = inject('doctype')
const preview = inject('preview')
const isGridRow = inject('isGridRow')
const { getFormattedPercent, getFormattedFloat, getFormattedCurrency } =
getMeta(doctype)
const { users, getUser } = usersStore()
let triggerOnChange
let parentDoc
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', () => {})
parentDoc = inject('parentDoc')
}
const field = computed(() => {
let field = props.field
if (field.fieldtype == 'Select' && typeof field.options === 'string') {
field.options = field.options.split('\n').map((option) => {
return { label: option, value: option }
})
if (field.options[0].value !== '') {
field.options.unshift({ label: '', value: '' })
}
}
if (field.fieldtype === 'Link' && field.options === 'User') {
field.fieldtype = 'User'
field.link_filters = JSON.stringify({
...(field.link_filters ? JSON.parse(field.link_filters) : {}),
name: ['in', users.data.crmUsers?.map((user) => user.name)],
})
}
if (field.fieldtype === 'Link' && field.options !== 'User') {
if (!field.create) {
field.create = (value, close) => {
const callback = (d) => {
if (d) fieldChange(d.name, field)
}
createDocument(field.options, value, close, callback)
}
}
}
let _field = {
...field,
filters: field.link_filters && JSON.parse(field.link_filters),
placeholder: field.placeholder || field.label,
display_via_depends_on: evaluateDependsOnValue(
field.depends_on,
data.value,
),
mandatory_via_depends_on: evaluateDependsOnValue(
field.mandatory_depends_on,
data.value,
),
}
_field.visible = isFieldVisible(_field)
return _field
})
function isFieldVisible(field) {
if (preview.value) return true
const hideEmptyReadOnly = Number(window.sysdefaults?.hide_empty_read_only_fields ?? 1)
const shouldShowReadOnly = field.read_only && (
data.value[field.fieldname] ||
!hideEmptyReadOnly
)
return (
(field.fieldtype == 'Check' ||
shouldShowReadOnly ||
!field.read_only) &&
(!field.depends_on || field.display_via_depends_on) &&
!field.hidden
)
}
const getPlaceholder = (field) => {
if (field.placeholder) {
return __(field.placeholder)
}
if (['Select', 'Link'].includes(field.fieldtype)) {
return __('Select {0}', [__(field.label)])
} else {
return __('Enter {0}', [__(field.label)])
}
}
function fieldChange(value, df) {
// 1. Update the local form model
data.value[df.fieldname] = value
// 2. Notify Frappe triggers
if (isGridRow) {
triggerOnChange(df.fieldname, value, data.value)
} else {
triggerOnChange(df.fieldname, value)
}
}
function getDataValue(value, field) {
if (field.fieldtype === 'Duration') {
return value || 0
}
return value
}
</script>
<style scoped>
:deep(.form-control.prefix select) {
padding-left: 2rem;
}
</style>
