Can the calendar be adapted to pull data from Express?

Hi. I’ve just created an Express object in order for users to be able to submit event data that is then shown (1) by location on a map, and (2) by date on a calendar. The map page should be fairly straightforward using googlemaps and ajax calls (he said before looking any deeper into the subject), but first I would like to deal with the calendar display whereby events shown on the calendar will open up a ‘details’ modal in a similar way to the built-in calendar, but with much more data (from an Express object) being displayed to the end-user.

Before I start possibly reinventing the wheel, has anyone already attempted or achieved this task please? In an ideal world there would be a way of mapping the data source of the built-in calendar with an express object, and although I’m sure this doesn’t exist, I’m wondering if it would be theoretically possible without hundreds of man-hours.

Failing that, I will probably need to create a custom block which pulls data from the express object and then adds it to a unique instance of FullCalendar. Looking at the problem from a conceptual perspective in the first instance, does anyone have any experience or ideas to throw into the hat please?

Progress: I don’t need to create a custom instance of the calendar; I can use the standard calendar block with a custom template, and change the ‘events’ and ‘eventRender’ URLs for the Express object data. I’ve run a POC with hard-coded data and it works just fine. So that takes care of the calendar display; now I need to fetch the data.

Is it correct that the concrete API does not include Express objects? …I can’t see an endpoint listed in the docs.

I haven’t developed for concrete for a few years, have never touched the Express functionality, and am new to v.9. Can anyone suggest the most straightforward method or mechanism for fetching Express object data from the calendar view template please?

I can see that there are some nifty ajax mechanisms that work with distinct blocks e.g. using the action_ methods, but I’m going to need to call on some sort of an Express data endpoint (if there is one) from a calendar view template i.e. with different bIDs for starters. Any suggestions or pointers anyone …please?

Let me dig on this a bit and I’ll post back :+1:

Thanks Evan, but don’t spend too long on this as I have solved both problems and will write up my solution this weekend. Most of it has been fairly straightforward, but the googlemaps JS API has seen many changes in recent years, with their docs/notes lacking examples of how to achieve results with some of the more complex functionality. This has required rather a lot of coding trail and error before getting things to run error-free!

1 Like

So to get an Express entity appearing in the Concrete API you have to:

  • You have to edit an existing entity (you can’t set this up on creation)
  • Edit entity
  • Choose “Include this entity in REST API”

That should get it showing up in the API. Hope that helps!

Sorry for the delay in posting my solution, but here goes, and please chip in if anything I’ve done can be improved upon or corrected if wrong…

