Issues Customizing Task Form in CRM Portal – File Attachments, Field Visibility & Custom UI Logic

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

  1. 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

  1. 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.

  1. 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>