espocrm icon indicating copy to clipboard operation
espocrm copied to clipboard

Extend personName with 'initials'

Open hdijkema opened this issue 4 years ago • 7 comments

personName is a field type in EspoCRM. Suppose I'd like to extend my Contact's name field type 'personName' with 'initials' varchar.

I've tried doing that today and I got a long way. This is what I did: (please read 'initials' where I wrote 'letters')

I added a view to my name of entity Contact:

"name": {
            "type": "nvkhName",
            "view": "custom:views/contact/fields/nvkh-name"
        },

Forget about the type 'nvkhName' for the moment.

So I created the view:

define('custom:views/contact/fields/nvkh-name', 'views/fields/person-name', function (Dep) {

    return Dep.extend({

        detailTemplate: 'custom:fields/nvkh-name/detail',
        editTemplate: 'custom:fields/nvkh-name/edit',
        editTemplateLastFirst: 'custom:fields/nvkh-name/edit-last-first',
        editTemplateLastFirstMiddle: 'custom:fields/nvkh-name/edit-last-first-middle',
        editTemplateFirstMiddleLast: 'custom:fields/nvkh-name/edit-first-middle-last',

        data: function () {
            this.inDataCalc = true;

            var data = Dep.prototype.data.call(this);

            this.inDataCalc = false;

            if (this.model === 'edit') {
               data.lettersMaxlength = this.model.getFieldParam(this.lettersField, 'maxLength');
            }

            data.lettersValue = this.model.get(this.lettersField);

            data.valueIsSet = data.valueIsSet || this.model.has(this.lettersField);

            data.isNotEmpty = data.isNotEmpty || !!data.lettersValue;

            if (data.isNotEmpty && (this.mode === 'detail' || this.mode === 'list' || this.mode === 'listLink')) {
                data.formattedValue = this.getFormattedValue();
            }

            return data;
        },

        setup: function () {
            Dep.prototype.setup.call(this);
           var ucName = Espo.Utils.upperCaseFirst(this.name)
            this.lettersField = 'letters' + ucName;
        },

        afterRender: function () {
            Dep.prototype.afterRender.call(this);

            if (this.mode === 'edit') {
                this.$letters = this.$el.find('[data-name="' + this.lettersField + '"]');

                this.$letters.on('change', function () {
                    this.trigger('change');
                }.bind(this));
            }
        },

        getFormattedValue: function () {

            if (this.inDataCalc) return '';

            var format = this.getFormat();

            var value = '';

            if (format === 'firstLast') {
                var salutation = this.model.get(this.salutationField);
                var first = this.model.get(this.firstField);
                var last = this.model.get(this.lastField);
                var letters = this.model.get(this.lettersField);

                if (salutation) {
                    salutation = this.getLanguage().translateOption(salutation, 'salutationName', this.model.entityType);
                }
                var open = '';
                var close = '';

                if (salutation) value += salutation;
                if (letters) {
                    value += ' ' + letters;
                    open = '(';
                    close = ')';
                }
                if (first) value += ' ' + open + first + close;
                if (last) value += ' ' + last;

                value = value.trim();

                console.log('first='+first+', last='+last+', vl='+letters);
                console.log('value='+value);

            } else {
                value = Dep.prototype.getFormattedValue.call(this);
            }

            return value;
        },

        fetch: function (form) {
            var data = Dep.prototype.fetch.call(this, form);
            data[this.lettersField] = this.$letters.val().trim() || null;
            return data;
        },

    });
});

Also not to difficult. However, this doesn't create the behaviour that I wanted. What it doesn't do is:

  • Write the right name format "letters (first) last" in the list.
  • Write the right name format when details are initially shown "letters (first) last".

Why? Because 'letters' do not get fetched. Strange... Oke. the definition in personName type doesn't contain 'letters' (./application/Espo/Resources/metadata/fields/personName.json).

So I looked in the code and found './application/Espo/Core/ORM/Helper.php'. It contains code to format a personName. And it is customizable, because it gets loaded and the custom loader gets in first. So I created:

custom/Espo/Custom/Core/Loaders/EntityManagerHelper.php:

namespace Espo\Custom\Core\Loaders;

