Announcement

Collapse
No announcement yet.

Solutions to adapting previous custom code to work with Espo 7.x

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

  • Solutions to adapting previous custom code to work with Espo 7.x

    Since Espo 7 has extensive changes/refactoring of the core there are changes that we need to make to our customizations, this post will chronicle our findings and solutions to hopefully help others going through this process.

    It would be very helpful if other participants can also post in this thread the problems encountered AND the solutions found for them, please do not post questions only here, use a different thread to request help.

    Issue: Error in declaration of custom Entry Points
    In our installation we have several custom entry points that were extended from the deprecated class Espo\Core\EntryPoints\EntryPoint and were throwing the following error when running the command php preload.php from CLI:
    Fatal error: Declaration of Espo\Modules\PropertyManagement\EntryPoints\Lease: :run(Espo\Core\Api\Request $request) must be compatible with Espo\Core\EntryPoint\EntryPoint::run(Espo\Core\Api \Request $request, Espo\Core\Api\Response $response): void in C:\xampp\htdocs\tenantfriendly\application\Espo\Mo dules\PropertyManagement\EntryPoints\Lease.php on line 61
    Solution: Modify the custom EntryPoint class as follows:
    BEFORE:
    Code:
    use Espo\Core\EntryPoints\{
    EntryPoint
    };
    use Espo\Core\EntryPoints\{
    EntryPoint
    };
    NOW:
    Code:
    use Espo\Core\EntryPoint\{
    EntryPoint
    };
    use Espo\Core\{
    Api\Request,
    Api\Response
    };
    BEFORE:
    Code:
    public function run(Request $request)
    NOW:
    Code:
    public function run(Request $request, Response $response): void
    Last edited by telecastg; 10-09-2021, 03:54 AM.

  • #2
    Issue: Error in declaration of custom Jobs
    Fatal error: Declaration of Espo\Modules\PropertyManagement\Jobs\InstantiateRe curringInvoices::run() must be compatible with Espo\Core\Job\JobDataLess::run(): void in C:\xampp\htdocs\tenantfriendly\application\Espo\Mo dules\PropertyManagement\Jobs\InstantiateRecurring Invoices.php on line 49
    Solution: Modify the custom Job class as follows:
    BEFORE:
    Code:
    use Espo\Core\{
    Jobs\Job
    };
    NOW:
    Code:
    use Espo\Core\{
    Job\JobDataLess
    };
    BEFORE:
    Code:
    class InstantiateRecurringInvoices implements Job
    AFTER:
    Code:
    class InstantiateRecurringInvoices implements JobDataLess
    BEFORE:
    Code:
    public function run()
    NOW:
    Code:
    public function run(): void

    Comment


  • #3
    Issues: Error in custom Controller, extended from "\Espo\Core\Templates\Controllers\Base", responding to a GET Ajax call:
    ERROR MESSAGE:
    [2021-10-12 20:01:49] WARNING: E_WARNING: Undefined property: stdClass::$criteriaScope {"code":2,"message":"Undefined property: stdClass::$criteriaScope","file":"C:\\xampp\\htdoc s\\tenantfriendly\\application\\Espo\\Modules\\Con trolBoard\\Controllers\\ControlBoard.php","line":4 5} []
    Solution: Modify the method signature in the Custom Controller:
    BEFORE:
    Code:
    namespace Espo\Modules\ControlBoard\Controllers;
    
    class ControlBoard extends \Espo\Core\Templates\Controllers\Base
    {
        public function actionTotalsByCriteria($params, $request, $data) {
             $criteriaScope = $data->get('criteriaScope');
             (more code here)
        }
    }
    AFTER:
    Code:
    namespace Espo\Modules\ControlBoard\Controllers;
    
    use Espo\Core\Api\Request;
    
    class ControlBoard extends \Espo\Core\Templates\Controllers\Base
    {
        public function actionTotalsByCriteria(Request $request):array {
            $data = $request->getQueryParams();
            $criteriaScope = $data["criteriaScope"];
            (more code here)        
        }
    }
    ERROR MESSAGE:
    ERROR: Slim Application Error Type: Error Code: 0 Message: Call to undefined method Espo\Modules\PropertyManagement\Controllers\Servic eTicket::fetchListParamsFromRequest()
    Solution: Modify the method call
    BEFORE:
    Code:
    public function getActionListDataCards($params, $request, $data)
    {
        (more code here)
        $params = [];
        // get the collection parameters from the front-end request
        $this->fetchListParamsFromRequest($params, $request, $data);
        (mode code here)
    }
    AFTER
    Code:
    public function getActionListDataCards(Request $request): object
    {
        (more code here)
        // get the collection parameters from the front-end request
        $params = $request->getQueryParams();
    }
    Last edited by telecastg; 10-13-2021, 01:57 AM.

    Comment


    • #4
      Issue: Upcoming elimination of the Select Manager class and substitution by the Select Builder framework
      This change to the core, although presented as an improvement, in terms of better coding design, will force developers to learn another bit of custom "Espo meta-language" in order to write the same queries that were previously built with the Select Manager

      The Selected Manager class is not eliminated yet, but its functions are deprecated and will eventually disappear, so we decided to push ahead and start making the necessary changes.

      This is how we adapted custom code in a proprietary extension that displays a list of pending Maintenance requests as a set of "data cards" grouped by the ageing of the request:
      Click image for larger version  Name:	Control Board View.png Views:	0 Size:	89.7 KB ID:	75828​
      BEFORE:
      Code:
      namespace Espo\Modules\ControlBoard\Controllers;
      
      class ControlBoard extends \Espo\Core\Templates\Controllers\Base
      {
          public function getActionListDataCards($params, $data, $request)
          {
              if (!$this -> getAcl() -> check($this -> name, 'read')) {
                  throw new Forbidden("No read access for {$this -> name}.");
              }
              $params = [];
              // get the collection parameters from the front-end routing request
              $this -> fetchListParamsFromRequest($params, $request, $data);
              $maxSizeLimit = $this -> getConfig() -> get('recordListMaxSizeLimit', self::MAX_SIZE_LIMIT);
              if (empty($params['maxSize'])) {
                  $params['maxSize'] = $maxSizeLimit;
              }
              if (!empty($params['maxSize']) & & $params['maxSize']  >  $maxSizeLimit) {
                  throw new Forbidden("Max size should should not exceed " . $maxSizeLimit . ". Use offset and limit.");
              }
              $result = $this -> getRecordService() -> getListDataCards($params);
              return (object) [
                  'total' = > $result -> total,
                  'list' = > $result -> collection -> getValueMapList(),
                  'additionalData' = > $result -> additionalData
              ];
          }
      }
      AFTER
      Code:
      namespace Espo\Modules\ControlBoard\Controllers;
      
      use Espo\Core\Api\Request;
      
      class ControlBoard extends \Espo\Core\Templates\Controllers\Base
      {
          public function getActionListDataCards(Request $request): object
          {
              if (!$this -> getAcl() -> check($this -> name, 'read')) {
                  throw new Forbidden("No read access for {$this -> name}.");
              }
              // get the collection parameters from the front-end request
              $searchParams = $this -> searchParamsFetcher -> fetch($request);
              $result = $this -> getRecordService() -> getListDataCards($searchParams);
              return (object) [
                  'total' => $result -> total,
                  'list' => $result -> collection -> getValueMapList(),
                  'additionalData' => $result -> additionalData
              ];
          }
      }
      Because of the number of characters limitation, this post will continue below.
      Last edited by telecastg; 10-18-2021, 04:22 PM.

      Comment


      • #5
        BEFORE:
        Code:
        namespace Espo\Modules\ControlBoard\Services;
        
        use Espo\Core\Utils\Util;
        
        class ControlBoard extends \Espo\Services\Record
        {
        
            public function getListDataCards($params)
            {
                $disableCount = $this->metadata->get(['entityDefs', $this->entityType, 'collection', 'countDisabled']) ?? false;
                if ($this->listCountQueryDisabled || $this->getMetadata()->get(['entityDefs', $this->entityType, 'collection', countDisabled']) ) {
                    $disableCount = true;
                }
                $maxSize = 0;
                if ($disableCount) {
                    if (!empty($params['maxSize'])) {
                        $maxSize = $params['maxSize'];
                        $params['maxSize'] = $params['maxSize'] + 1;
                    }
                }
        
                // translate the front-end parameters ($params) for the SELECT query into specifications understood by the Espo ORM language
                $selectParams = $this->getSelectParams($params);  
        
                // define additional values for he ORM SELECT query parameters
                $selectParams['maxTextColumnsLength'] = $this->getMaxSelectTextAttributeLength();
        
                // extract the list of fields to be included into the ORM SELECT query parameters from the front-end input ($params)
                $selectAttributeList = $this->getSelectManager()->getSelectAttributeList($params);
                if ($selectAttributeList) {
                    $selectParams['select'] = $selectAttributeList;
                } else {
                    $selectParams['skipTextColumns'] = $this->isSkipSelectTextAttributes();
                }
        
                // extract the Control Board display data from the target entity's entitydefs
                $controlBoardCriteriaData = $this->getMetadata()->get(['entityDefs', $this->entityType, 'controlBoardCriteriaData']);
                $controlBoardCriteriaField = $controlBoardCriteriaData['controlBoardCriteriaField'];
                $fieldSelectStatement = $this->getMetadata()->get(['entityDefs', $this->entityType, 'fields', $controlBoardCriteriaField, 'select']);
                // update if necessary the criteria filed's value
                if ($fieldSelectStatement) {
                    $pdo = $this->getEntityManager()->getPDO();
                    $sql = 'UPDATE '.Util::camelCaseToUnderscore($this->entityType).' SET `'.$controlBoardCriteriaField.'` = '.$fieldSelectStatement;
                    $sth = $pdo->prepare($sql);
                    $sth->execute();
                }
                // fetch a collection of target entity records
                $collection = new \Espo\ORM\EntityCollection([], $this->entityType);  
                // get the filter values for each data group
                $criteriaConditionGroups = $controlBoardCriteriaData['criteriaConditionGroups'];
                $additionalData = (object) [
                    'groupList' => []
                ];
                // fetch sub-collections of each data group
                foreach ($criteriaConditionGroups as $group) {
                    $selectParamsSub = $selectParams;
                    $type = $group['conditionGroupType'];
                    $label = $group['conditionGroupLabel'];
                    $conditionIndex = 0;
                    foreach($group['conditionValues'] as $whereCondition) {
                        $operator = '';
                        if($whereCondition['operator'] && $whereCondition['operator'] !== '=') {
                            $operator = $whereCondition['operator'];
                        }
                        $groupValue = $whereCondition['value'];
                        if($whereCondition['valueType'] && $whereCondition['valueType'] === 'int') {
                            $groupValue = intval($groupValue);
                        }
                        if($conditionIndex > 0 && $type === 'or') {
                            $selectParamsSub['whereClause'][] = ['OR' => [$controlBoardCriteriaField.$operator => $groupValue]];
                        } else {
                            $selectParamsSub['whereClause'][] = [$controlBoardCriteriaField.$operator => $groupValue];
                        }
                        $conditionIndex++;
                    }
                    $o = (object) [
                        'name' => $label
                    ];
                    $collectionSub = $this->getRepository()->find($selectParamsSub);
                    if (!$disableCount) {
                        $totalSub = $this->getRepository()->count($selectParamsSub);
                    } else {
                        if ($maxSize && count($collectionSub) > $maxSize) {
                            $totalSub = -1;
                            unset($collectionSub[count($collectionSub) - 1]);
                        } else {
                            $totalSub = -2;
                        }
                    }
                    foreach ($collectionSub as $e) {
                        $this->loadAdditionalFieldsForList($e);
                        if (!empty($params['loadAdditionalFields'])) {
                            $this->loadAdditionalFields($e);
                        }
                        if (!empty($selectAttributeList)) {
                            $this->loadLinkMultipleFieldsForList($e, $selectAttributeList);
                        }
                        $this->prepareEntityForOutput($e);
                        $collection[] = $e;
                    }
                    $o->total = $totalSub;
                    $o->list = $collectionSub->getValueMapList();
                    $additionalData->groupList[] = $o;
                }
                if (!$disableCount) {
                    $total = $this->getRepository()->count($selectParams);
                } else {
                    if ($maxSize && count($collection) > $maxSize) {
                        $total = -1;
                        unset($collection[count($collection) - 1]);
                    } else {
                        $total = -2;
                    }
                }
                return (object) [
                    'total' => $total,
                    'collection' => $collection,
                    'additionalData' => $additionalData
                ];
            }
        }
        AFTER:
        Code:
        namespace Espo\Modules\ControlBoard\Services;
        
        use Espo\Core\Utils\Util;
        use Espo\Core\{
        FieldProcessing\ListLoadProcessor,
        FieldProcessing\Loader\Params as FieldLoaderParams
        };
        
        class ControlBoard extends \Espo\Services\Record
        {
        
            public function getListDataCards($searchParams)
            {
                $disableCount = $this->metadata->get(['entityDefs', $this->entityType, 'collection', 'countDisabled']) ?? false;
                $maxSize = $searchParams->getMaxSize();
                if ($disableCount && $maxSize) {
                    $searchParams = $searchParams->withMaxSize($maxSize + 1);
                }
        
                // use the SelectBuilderFactory class to generate Espo's ORM SELECT query values from the front-end input ($searchParams)
                $query = $this->selectBuilderFactory->create()->from($this->entityType)->withStrictAccessControl()->withSearchParams($searchParams)->build();
        
                // initialize the group filtering query specs
                $additionalData = (object) ['groupList' => [],];
        
                // instantiate the target entity's Repository class
                $repository = $this->entityManager->getRepository($this->entityType);
        
                // get the filter values for each data group
                $controlBoardCriteriaData = $this->metadata->get(['entityDefs', $this->entityType,'controlBoardCriteriaData']);
        
                $controlBoardCriteriaField = $controlBoardCriteriaData['controlBoardCriteriaField'];
        
                $fieldSelectStatement = $this->getMetadata()->get(['entityDefs', $this->entityType, 'fields',$controlBoardCriteriaField, 'select']);
        
                if ($fieldSelectStatement) {
                    $pdo = $this->getEntityManager()->getPDO();
                    $sql = 'UPDATE '.Util::camelCaseToUnderscore($this->entityType).' SET `'.$controlBoardCriteriaField.'` = '.$fieldSelectStatement;
                    $sth = $pdo->prepare($sql);
                    $sth->execute();
                }
        
                // fetch a collection of target entity records
                $collection = new \Espo\ORM\EntityCollection([], $this->entityType);
        
                $criteriaConditionGroups = $controlBoardCriteriaData['criteriaConditionGroups'];
        
                $additionalData = (object) [
                    'groupList' => []
                ];
        
                // create sub collections for each condition group filtering values
                foreach ($criteriaConditionGroups as $group) {
                    $type = $group['conditionGroupType'];
                    $label = $group['conditionGroupLabel'];
                    $conditionIndex = 0;
                    foreach($group['conditionValues'] as $whereCondition) {
                        $whereClause = [];
                        $operator = '';
                        if($whereCondition['operator'] && $whereCondition['operator'] !== '=') {
                            $operator = $whereCondition['operator'];
                        }
                        $groupValue = $whereCondition['value'];
                        if($whereCondition['valueType'] && $whereCondition['valueType'] === 'int') {
                            $groupValue = intval($groupValue);
                        }
                        if($conditionIndex > 0 && $type === 'or') {
                            $whereClause[] = ['OR' => [$controlBoardCriteriaField.$operator => $groupValue]];
                        } else {
                            $whereClause[] = [$controlBoardCriteriaField.$operator => $groupValue];
                        }
                        $conditionIndex++;
                    }
                    $itemSelectBuilder = $this->entityManager->getQueryBuilder()->select()->clone($query);
                    $itemSelectBuilder->where($whereClause);
                    $groupObject = (object) [
                        'name' => $label
                    ];
                    $itemQuery = $itemSelectBuilder->build();
        
                    $collectionSub = $repository->clone($itemQuery)->find();
        
                    if (!$disableCount) {
                        $totalSub = $repository->clone($itemQuery)->count();
                    } else {
                        if ($maxSize && count($collectionSub) > $maxSize) {
                            $totalSub = -1;
                            unset($collectionSub[count($collectionSub) - 1]);
                        } else {
                            $totalSub = -2;
                        }
                    }
                    $fieldLoader = new FieldLoaderParams();
                    $loadProcessorParams = $fieldLoader->withSelect($searchParams->getSelect());
                    foreach ($collectionSub as $e) {
                        $this->listLoadProcessor->process($e, $loadProcessorParams);
                        $recordService->prepareEntityForOutput($e);
                        $collection[] = $e;
                    }
                    $groupObject->total = $totalSub;
                    $groupObject->list = $collectionSub->getValueMapList();
                    $additionalData->groupList[] = $groupObject;
                }
                if (!$disableCount) {
                    $total = $repository->clone($query)->count();
                } else {
                    if ($maxSize && count($collection) > $maxSize) {
                        $total = -1;
                        unset($collection[count($collection) - 1]);
                    } else {
                        $total = -2;
                    }
                }
                $result = (object) [
                    'total' => $total,
                    'collection' => $collection,
                    'additionalData' => $additionalData
                ];
                return $result;
            }
        }
        Last edited by telecastg; 02-28-2022, 08:46 PM.

        Comment


        • #6
          Issue: Deprecated use of the ServiceFactory class and substitution by the use of the ServiceContainer class

          This change is not throwing error messages yet, but given that the direct use of the ServiceFactory class has been deprecated, it is better to start upgrading existing custom scripts to avoid future headaches.

          BEFORE:
          Code:
          namespace Espo\Modules\ListPlus\Controllers;
          
          use Espo\Core\{
          Container,
          Api\Request
          };
          
          class ListPlusAdmin
          {
              protected $container;
          
              public function __construct(Container $container)
              {
                  $this->container = $container;
              }
          
              public function postActionCreateScopeBackEndChanges(Request $request) {
                  // extract the payload from the request object
                  $dataObj = $request->getParsedBody();
                  // invoke service class to execute the instructions passing the payload as object
                  $result = $this->container->get('serviceFactory')->create('ListPlusAdmin')->createScopeBackEndChanges($dataObj);
                  return $result;
              }
          }
          AFTER:
          Code:
          namespace Espo\Modules\ListPlus\Controllers;
          
          use Espo\Core\{
          Di,
          Api\Request
          };
          
          class ListPlusAdmin implements Di\ServiceFactoryAware
          {
              use Di\ServiceFactorySetter;
          
              public function postActionCreateScopeBackEndChanges(Request $request) {
                  // extract the payload from the request object
                  $dataObj = $request->getParsedBody();
                  // invoke service class to execute the instructions passing the payload as object
                  $result = $this->serviceFactory->create('ListPlusAdmin')->createScopeBackEndChanges($dataObj);
                  return $result;
              }
          }
          Update: See this post by Yuri regarding the correct way of calling a Service class from another Service class in Espo 7: https://forum.espocrm.com/forum/deve...9889#post79889

          Update: See this example of calling the Service class from any other class using a "use" statement (including the Service class code) and then injecting it in the constructor method: https://forum.espocrm.com/forum/deve...9985#post79985
          Last edited by telecastg; 04-20-2022, 06:19 PM.

          Comment


          • #7
            Issue: Deprecated use of function getEntityManager() in Controllers, substituted by Direct Injection of Di\EntityManagerAware and Di\EntityManagerSetter classes

            This is another issue that is not throwing errors yet, but since the getEntityManager() function is deprecated is better to start adapting custom scripts before a future upgrade causes headaches.

            BEFORE:
            Code:
            namespace Espo\Modules\Esignature\Controllers;
            
            use Espo\Core\{
            Container,
            Exceptions\BadRequest,
            Api\Request
            };
            
            class Esignature
            {
                protected $container;
            
                public function __construct(Container $container)
                {
                    $this->container = $container;
                }
            
                public function postActionPrintForEsignature(Request $request): string
                {
                    $data = $request->getParsedBody();
                    $entity = $this->container->get('entityManager')->getEntity($data->entityType,$data->entityId);
                    $template = $this->container->get('entityManager')->getEntity('Template',$data->templateId);
                    if(empty($entity)) {
                        throw new BadRequest('Entity "{$data->entityType}" ID "{$data->entityId}" not found');
                    }
                    if(empty($template)) {
                        throw new BadRequest('Template ID "{$data->templateId}" not found');
                    }
                    $result = $this->container->get('serviceFactory')->create('Esignature')->printForEsignature($entity, $template, $data->isPortal);
                    return $result;
                }
            
            }
            AFTER:
            Code:
            namespace Espo\Modules\Esignature\Controllers;
            
            use Espo\Core\{
            Di,
            Api\Request
            };
            
            class Esignature implements Di\ServiceFactoryAware, Di\EntityManagerAware
            {
            
                use Di\ServiceFactorySetter;
            
                use Di\EntityManagerSetter;
            
                public function postActionPrintForEsignature(Request $request): string
                {
                    $data = $request->getParsedBody();
                    $entity = $this->entityManager->getEntity($data->entityType,$data->entityId);
                    $template = $this->entityManager->getEntity('Template',$data->templateId);
                    if(empty($entity)) {
                        throw new BadRequest('Entity "{$data->entityType}" ID "{$data->entityId}" not found');
                    }
                    if(empty($template)) {
                        throw new BadRequest('Template ID "{$data->templateId}" not found');
                    }
                    $result = $this->serviceFactory->create('Esignature')->printForEsignature($entity, $template, $data->isPortal);
                    return $result;
                }
            
            }

            Comment


            • #8
              Thanks for the information. Would it be possible for you to create a git repo with the before code and then check in the changes to the files. This would make it easy for everybody to see the exact changes required in each case.

              Comment


              • telecastg
                telecastg commented
                Editing a comment
                You're very welcome Kyle, unfortunately I am not good with Git (I am not a professional developer) and right now I am concentrating all my efforts into ironing out possible conflicts in our application.

              • espcrm
                espcrm commented
                Editing a comment
                I think he wouldn't mind if you (Kyle) create the repo and manage it.

            • #9
              I have created a repo for this and will commit items as time permits. Happy to merge other peoples items as well.

              Contribute to boydle/Espo-V7-Samples development by creating an account on GitHub.

              Comment


              • espcrm
                espcrm commented
                Editing a comment
                Cool, I guess I can try to pull request rare occasion if telecastg or anyone else make a post to save you some work.

              • item
                item commented
                Editing a comment
                Yes espcrm,
                it's a learning platform of github

            • #10
              All the samples above with some modifications have been added to Git repo

              Comment


              • item
                item commented
                Editing a comment
                Thanks Kyle

            • #11
              I have just one big question if someone can help :

              - sometime we have function init()
              - sometime we have function _construct( .... )
              - sometime we have Di ...
              - sometime we have addDependency
              ..
              where and why use this or this ?
              it's just for not die "ignorant"

              Comment


              • telecastg
                telecastg commented
                Editing a comment
                I honestly have no idea, yuri classifies using Di as an "improvement" but I lack the technical knowledge to understand why.

                I am sure that all these core improvements make the platform better in terms of code design and engineering but for the every day user/application developer like me, they are, for the time being, a big headache
                Last edited by telecastg; 10-28-2021, 12:16 AM.

              • telecastg
                telecastg commented
                Editing a comment
                I found more information about the use of Dependency Injection (DI) in case anyone is interested. https://php-di.org/doc/understanding-di.html

            • #12
              Issue: Filtering records using SelectManagers is deprecated, although still working for the moment, SelectManagers have been substituted by the SelectBuilder class and will probably will disappear in future updates.

              To see an example on how to adapt an existing SelectManager filter to the Espo 7 way, check this post: https://forum.espocrm.com/forum/deve...8036#post78036

              Comment


              • #13
                Using Dependency Injection (Di) instead of utilizing "use" statements in a class definition

                This is an example of using the currently preferred method as of Espo 7 to load dependent classes to be used in a new class definition. Both ways are still working as of today, but future updates might change this.

                BEFORE:
                PHP Code:
                namespace Espo\Modules\Chats\Services;

                use 
                Espo\Core\Utils\Metadata;

                class 
                ChatForumAdmin
                {

                    protected 
                $metadata;

                     public function 
                __construct(Metadata $metadata)
                    {
                        
                $this->metadata $metadata;
                    }

                    public function 
                createLinks() {
                        
                // some code

                        
                $existingSidePanelsInMetadata $this->metadata->get('clientDefs.' $linkPayload->parentEntity '.sidePanels.detail');

                        
                // more code

                    
                }


                NOW
                PHP Code:
                namespace Espo\Modules\Chats\Services;

                use 
                Espo\Core\Di;

                class 
                ChatForumAdmin implements Di\MetadataAware
                {

                    use 
                Di\MetadataSetter;

                    public function 
                createLinks() {
                        
                // some code

                        
                $existingSidePanelsInMetadata $this->metadata->get('clientDefs.' $linkPayload->parentEntity '.sidePanels.detail');

                        
                // more code

                    
                }


                Comment

                Working...
                X