Announcement

Collapse
No announcement yet.

Fully customised bottom view on entity

Collapse
X
 
  • Filter
  • Time
  • Show
Clear All
new posts

  • Fully customised bottom view on entity

    Hello devs,

    I have a camera entity that has replaceable components on it such as lens and chip. I'm showing the currently attached lens and chip on the Camera detail view but below that I want to show a chronological history detailing all of the replaced components and also including production and dispatch records. The screen would roughly look like this (where 'Hello' is printed is where I want the history to be):
    Click image for larger version  Name:	camera-history.png Views:	0 Size:	59.9 KB ID:	98976

    Each time a component was swapped out, it would be added to the history. The history could look something like this:

    Code:
    ---------------------------------------------------------
    | Date         | Record Type  | ID          | Action    |
    ---------------------------------------------------------
    | 20/10/23     | Lens         | lens1       | Replaced  |
    ---------------------------------------------------------
    | 10/10/23     | Lens         | lens1       | Fixed     |
    ---------------------------------------------------------
    | 20/01/22     | Dispatch     | SN123123    | Sent      |
    ---------------------------------------------------------
    | 20/01/22     | Production   | SN123123    | Built     |
    ---------------------------------------------------------​
    I have created the Camera History panel by defining bottomPanels in my Camera.json file:

    PHP Code:
        "bottomPanels": {
            
    "detail": [
                {
                    
    "name""cameraHistory",
                    
    "label""Camera History",
                    
    "view""custom:views/Camera/record/detail-bottom"
                
    }
            ]
        }
    ​ 
    And then a custom view file:

    Code:
    define('custom:views/Camera/record/detail-bottom', ['views/record/panels/bottom'], function (Dep) {
    console.log('custom bottom view');
        return Dep.extend({
            templateContent: '<div>{{viewObject.someKey}}</div>',
            setup: function () {
                this.someKey = 'Hello';
            },
        });
    });​
    But to do this I need to hook the view up to a completely custom query using the ORM. I've had a good look through the forum and can't figure out where I would create that query or how to pass the data to the view. Can anyone please help me with whether this is possible and if yes, which php files would I need to write and how would that connect with the view?

    Many thanks,
    Clare

  • #2
    If you need a fully fledged list view, I recommend to extend from views/record/panels/relationship instead. Considering you have entities Camera and CameraHistoryRecord.

    Code:
    define('custom:views/camera/record/panels/camera-history', ['views/record/panels/relationship'], function (Dep) {
    
        return class extends Dep {
    
            link = 'cameraHistory'
            scope = 'Camera'
            entityType = 'Camera'
    
            setup() {
                this.defs = {
                    createDisabled: true,
                    selectDisabled: true,
                    unlinkDisabled: true,
                    // layout: 'listForCamera', // you can have a custom list layout (listForCamera.json)
                };
    
                super.setup();
            }
        }
    });​

    Then create in routes.json https://docs.espocrm.com/development...ction/#routing
    Code:
    [
        {
            "route": "Camera/:id/cameraHistory",
            "method": "get",
            "actionClassName": "Espo\\Custom\\Api\\GetCameraHistory"
        }​
    ]

    custom/Espo/Custom/Api/GetCameraHistory.php
    Code:
    <?php
    
    namespace Espo\Custom\Api;
    
    use Espo\Core\Api\Action;
    use Espo\Core\Api\Request;
    use Espo\Core\Api\Response;
    use Espo\Core\Api\ResponseComposer;
    use Espo\Core\Exceptions\BadRequest;
    use Espo\Core\Exceptions\Forbidden;
    use Espo\Core\Record\SearchParamsFetcher;
    use Espo\Core\Select\SelectBuilderFactory;
    use Espo\Core\Acl;
    use Espo\ORM\EntityManager;
    
    /**
     * @noinspection PhpUnused
     */
    class GetCameraHistory implements Action
    {
        public function __construct(
            private SearchParamsFetcher $searchParamsFetcher,
            private SelectBuilderFactory $selectBuilderFactory,
            private EntityManager $entityManager,
            private Acl $acl,
        ) {}
    
        public function process(Request $request): Response
        {
            $id = $request->getRouteParam('id');
            $searchParams = $this->searchParamsFetcher->fetch($request);
    
            if (!$id) {
                throw new BadRequest();
            }
    
            if (!$this->acl->checkScope('Camera')) {
                throw new Forbidden('No access.');
            }
    
            $selectBuilder = $this->selectBuilderFactory
                ->create()
                ->from('CameraHistoryRecord') // your entity type
                ->withStrictAccessControl()            
                ->withSearchParams($searchParams)
                ->createSelectBuilder();
    
            $selectBuilder->where(['cameraId' => $id]); // some where conditions
    
            $query = $selectBuilder->build();
    
            $repository = $this->entityManager
                ->getRDBRepository('CameraHistoryRecord'); // your entity type​
    
            $collection = $repository->clone($query)->find();
            $count = $repository->clone($query)->count();            
    
            // see https://github.com/espocrm/espocrm/blob/8.0.3/application/Espo/Core/Record/Service.php#L856
    
            return ResponseComposer::json([
                'total' => $count,
                'list' => $collection->getValueMapList(),
            ]);
        }
    }​
    Last edited by yuri; 10-24-2023, 08:20 PM.

    Comment


    • #3
      Wow Yuri, thank you so much for the detailed response. I will work through it today!

      Comment


      • #4
        Maybe you don't need a custom panel view and a regular relationship would work for you.

        Comment


        • #5
          Hi,
          i have try for a no relationship entity but no luck. and no error in front-end or back-end.
          the api is not called for fetch collection.

          it's possible to have a bottom panel who fetch a collection from the custom route api without "link = 'cameraHistory'" ?

          telecastg

          Comment


          • #6
            Hello friend

            With custom coding it is possible to render a list of unrelated (not linked) records in the bottom panel of another record detail display.

            Extending from views/record/panels/relationship sounds like a good starting point, however you must consider that this class is predicated on the existence of a link between the detail display record (parent record) and the list of children records, and a great detail of the functionality in that class is devoted to actions that you can take based on the relationship, like create new children records, delete them or unlink them.

            You could fetch a list of records through the API call regardless of whether they are related to the parent record or not and display them in a bottom panel of any record detail, but my questions would be:

            What would you like to do with the list of records once it has been rendered in the bottom panel? and, would there be any relationship between the parent record and the children records?

            Understanding a little bit more what your goals are I will be happy to try helping you.

            Best Regards

            Comment


            • #7
              Hi friend
              let explain my IA :
              meeting
              meeting have a one2many "care" entity

              but "care" is related to one "nomenclature" and not nomenclature to meeting.

              i need to list in bottom view.. the available "nomenclature" of : user attendee, so User can select one "nomenclature" then i will create the "care" who will related to Meeting

              the available "nomenclature" is dependant of "duration/type/status/.." of meeting and of course, the user attendee "...."

              The bottom panel is displayed, console.log is printed.. but noting call to the api.
              as it's not a relation, i have try with :


              PHP Code:
              define('custom:views/meeting/record/panels/nomenclature-bottom', ['views/record/panels/bottom'], function (Dep) {


                  return class extends 
              Dep {

                      
              //link = 'nomenclature'
                      
              scope 'Nomenclature'
                      
              entityType 'Nomenclature'


                      
              setup() {
                          
              console.log('custom bottom view');
                          
                          
              super.setup();
                      }
                  }
              });
              ​ 
              when i try with :
              ['views/record/panels/relationship']

              i have error as there are no relationship.. and it's just.

              Last edited by item; 12-02-2023, 11:39 PM.

              Comment


              • #8
                Hello @item,

                Here is the code to render a list of not directly linked records in the bottom panel of a record.

                In our application we have a "Property" scope which is linked to a "Tenancy" scope (One Property - Many Tenancies) and a "Collection Issue" scope linked to the "Tenancy" scope (One Tenancy - Many Collection Issues), but there is no link between Property and Collection Issue.

                The code below allows us to render a list of all Collection Issues under a detail display of a Property. The only functionality defined for the list are the ability to create new "Collection Issues" and to filter the list by the "Collection Issue" status, using the "Collection Issue" predefined filters.

                Please note that this example is very simple and not very useful as is, in practice you would want to set parameters to limit the Collection Issue records that will display, so you would probably execute a query as response to the collection url API to filter the Collection Issue collection that is displayed.

                Step 1
                custom/Espo/Custom/Resources/metadata/clientDefs/Property.json
                Code:
                    "bottomPanels": {
                        "detail": [
                            {
                                "name": "collectionIssues",
                                "label": "Collection Issues",
                                "scope": "CollectionIssue",
                                "view": "custom:views/record/panels/bottom-list",
                                "layout": "listSmall",
                                "selectPrimaryFilterName": "open",
                                "filterList": ["all","open", "closed"]
                            }
                        ]
                    },
                Step 2
                client/custom/src/views/record/panels/bottom-list.js
                Code:
                define('custom:views/record/panels/bottom-list', ['views/record/panels/bottom', 'search-manager'], function (Dep, SearchManager) {
                
                    return Dep.extend({
                
                        template: 'record/panels/relationship',
                
                        noCreateScopeList: [],
                
                        setup: function () {
                            Dep.prototype.setup.call(this);
                
                            this.scope = this.defs.scope;
                
                            let url = this.scope;
                
                            if (!('create' in this.defs)) {
                                this.defs.create = true;
                            }
                
                            if (!('view' in this.defs)) {
                                this.defs.view = true;
                            }
                
                            this.filterList = this.defs.filterList || this.filterList || null;
                            
                            if (this.filterList && this.filterList.length) {
                                this.filter = this.getStoredFilter();
                            }
                
                            if (this.defs.createDisabled) {
                                this.defs.create = false;
                            }
                
                            if (this.defs.selectDisabled) {
                                this.defs.select = false;
                            }
                
                            if (this.defs.viewDisabled) {
                                this.defs.view = false;
                            }
                
                            this.setupTitle();
                            
                            let hasCreate = false;
                            
                            if (this.defs.create) {
                                if (
                                    this.getAcl().check(this.scope, 'create') &&
                                    !~this.noCreateScopeList.indexOf(this.scope)
                                ) {
                                    this.buttonList.push({
                                        title: 'Create',
                                        action: 'create',
                                        html: '<span class="fas fa-plus"></span>',
                                        data: {
                                        },
                                        acl: this.defs.createRequiredAccess || null,
                                    });
                                    hasCreate = true;
                                }
                            }
                
                            let layoutName = 'listSmall';
                
                            if (this.listLayoutName) {
                                layoutName = this.listLayoutName;
                            }
                
                            let listLayout = null;
                
                            let layout = this.defs.layout || null;
                
                            if (layout) {
                                if (typeof layout === 'string') {
                                     layoutName = layout;
                                } else {
                                     layoutName = 'listBottomCustom';
                                     listLayout = layout;
                                }
                            }
                
                            this.listLayout = listLayout;
                            this.layoutName = layoutName;
                
                            this.setupSorting();
                
                            this.wait(true);
                
                            this.getCollectionFactory().create(this.scope, collection => {
                                collection.maxSize = this.recordsPerPage || this.getConfig().get('recordsPerPageSmall') || 5;
                
                                if (this.defs.filters) {
                                    let searchManager = new SearchManager(collection, 'listRelationship', false, this.getDateTime());
                                    searchManager.setAdvanced(this.defs.filters);
                                    collection.where = searchManager.getWhere();
                                }
                
                                collection.url = collection.urlRoot = url;
                
                                if (this.defaultOrderBy) {
                                    collection.setOrder(this.defaultOrderBy, this.defaultOrder || false, true);
                                }
                
                                this.collection = collection;
                
                                collection.parentModel = this.model;
                
                                this.setFilter(this.filter);
                
                                collection.fetch();
                
                                let viewName =
                                    this.defs.recordListView ||
                                    this.getMetadata().get(['clientDefs', this.scope, 'recordViews', 'list']) ||
                                    'views/record/list';
                
                                this.listViewName = viewName;
                                this.rowActionsView = this.defs.readOnly ? false : (this.defs.rowActionsView || this.rowActionsView);
                
                                this.once('after:render', () => {
                
                                    this.createView('list', viewName, {
                                        collection: collection,
                                        layoutName: layoutName,
                                        listLayout: listLayout,
                                        checkboxes: false,
                                        rowActionsView: this.rowActionsView,
                                        buttonsDisabled: true,
                                        el: this.options.el + ' .list-container',
                                        skipBuildRows: true,
                                        rowActionsOptions: {
                                            unlinkDisabled: this.defs.unlinkDisabled,
                                        },
                                        displayTotalCount: false,
                                    }, view => {
                                        view.getSelectAttributeList((selectAttributeList) => {
                                            if (selectAttributeList) {
                                                collection.data.select = selectAttributeList.join(',');
                                            }
                
                                            if (!this.defs.hidden) {
                                                collection.fetch();
                                                return;
                                            }
                
                                            this.once('show', () => collection.fetch());
                                        });
                                    });
                                });
                
                                this.wait(false);
                            });
                
                            this.setupFilterActions();
                
                        },
                
                        getStoredFilter: function () {
                            const key = 'panelFilter' + this.model.name + '-' + (this.panelName || this.name);
                            return this.getStorage().get('state', key) || null;
                        },
                
                        setupTitle: function () {
                            this.title = this.title || this.translate(this.scope);
                            let iconHtml = '';
                            if (!this.getConfig().get('scopeColorsDisabled')) {
                                iconHtml = this.getHelper().getScopeColorIconHtml(this.scope);
                            }
                
                            this.titleHtml = this.title;
                
                            if (this.defs.label) {
                                this.titleHtml = iconHtml + this.translate(this.defs.label, 'labels', this.scope);
                            } else {
                                this.titleHtml = iconHtml + this.title;
                            }
                
                            if (this.filter && this.filter !== 'all') {
                                this.titleHtml += ' &middot; ' + this.translateFilter(this.filter);
                            }
                            
                        },
                
                        setupFilterActions: function () {
                            if (this.filterList && this.filterList.length) {
                                this.actionList.push(false);
                
                                this.filterList.slice(0).forEach((item) => {
                                    let selected;
                
                                    if (item === 'all') {
                                        selected = !this.filter;
                                    }
                                    else {
                                        selected = item === this.filter;
                                    }
                
                                    let label = this.translateFilter(item);
                
                                    let $item =
                                        $('<div>')
                                            .append(
                                                $('<span>')
                                                    .addClass('check-icon fas fa-check pull-right')
                                                    .addClass(!selected ? 'hidden' : '')
                                            )
                                            .append(
                                                $('<div>').text(label)
                                            );
                
                                    this.actionList.push({
                                        action: 'selectFilter',
                                        html: $item.get(0).innerHTML,
                                        data: {
                                            name: item,
                                        },
                                    });
                                });
                            }
                        },
                
                        actionSelectFilter: function (data) {
                            const filter = data.name;
                            let filterInternal = filter;
                
                            if (filter === 'all') {
                                filterInternal = false;
                            }
                
                            this.storeFilter(filterInternal);
                            this.setFilter(filterInternal);
                
                            this.filterList.forEach(item => {
                                const $el = this.$el.closest('.panel').find('[data-name="'+item+'"] span');
                
                                if (item === filter) {
                                    $el.removeClass('hidden');
                                } else {
                                    $el.addClass('hidden');
                                }
                            });
                
                            this.collection.reset();
                
                            let listView = this.getView('list');
                
                            if (listView && listView.$el) {
                                let height = listView.$el.parent().get(0).clientHeight;
                
                                listView.$el.empty();
                
                                if (height) {
                                    listView.$el.parent().css('height', height + 'px');
                                }
                            }
                
                            this.collection.fetch().then(() => {
                                listView.$el.parent().css('height', '');
                            });
                
                            this.setupTitle();
                
                            if (this.isRendered()) {
                                this.$el.closest('.panel')
                                    .find('> .panel-heading > .panel-title > span')
                                    .html(this.titleHtml);
                            }
                        },
                
                        translateFilter: function (name) {
                            return this.translate(name, 'presetFilters', this.scope);
                        },
                
                        setupSorting: function () {
                            let orderBy = this.defs.orderBy || this.defs.sortBy || this.orderBy;
                            let order = this.defs.orderDirection || this.orderDirection || this.order;
                
                            if ('asc' in this.defs) { // TODO remove in 5.8
                                order = this.defs.asc ? 'asc' : 'desc';
                            }
                
                            if (!orderBy) {
                                orderBy = this.getMetadata().get(['entityDefs', this.scope, 'collection', 'orderBy']);
                                order = this.getMetadata().get(['entityDefs', this.scope, 'collection', 'order'])
                            }
                
                            if (orderBy && !order) {
                                order = 'asc';
                            }
                
                            this.defaultOrderBy = orderBy;
                            this.defaultOrder = order;
                            console.log('data-visualization:views/record/panels/bottom-list.js #337 setupSorting() this.defaultOrderBy = ',this.defaultOrderBy);            
                        },
                
                        setFilter: function (filter) {
                            this.filter = filter;
                            this.collection.data.primaryFilter = null;
                
                            if (filter && filter !== 'all') {
                                this.collection.data.primaryFilter = filter;
                            }
                        },
                
                        storeFilter: function (filter) {
                            const key = 'panelFilter' + this.model.name + '-' + (this.panelName || this.name);
                
                            if (filter) {
                                this.getStorage().set('state', key, filter);
                            } else {
                                this.getStorage().clear('state', key);
                            }
                        }
                        
                    });
                });
                ​
                Hope this helps

                Comment


                • #9
                  Hi telecastg,

                  this solution is not for me, i can’t not set search param on front-end, i really need the bottom panel call the API as Yuri have post above.

                  sample search param in back-end :
                  count(userAttendees) .. count(contactsAttendees) …
                  get data from users/contact…
                  then a build my selectBuilder and then I have the collection to send to front-end and listed as bottom panel

                  Yuri maybe can post a hint for have unrelated bottom who call api for collection 😉

                  Comment


                  • #10
                    Originally posted by item View Post
                    Hi telecastg,
                    this solution is not for me, i can’t not set search param on front-end, i really need the bottom panel call the API as Yuri have post above.
                    The "url" property of the collection object is actually the API call

                    In yuri 's example, the "url" property is defined by views/record/panels/relationship as "Camera/{this.model.id}/cameraHistory" which is the API call to the custom route
                    Last edited by telecastg; 12-05-2023, 05:26 PM.

                    Comment


                    • item
                      item commented
                      Editing a comment
                      Done
                      Thanks @telecastg

                      yes i have now a little more understand front-end and the "url"

                      wait next release, i need add a custom rowAction in this panels who will create a new "IA based record"

                      This bottom is like a "you can create only related cares based on this sugestions of nomenclatures dependant on many field/link/value of meeting"
                      I have now a new idea where i can do same logic..

                  • #11
                    Hi devs,

                    I have successfully created a custom bottom panel that contains different entity types in the same list using a union join in my API call - it's more or less Yuri's solution from above but with some extra code.

                    It basically works except that I don't have a link on the name of the entities - it's just rendered as text (please excuse the mess, I haven't set up any proper labels yet):

                    Click image for larger version

Name:	image.png
Views:	316
Size:	25.7 KB
ID:	101605

                    Unlike a regular relationship bottom panel (where delno 2 is a link to the entity):

                    Click image for larger version

Name:	image.png
Views:	266
Size:	22.6 KB
ID:	101606

                    I've searched through the code but I can't figure out how to make it a link. I can see both my custom name field and the regular name field are being rendered in client\res\layout-types\list-row.tpl and I think it's possible I'll need to generate a different link depending on the entity type, but I can't see where I would even put this code.

                    Can anyone please help me with how the links are created?

                    Cheers,
                    Clare​​

                    Comment


                    • rabii
                      rabii commented
                      Editing a comment
                      can you share the code ?

                      Did you use a specific template to render the list ?

                      You might have missed some defs on the field name.

                    • onepoint0
                      onepoint0 commented
                      Editing a comment
                      Thanks for the comment rabii - I figured this out a while back and made it work. I can't remember what the issue was now otherwise I would post the solution for others

                    • rabii
                      rabii commented
                      Editing a comment
                      Cool.
                      I thought it was not fixed that is why i asked to share the code so that we can help you fix whatever issue was there

                      Glad you got it sorted anyway
                  Working...
                  X