class EntityManagerHelper extends \Espo\Core\Loaders\EntityManagerHelper
{
    public function load()
    {
        return new \Espo\Custom\Core\ORM\NvkhHelper(
            $this->getContainer()->get('config')
        );
    }
}

And a custom helper:

custom/Espo/Custom/Core/ORM/NvkhHelper.php

namespace Espo\Custom\Core\ORM;

use Espo\Core\Utils\Config;

use Espo\ORM\Entity;

class NvkhHelper extends \Espo\Core\ORM\Helper
{
    public function __construct(Config $config)
    {
        parent::__construct($config);
    }

    public function formatPersonName(Entity $entity, string $field)
    {
        if ($entity->getEntityType() === 'Contact') {

            $first = $entity->get('first' . ucfirst($field));
            $last = $entity->get('last' . ucfirst($field));
            $letters = $entity->get('letters' . ucfirst($field));

            if (!$first && !$last && !$letters) {
                return null;
            }

            $arr = [];
            if ($letters) $arr[] = $letters;
            if ($first) {
               if ($letters) $arr[] = "($first)";
               else $arr[] = $first;
            }
            if ($last) $arr[] = $last;

            return implode(' ', $arr);

        } else {
            return parent::formatPersonName($entity, $field);
        }

    }
}

Oke, now we're getting somewhere. When I click on a Contact, the details are shown. Initially with only firstname, lastname; but then a refresh is made and it shows 'letters (firstname) lastname'. Yes!

However, I still don't get the 'letters (firstname) lastname' entries in a list.

Oke. let's create a new type then. nvkhName. I created it and put it here: ./custom/Espo/Custom/Resources/metadata/fields/nvkhName.json. I added 'letters' as a type to this nvkhName.json file. It wasn't difficult to do.

Now, I refer to this nvkhName type. See json file on top of this issue. But then, the backend starts complaining with a code 500. Why?

After some searching I find "application/Espo/Core/Utils/Database/Orm/Base.php". It contains a function: protected function getForeignField(string $name, string $entityType)

And this actually has a hard reference to 'personType', which converts the 'name' field into a list of fields. When I add this code:

        (...)
        if (isset($foreignField['type']) && $foreignField['type'] == 'nvkhName') {
            return [ 'letters' . ucfirst($name), ' ', 'first' . ucfirst($name), ' ', 'last' . ucfirst($name) ];
        } 
        (...)

All starts working smoothly. Error 500 disappears. However, now I've made changes to the core of EspoCrm. This is no customization. This is a patch. I don't like that.

Any advice? What can I do in this case? I don't think I can customize this class...

hdijkema avatar May 22 '20 21:05 hdijkema

After some thinking, I would like to propose a different implementation of the getForeignField() function in application/Espo/Core/Utils/Database/Orm/Base.php:

    protected function getForeignField(string $name, string $entityType)
    {
        $foreignField = $this->getMetadata()->get(['entityDefs', $entityType, 'fields', $name]);

        if (isset($foreignField['type'])) {
            if (isset($foreignField['fieldOrder'])) {
                $fieldDefs = $foreignField['fieldOrder'];
            } else {
                $foreignType = $foreignField['type'];
                $personNameFormat = $this->config->get('personNameFormat');
                $fieldsMeta = $this->getMetadata()->get('fields');
                if (isset($fieldsMeta[$foreignType])) {
                   $fieldMeta = $fieldsMeta[$foreignType];
                   if (isset($fieldMeta['fieldOrders'])) {
                       $fieldOrders = $fieldMeta['fieldOrders'];
                       if (isset($fieldOrders[$personNameFormat])) {
                           $fieldDefs = $fieldOrders[$personNameFormat];
                       } else if (isset($fieldOrders['default'])) {
                           $fieldDefs = $fieldOrders['default'];
                       }
                   }
                }
            }

            if ($fieldDefs) {
               $retval = array();
               $space = '';
               foreach($fieldDefs as $field) {
                   if ($space != '') { array_push($retval, $space); }
                   array_push($retval, $field . ucfirst($name));
                   $space = ' ';
               }
               return $retval;
            }
        }

        return $name;
    }