First we create a template for the calendar based on a copy of /concrete/blocks/calendar/view.php, but we replace the two data calls (one calls all the events and the other calls the selected event) as follows:

                [snip]
                events: '<?= trim($view->action('get_entry_list', Core::make('token')->generate('list_view'))).'/'.$entityID?>',
                nextDayThreshold: '00:00:00',
                eventDataTransform: function(event) {
                    if(event.allDay) {
                        event.end = moment(event.end).add(1, 'days')
                    }
                    return event;
                },

                eventRender: function(event, element) {
                    <?php if ($controller->supportsLightbox()) { ?>
                        element.attr('href', '<?= trim($view->action('output_modal_content', Core::make('token')->generate('modal_view'))) ?>/' + event.id + '/event').magnificPopup({
                [snip]

Next we create an endpoint for our events call by extending the Express Entry List Controller thus:

<?php

namespace Application\Block\ExpressEntryList;

use Concrete\Core\Entity\Express\Entity;
use Concrete\Core\Database\Connection\Connection;
use Symfony\Component\HttpFoundation\JsonResponse;
use Concrete\Block\ExpressEntryList\Controller as ExpressEntryListController;

class Controller extends ExpressEntryListController
{

    // ajax handler to return a JSON encoded array of express EVENT entity entries
    // $bID: the block ID of the calling block (not used in this case)
    // $exEntryID: the required express entity ID (int)
    public function action_get_entry_list($token = false, $bID = false, $exEntityID = null)
    {
        // validate the supplied token (generated by the server) to ensure it is valid/current
        if (\Core::make('token')->validate('list_view', $token) && !empty($exEntityID)) {

            // fetch the entity object from the submitted entity ID
            $entity = $this->entityManager->find(Entity::class, $exEntityID);

            // fetch the entity's handle, then construct the name of its search index data table
            $dbTable = camelcase($entity->getHandle()).'ExpressSearchIndexAttributes';

            // extract all entries from the search index data table that have been approved (ak_publish = Y) and which are not more than two months old
            $db = $this->app->make(Connection::class);
            $sql = "SELECT exEntryID, ak_title, ak_date, ak_days, ak_category FROM ".$dbTable." WHERE ak_publish LIKE '%Y%' AND ak_date > date_sub(now(), interval 2 month) ORDER BY ak_date";
            $query = $db->fetchAllAssociative($sql);
            $entries = [];
            $i = 0;
            foreach ($query as $row) {

                // form the exact start and end date-times (times are not shown at the front-end)
                $start = date('Y-m-d 09:00:00', strtotime($row['ak_date']));
                if ($row['ak_days'] < 2) {
                    $end = date('Y-m-d 17:00:00', strtotime($row['ak_date']));
                } else {
                    $end = date('Y-m-d 17:00:00', (strtotime($start)+(($row['ak_days']-1)*86400)));
                }

                // determine the calendar's event bar colouring according to the event category/type
                if (str_starts_with(trim($row['ak_category']), 1)) {
                    $bgColour = 'rgb(151, 151, 151)'; // grey
                    $txColour = 'rgb(255, 255, 255)'; // white
                } elseif (str_starts_with(trim($row['ak_category']), 2)) {
                    $bgColour = 'rgb(52, 138, 177)'; // blue
                    $txColour = 'rgb(255, 255, 255)'; // white
                } elseif (str_starts_with(trim($row['ak_category']), 3)) {
                    $bgColour = 'rgb(42, 155, 49)'; // green
                    $txColour = 'rgb(255, 255, 255)'; // white
                } elseif (str_starts_with(trim($row['ak_category']), 4)) {
                    $bgColour = 'rgb(186, 165, 0)'; // mustard
                    $txColour = 'rgb(255, 255, 255)'; // white
                } elseif (str_starts_with(trim($row['ak_category']), 5)) {
                    $bgColour = 'rgb(220, 130, 19)'; // orange
                    $txColour = 'rgb(255, 255, 255)'; // white
                } elseif (str_starts_with(trim($row['ak_category']), 6)) {
                    $bgColour = 'rgb(224, 57, 46)'; // red
                    $txColour = 'rgb(255, 255, 255)'; // white
                } else {
                    $bgColour = 'rgb(0, 0, 0)'; // black
                    $txColour = 'rgb(255, 255, 255)'; // white
                }

                $entries[$i]['id'] = $row['exEntryID'];
                $entries[$i]['title'] = $row['ak_title'];
                $entries[$i]['start'] = $start;
                $entries[$i]['end'] = $end;
                $entries[$i]['backgroundColor'] = $bgColour;
                $entries[$i]['borderColor'] = $bgColour;
                $entries[$i]['textColor'] = $txColour;
                $i++;
            }
            
            return new JsonResponse($entries);
        }
        exit;
    }
}

Obviously the above code includes many things that are relevant to my Express object that won’t be to yours, but you should be able to use the above as a guide to roll your own solution.

Next we need to create an endpoint for our rendered event call by extending the Express Entry Detail Controller. I have three different types of data so I’ll just show the event data here:

<?php

namespace Application\Block\ExpressEntryDetail;

use Concrete\Block\ExpressEntryDetail\Controller as ExpressEntryDetailController;
use Concrete\Core\Support\Facade\Facade;
use Doctrine\ORM\EntityManager;

class Controller extends ExpressEntryDetailController
{

    // ajax handler to print the contents of an entity entry as modal-ready html
    // $bID: the block ID of the calling block (int)
    // $exEntryID: the required express entry ID (int)
    // $type: 'event', 'club' or 'act'
    public function action_output_modal_content($token = false, $bID = false, $exEntryID = null, $type = null)
    {
        // validate the supplied token (generated by the server) to ensure it is valid/current
        if (\Core::make('token')->validate('modal_view', $token) && !empty($exEntryID) && !empty($type)) {

            // call the relevant modal content method
            if ($type == 'event') {
                echo $this->getEventEntryModalContent($exEntryID, $bID);
            } else if ($type == 'club') {
                echo $this->getClubEntryModalContent($exEntryID, $bID);
            } else if ($type == 'act') {
                echo $this->getActEntryModalContent($exEntryID, $bID);
            }
        }
        exit;
    }


    // fetch an express EVENT entry's data as modal-ready html
    private function getEventEntryModalContent($exEntryID = null, $bID = false)
    {
        // the app and entity manager are not available when calling this 
        // method from a third-party block, so we need to manually create them
        if (!isset($this->app) || !isset($this->entityManager)) {
            $this->app = Facade::getFacadeApplication();
            $this->entityManager = $this->app->make(EntityManager::class);
        }
        
        // fetch the entry and entity objects from the supplied ID
        $entry = $this->entityManager->find('Concrete\Core\Entity\Express\Entry', $exEntryID);
        if (is_object($entry)) {
            $entity = $entry->getEntity();

            // iterate through each entity attribute
            // See /html/concrete/src/Express/Export/EntryList/CsvWriter.php for 
            // examples of how to deal with multi-column values and associations
            $attributes = [];
            $ak = $entity->getAttributeKeyCategory();
            if (is_object($ak)) {
                foreach ($entity->getAttributes() as $att) {
                    $name = $att->getAttributeKeyDisplayName();
                    $handle = $att->getAttributeKeyHandle();
                    $value_ob = $entry->getAttributeValueObject($handle);
                    if ($value_ob) {
                        $value = $value_ob->getPlainTextValue();
                        // note the file ID of the uploaded image if applicable
                        $index = '';
                        if ($handle == 'image') {
                            $index = $value_ob->getSearchIndexValue();
                        }
                        $attributes[$handle] = ['name' => $name, 'value' => $value ?? '', 'index' => $index];
                    }
                }
            }
            //echo json_encode($attributes);

            // transfer the known/expected attribute values to an array
            $fID = 0;
            $div = [];
            $div['url'] = '';
            $div['date'] = '';
            $div['days'] = '';
            $div['title'] = '';
            $div['image'] = '';
            $div['status'] = '';
            $div['location'] = '';
            $div['features'] = '';
            $div['description'] = '';

            foreach ($attributes as $handle => $a) {
                $div[$handle] = $a['value'];
                // note the file ID of the uploaded image if applicable
                if ($handle == 'image') { $fID = $a['index']; }
            }

            // process and re-order the attribute values
            $return = '';
            // TITLE
            if (!empty($div['title'])) {
                $return .= '<h3>'.$this->sanitise($div['title']).'</h3>';
            }

            // IMAGE
            if (!empty($div['image'])) {
                // use 'small' thumbnail as its width is 740px (710px required)
                if (!empty($fID)) {
                    $f = \File::getByID($fID);
                    if (is_object($f)) {
                        $type = \Concrete\Core\File\Image\Thumbnail\Type\Type::getByHandle('small');
                        $src = $f->getThumbnailURL($type->getBaseVersion());
                        $return .= '<div class="cal-pic"><img src="'.$src.'" class="img-fluid" alt="'.$this->sanitise($attributes['title']['value']).'"></img></div>';
                    }
                }
            }

            return '<div class="cal-modal">'.$return.'</div>';
        }
    }
}

As you may have realised, I have only shown the processing of the event title and image, as my attributes are not going to be the same as your attributes, but this should be enough to demonstrate the process of calling the event data, processing it, constructing the html output, and returning it when called, to the pop-up calendar event modal.

Feel free to comment if you can see any issues with any of the above, as I do not profess to be any sort of expert, and this is my first foray into the world of concrete cms Express objects.

Note that as the two extended controllers are not otherwise instantiated on the page, we have to add both an Express Details block instance and an Express List block instance on the same page. We can select the relevant Entity for the Express Details block without it adding any content on the page (leave ‘entry’ as ‘from another page’), but we need to leave the Express List block unassigned else it will display content. Not selecting an ID will throw the odd ‘missing ID’ error in the logs when you edit the page, but there’s no way round this and it’s only a warning in the logs that you will be expecting.