So instead of hard-coding personName as a type in getForeignField, I'm proposing to solve this in the metadata of fields. In this metadata I added a section 'fieldOrders':

    (...)
    "fieldOrders": {
         "default": [ "letters", "first", "last" ],
         "lastFirst": [ "last", "letters", "first" ],
         "lastFirstMiddle": [ "last", "letters", "first", "middle" ],
         "firstMiddleLast": [ "letters", "first", "middle", "last" ],
         "firstLast": [ "letters", "first", "last" ]
    }
    (...)

And in the entity definitions, I'm proposing a json field for a type, e.g. my Contact.json, which overrides all "fieldOrders" of the field itself:

    (...)
    "fields": {
        "name": {
            "type": "nvkhName",
            "view": "custom:views/contact/fields/nvkh-name",
            "fieldOrder": [ "letters", "first", "last" ]
        },
    (...)

hdijkema avatar May 24 '20 09:05 hdijkema

So, I created a new PersonName that can be used with Entities. It is the IpersonName. It not only has firstName, lastName, middleName; but also 'initialsName'. To use it, one would create an entity derived from Person (e.g. Contact); extend the entity with 'initialsName' and put the field type for 'name' in the metadata of the entity (e.g. Contact.json). "type" = "ipersonName".

I made following changes to EspoCRM:

And now it works.

The only thing that doesn't work yet, is when I update the initials in the the editing form.

When I go back to the list, the initials aren't updated in the list of Contacts.

Any idea's?

hdijkema avatar May 24 '20 13:05 hdijkema

I got it to work without modifying any of the original Espo scripts.

I created a custom "PersonPlus" entity extended from "Person" that has an additional "Mother Maiden Name" field (this is needed for many Spanish speaking countries) and the required custom field definitions and front end scripts and templates.

Almost everything is encapsulated in a "PersonPlus" module namespace (because I don't like dumping every customization in a single "custom" namespace) except the custom loader which at least until I find a workaround, requires to exist in the Custom namespace.

To apply it to "Contact" all you need to do is to create a custom "Contact" entity and extend from "PersonPlus" instead of from "Person" then add a varchar field "motherMaidenName".

I am planning to package it as a free plug in but you are welcome to check the code and adapt for your needs here: https://github.com/telecastg/person-plus-for-espocrm

telecastg avatar May 26 '20 20:05 telecastg

Thank you for telling this.

hdijkema avatar Jun 30 '20 23:06 hdijkema

To apply it to "Contact" all you need to do is to create a custom "Contact" entity and extend from "PersonPlus" instead of from "Person" then add a varchar field "motherMaidenName".

I'm not sure what you mean by this. I somehow don't get my IPerson entity working. Problems with ORDER BY...

See comment below.

hdijkema avatar Jul 01 '20 18:07 hdijkema

I got it to work without modifying any of the original Espo scripts.

I implemented your solution, but See issue telecastg/person-plus-for-espocrm#3 and issue telecastg/person-plus-for-espocrm#4.

Either you have no use for Contacts as foreignEntities in other Entities, or I'm missing something here.

I'm creating a new issue with a change request for Base.php#L225

hdijkema avatar Jul 02 '20 07:07 hdijkema

I'm still missing something...

When I use a normal list view, I see:

image

In a list of related contacts, I see:

image

Also, in the details view I see:

image

Which is all good.

However, when using a many-to-one relation, I get to see the foreign field for the Contact. And than, I see:

image

No Brackets around the firstName field.

Also, when getting a list of related contacts in a detail view, I get no brackets:

image

Also, the entity helper hook doesn't get called. What I see, is that

    public function getForeignField(string $fieldName, string $entityType)
    {
        $format = $this->config->get('personNameFormat');
        return $this->getFields($format, $fieldName);
    }

gets called (see #1751, the change request for getForeignField).

So, somehow, the Contacts with type IPersonName, doesn't get formatted when the getForeignField() function is used.

Am I missing something?

hdijkema avatar Jul 03 '20 00:07 hdijkema

As if v7.4, a custom field converter can be used instead of Espo\Core\Utils\Database\Orm\FieldConverters\PersonName.

yurikuzn avatar Jan 08 '24 13:01 yurikuzn