Load More button fails for pdoPage with custom snippet

I’m using pdoPage with a custom snippet to return records from a MIGXdb database.

The correct number of records are returned for the value set by &limit however the button to load more records is not shown. But I can see .btn-more exists in the page generated as

<button class="btn btn-more btn-secondary rounded-pill px-5" style="display: none;">pdopage_more</button>

I am unsure why the button has style="display: none;". If I disable this style using Developer Tools in the browser and click the button it does not load more records, nor does this trigger a request in the Network tab.

Can anyone suggest what I have done incorrectly please?

My pdoPage call is

<h2>pdoPage</h2>
[[- #pdopage, .rows and !+page.nav markup is in event_grid__home.tpl, not hard coded around pdoPage call below
    <div id="pdopage">
        <div class="rows">
    https://docs.modx.pro/en/components/pdotools/snippets/pdopage#built-in-ajax-pagination
]]

[[- placeholder event_cards__row includes #pdopage, .rows and !+page.nav markup ]]
[[+event_cards__row]]

[[!pdoPage?
    [[- pdoPage parameters]]
    &element=`get_events` [[- name of the snippet to run ]]
    &limit=`3` [[- limit the number of resources returned per run eg. default single or ajax multiple, use 0 for unlimited results ]]
    &ajaxMode=`button`


    [[- get_events snippet parameters]]
    &filter=`1` [[- show filter dropdowns ]]
    &title=`What's on` [[- default: Upcoming Events ]]
]]

event_grid__home.tpl used by the snippet get_events is below, you can see the pdoPage elements, #pdopage etc., starting at line #36

<div id="the_events" class="bg-primary text-white">
    <div class="container my-5">
        <div class="row align-items-center py-5">
            [[- show filter if get_events?&filter=`1` ]]
            [[+filter:is=`1`:then=`
                <div class="col-12 text-end">
                    <div class="dropdown">filter options in first row otherwise text-center becomes offset
                        [[- family ]]
                        [[- output dropdown btn - TODO - update snippet calls to name templates, also filter__event_family.wrapper.php where templates are hard coded ]]
                        [[!filter__event_family? &current_family=`[[+family]]` &tpl=`filter__event_family.row.tpl`]]

                        [[- parent ]]
                        [[- output dropdown btn - TODO - update snippet calls to name templates, also filter__event_parent.wrapper.php where templates are hard coded ]]
                        [[!filter__event_parent? &current_parent=`[[+parent]]` &tpl=`filter__event_parent.row.tpl`]]

                        [[- month ]]
                        [[- TODO: add &tpl parameter to snippet call and check templates are not hard coded ]]
                        [[!filter__event_month]]
                    </div>
                </div>
            `]]
            <div class="col-12">
                <h1 class="events_heading text-center c_boldfirst">
                    [[+title:htmlentities]]
                </h1>

                [[- show subtitle (family/parent) name or Monthname YYYY if available]]
                [[+subtitle:isnot=``:then=`
                    <h2 class="text-center h4">[[+subtitle]]</h2>
                `]]
            </div>
        </div> [[- /.row ]]

        [[- pdoPage elements ]]
        <div id="pdopage">
            [[!+page.nav]] [[- previous notes: shows ul/li if default mode, shows button if ajaxMode=button, also required to trigger load more content on ajax scroll ]]
            <div class="rows">
                [[- placeholder event_cards__row includes div.row markup ]]
                [[+event_cards__row]]
            </div>
        </div>


        <div class="row pt-4 pb-5">
            <div class="col text-center">
                <a href="#" class="btn btn-secondary rounded-pill px-5">See more events - ajax load, btn to be outline brass style, show only if there are more events</a>
            </div>
        </div>
    </div> [[- /.container-fluid]]
</div> [[- /#the_events ]]

Does your custom snippet set the total amount of events, so that pdoPage knows how many pages there are?

@halftrainedharry
No, the custom snippet get_events doesn’t set the total number of events.

I’ve looked at the code for snippet.pdopage.php and it looks like the expected parameter is $total.

But can’t figure how to set this in the get_events snippet.

I suspect the method ot figure the total recordd count is along the lines of the example in the docs

$total = $xpdo->getCount('Box',array(

But am unsure how this can be generated in my get_events shippet.

The get_events snippet is below, any further help would be appreeciated.

<?php
/**
 * 
 * [[get_events? id=`4`]]
 * 
 * TODO: add option for &parent - to filter events on resources using event_parent template. if parent is set, ignore family/month/year with if/else?
 * TODO: add parameter for btn_text? some comps are 'load more matches' some 'show more events'
 * TODO: querystring no longer used - if snippet call specifies &family, &month or &year it should ignore anything set in the querystring?
 * 
 * &id - NOT REQUIRED - EVENT DETAIL SHOWN VIA TEMPLATE event_detail.tpl  ::  int, id of single event, not required, no default - can be set by snippet call or CustomRequest URI
 * 
 * &family - can be int set in snippet call, or injected into the $_GET prameters by CustomConfig based on the URI path
 * &parent - can be int set in snippet call, or injected into the $_GET prameters by CustomConfig based on the URI path
 * &month  - can be string set in snippet call, or injected into the $_GET prameters by CustomConfig based on the URI path
 * 
 * &title - string, for heading tag, default: Upcoming Events
 * 
 * &filter - int, show filter dropdown on output, default 0
 * &experience  - int, id of experience to limit event list to only events with that experience, default: null
 * &max_records - int, max records, default 3
 * 
 * 
 * &mLC_tpl - chunk for migxLoopCollection, default: event_card.tpl
 * &mLC_tpl_wrapper - chunk for migxLoopCollection, default: event_card_wrapper.tpl
 * &grid_tpl?? - to frame grid output??  default: event_grid__home.tpl
 * 
 * &debug - int, sets debug=1 for, default 0 - flag for migxLoopCollection call
 * 
 * NOTE: CustomConfig will work to the URI path supplied inc. part paths eg. /whats-on/football/scotland-men/ where full path
 *       is /whats-on/football/scotland-men/2023-11-19/scotland-v-norway/1574/
 * 
 */

/*  CustomRequest
    - available parameters
        [family] => football
        [parent] => scotland-men
        [date] => 2023-11-19
        [slug] => scotland-v-norway
        [id] => 1574
    - URI Format `/whats-on/family/parent/date/slug/id`
    - usage eg. $_GET['slug'];
*/


// pdoPage tests - pdoPage passes its parametetrs through to get_events which is set with &element=`get_events`

if(!empty($limit)) $temp = 'limit: ' . $limit;
if(!empty($offset)) $temp = 'offset: ' . $offset;

echo '<div><h2>temp</h2>' . $temp . '</div>';
// if(!empty($temp)) return $temp;



// basic setup

// silent exit if we don't have modx
if(empty($modx) || !($modx instanceof modX)) return;

// check we have id else return error - not an error, id indicates retreive event detail instead of event list
// if (empty($id)) return '[get_events] error: &id not set';

// NOT REQUIRED yet? - get details for specific event if id is set, otherwise show list
// $id = isset($id) ? intval($id) : '';


$id = !empty($id) ? $id : $_GET['event_id'];

// if family, parent or date set in snippet call prioritise the given value and cast it to int, else use CustomRequest $_GET value
$family = isset($family) ? (int)$family : $_GET['family']; // return '$family: ' . $family;
$parent = isset($parent) ? (int)$parent : $_GET['parent'];
$date   = isset($date)   ? $date : $_GET['date'];

// if title is set use it, otherwise set default
$title = isset($title) ? $title : 'Upcoming Events';

// if filter set make sure it's an integer, otherwise set default
$filter = isset($filter) ? intval($filter) : 0;

// if max_records set make sure it's an integer, otherwise set default
$max_records = isset($max_records) ? intval($max_records) : 3;

$debug = isset($debug) ? intval($debug) : 0;



//  deal with templates

// check if &tpl specified, else set default
// TODO: may need to configure switch for row template eg. if parent=football - can't do this in snippet, can only send one tpl to migxLoopCollection
$mLC_tpl = isset($mLC_tpl) ? $mLC_tpl : 'event_card.tpl';

// check if &tpl specified, else set default
$mlc_tpl_wrapper = isset($mlc_tpl_wrapper) ? $mlc_tpl_wrapper : 'event_card_wrapper.tpl';

// set the grid tpl
$grid_tpl = isset($grid_tpl) ? $grid_tpl : 'event_grid__home.tpl';


// configure where statement

// configure WHERE options - default statement (event must be published - requires comma to separate filter options to be added) and array for filter options
$where = '{"published":1},';
$arr_where = [];


// TODO: if we have $id or $_GET['id] task is to return the specified event?

if (isset($family) && $family == 'any') {
    // family is any so do nothing here, JSON for WHERE statement does not work with wildcard as % eg. $arr_where[] = '{"family":"%"}';
} elseif (isset($family)) {
  // at this point $family can be either int set in snippet call or string from CustomRequest URI
  
  // if it's not an int lookup the family id from family slug (string) from CustomRequest URI and cast it to int
  if(!is_int($family)) {
    $family = $modx->runSnippet('get__object_field', array(
      'class' => 'VenueFamily',
      'where_key' => 'slug',
      'where_value' => $family,
      'field' => 'id',
    ));

    // cast value retreived to int
    $family = (int)$family;
  }

  // we now have $family as int

  // lookup friendly family name which may be required in .tpl as +subtitle
  $family_name = $modx->runSnippet('get__object_field', array(
    'class' => 'VenueFamily',
    'where_key' => 'id',
    'where_value' => $family,
    'field' => 'name',
  ));

  // family slug is used by event_card__home.tpl to build link to experiences page
  $family_slug = $modx->runSnippet('get__object_field', array(
    'class' => 'VenueFamily',
    'where_key' => 'id',
    'where_value' => $family,
    'field' => 'slug',
  ));
  
  // construct JSON for WHERE statement
  $arr_where[] = '{"family":"' . $family . '"}';
}


/* $parent was elseif but needs to be if to chain uri parameters into query */
if (isset($parent) && $parent == 'all') {
    // parent is any so do nothing here, JSON for WHERE statement does not work with wildcard as % eg. $arr_where[] = '{"parent":"%"}';
  } elseif (isset($parent)) {
  // at this point $parent can be either int set in snippet call or string from CustomRequest URI

  // if it's not an int lookup the family id from family slug (string) from CustomRequest URI and cast it to int
  if(!is_int($parent)) {
    $parent = $modx->runSnippet('get__object_field', array(
      'class' => 'VenueParent',
      'where_key' => 'slug',
      'where_value' => $parent,
      'field' => 'id',
    ));

    // cast value retreived to int
    $parent = (int)$parent;
  }

  // we now have $parent as int

  // lookup friendly parent name which may be required in .tpl as +subtitle
  $parent_name = $modx->runSnippet('get__object_field', array(
    'class' => 'VenueParent',
    'where_key' => 'id',
    'where_value' => $parent,
    'field' => 'name',
  ));

    // construct JSON for WHERE statement
    $arr_where[] = '{"parent":"' . $parent . '"}';
    
}


// deal with dates

// if we have $date (eg. 2023-11-23) from CustomRequest split it into year, month and day
if(isset($date)) {
  // split the date string to an array
  $dateParts = explode("-", $date);

  // assign array elements to variables
  list($year, $month, $day) = $dateParts;

  // can now work with the $year, $month, $day variables as required
}



// validate month and year if they are both set and add to the where statement
if(isset($month) && isset($year)) {
  // cast values to int
  $month = (int)$month;
  $year = (int)$year;

  // get current year
  $current_year = date('Y');

  // check if month number is integer from 1-12 and year is the current_year year or current_year + 4
  if ((is_int($month) && $month >= 1 && $month <= 12) && (is_int($year) && $year >= $current_year && $year <= $current_year + 4)) {
    // find days in given month, this is required below
    $days = cal_days_in_month(CAL_GREGORIAN, $month, $year);

    // construct JSON for WHERE statement
    // where needs start and end date - start is always 01, end is $days - notice the gte and lte operators change below
    $arr_where[] = '{"date:>=":"' . $year . '-' . $month . '-01"}';
    $arr_where[] = '{"date:<=":"' . $year . '-' . $month . '-' . $days .'"}';

  } else {
    // TODO: add error message here
    // echo 'invalid date, showing all events';
  }
} else {
    // limit to future events with start date of >= current date

    // get today's date in format  YYYY-MM-DD (ref. https://www.php.net/manual/en/datetime.format.php)
    $today = date('Y-m-d');

    // add date limit to JSON for WHERE statement
    $arr_where[] = '{"date:>=":"' . $today . '"}'; 
}


// construct where statement to limit events returned by to those with experience available if &experience is set
// - this is used by experience_detail.tpl to show upcoming events with this experience type
if(isset($experience)) { 
    // DEBUG: return 'exp: ' . $experience;

    // cast value to int
    $experience = intval($experience);

    // need function to get concatenated rel_event_ids, then run: $c->where([ "id:IN" => array('5','6','7'), (eg. from getexperience_packages.php)

  $c = $modx->newQuery('VenueExperiencePackage');
  $c->select(array(
    // don't need all fields // 'VenueExperiencePackage.*',
    // don't need id either // 'VenueExperiencePackage.id',
    'event_ids' => 'GROUP_CONCAT(DISTINCT rel_event_ids SEPARATOR "||")'
  ));
  $c->where(array(
    'rel_experience' => $experience
  ));

  // prepare the sql statement
  $c->prepare();
  $sql = $c->toSQL(); // DEBUG: return '<div>' . $sql . '</div>';

  // run the query
  $query = $modx->query($sql);

  // get the single row with concatenated event_ids
  $row = $query->fetch(PDO::FETCH_ASSOC); // DEBUG return print_r($row); // eg. Array ( [event_ids] => 16||17||17||remove_this )

  // explode the $row['event_ids'] to an array we can sanitize
  $arr_event_ids = explode('||', $row['event_ids']); // DEBUG return print_r($arr_event_ids);

  // loop through the array check the value is an integer, otherwise remove it from the array
  foreach ($arr_event_ids as $key => $value) {
    if (!ctype_digit((string)$value)) {
        unset($arr_event_ids[$key]);
    }
  }
  
  // DEBUG return print_r($arr_event_ids);

  // remove duplicate values from the array
  $arr_event_ids = array_unique($arr_event_ids);

  // OPTIONAL
  // - reindex the array to have consecutive integer keys
  // $arr_event_ids = array_values($arr_event_ids);

  // - sort the array in ascending order
  // sort($arr_event_ids);

  $event_ids = implode(",", $arr_event_ids); // return print_r($event_ids);

  // finally, add the event_ids to the where statement to limit the events returned to those offering the specified experience
  $arr_where[] = '{"id:IN":[' . $event_ids . ']}';

    /*
    if (is_int($experience)) {
      // construct JSON for WHERE statement
      $arr_where[] = '{"rel_experience:LIKE":"' . $experience . '||%"}'; // check if 'id||' is at the beginning
      $arr_where[] = '{"OR:rel_experience:LIKE":"%||' . $experience . '||%"}'; // check if '||id||' is in the middle
      $arr_where[] = '{"OR:rel_experience:LIKE":"%||' . $experience . '"}'; // check if '||id' is at the end
      $arr_where[] = '{"OR:rel_experience:=":"' . $experience . '"}'; // check if 'id' is the only value
    }
    */
}

// if we have a custom $arr_where append the values to the default $where statement
if (isset($arr_where) && !empty($arr_where)) {
  $where .= implode(",", $arr_where);
} else {
  // DEBUG
  // $str_where = "No querystring";
}

// DEBUG
// echo 'str_where: ' . $str_where . '<hr>';
// echo 'where: ' . $where . '<hr>';


// create arr_output_props
$arr_output_props = [];


/**
 * run migxLoop Collection to retreive the data
 * - NOTE: must specify selectfields for joins otherwise the related fields are not retreived
 */


/* deal with limit and offset
   - if using manual query: $c->limit($limit, $offset);
   - but we're using migxLoopCollection - so check for and include &limit and &offset
*/

$mLC_output = $modx->runSnippet('migxLoopCollection',array(
  'packageName' => 'venue',
  'classname' => 'VenueEvent',

  // retreive data
  // note - comma after final brace is ok, the query still works
  // 'joins' => '[{"alias":"Family"},{"alias":"Parent"},]',
  'joins' => '[{"alias":"Family","selectfields":"name,img_src"},{"alias":"Parent","selectfields":"name,img_src"}]',
  'where' => '['.$where.']',
  'sortConfig' => '[{"sortby":"date"},"sortdir":"ASC"]',
  'limit' => $limit,
  'offset' => $offset,
  'totalvar' => $totalVar,

  // templates
  'tpl' => $mLC_tpl,
  // 'outputSeparator' => '<hr>',
  'wrapperTpl' => $mlc_tpl_wrapper,
  
  'debug' => $debug
));


// add the data to arr_output_props 
if(!empty($mLC_output)) {
  $arr_output_props['event_cards__row'] = $mLC_output;
} else {
// NOTE: pass empty array to getChunk below to avoid error - PHP Fatal error:  Uncaught ArgumentCountError: Too few arguments to function modX::parseChunk()
  $arr_output_props['event_cards__row'] = $modx->getChunk('filter_err__no_results.tpl', array());
}

// populate the arr_output_props used for filters
$arr_output_props['family']  = $family;
$arr_output_props['parent'] = $parent;
$arr_output_props['date'] = $date;


// if friendly names are set add them to arr_output_props
if(!empty($family_name)) {
  $arr_output_props['family_name']  = $family_name;
}
if(!empty($parent_name)) {
  $arr_output_props['parent_name']  = $parent_name;
}

if(!empty($family_slug)) {
  $arr_output_props['family_slug']  = $family_slug;
}
if(!empty($parent_slug)) {
  $arr_output_props['parent_slug']  = $parent_slug;
}

// create subtitle - prioritise date, then parent_name, else use family_name
if(!empty($date)) {
  // create subtitle as Monthname YYYY

  // convert $date to DateTime object $obj_date
  $obj_date = DateTime::createFromFormat('Y-m-d', $date);

  // check if $obj_date is a valid DateTime object
  if ($obj_date instanceof DateTime) {
      // set $subtitle to Monthname YYYY
      $subtitle = $obj_date->format('F') . ' ' . $obj_date->format('Y');
  }
} elseif(!empty($parent_name)) {
  $subtitle = $parent_name;
} elseif(!empty($family_name)) {
  $subtitle = $family_name;
} else {
  $subtitle = '';
}

// add subtitle to arr_put_props
$arr_output_props['subtitle']  = $subtitle;


// populate the arr_output_props used for &grid_tpl wrapper
$arr_output_props['title']  = $title;
$arr_output_props['filter'] = $filter;

// generate and return the final grid output - use getChunk to allow tags to be parsed eg. for :htmlentities
$output .= $modx->getChunk($grid_tpl, $arr_output_props);

return $output;

exit;





/* *********************************************************************** */
/* *************************** OLD CODE BELOW **************************** */
/* *********************************************************************** */





/**
 * 
 * 
 * the native modx way - unfinished
 * 
 * 
 * 
 */


// include the venue package
$base_path = $modx->getOption('core_path') . 'components/venue/';
$modx->addPackage('venue', $base_path . 'model/');

// configure the query

/*
 * need condition to figure if &id set
 *  &where=`{"date:>":"[[!GetDate:date=`%Y-%m-%d`]]"}`
 * 
 * if user selects a month, use it's number in the where clause as
 *      if $month {
 *          
 *      }
 * if a user selects
 * 
 */

$c = $modx->newQuery('VenueEvent');
$c->where(["published" => 1]);
$c->sortby('date','ASC');
$events = $modx->getCollectionGraph('VenueEvent', $c);

// create output as array of formatted items - this could apply to multiple event but we only have one event when working from specific VenueEvent ID

$output = '';

// check events collection has content
if (count($events) === 0) {
    $err_msg ='[get_events] event $id not found, not published or contains no published events';
    $modx->log(modX::LOG_LEVEL_ERROR, $err_msg);
    return $err_msg;
}

foreach ($events as $event) {
    print_r($event); // exhausts memory
    // $output .= $modx->getChunk('wrap_event.tpl', $event);
}

// implode formatted array and return as single block of formatted products
return $output;





/*
  OLD NOTES
*/

// query the products
//$products = $modx->getCollectionGraph('product', '{"option":{}}', ["published" => 1, "option.published" => 1]);
                                    // product is the class to use from the catalog scheme
                                                    // join product options table
                                                                    // WHERE product.published = 1 AND option.published = 1


/*
// class
$class = 'product';

//criteria
$c = $modx->newQuery($class);
// example: $crit->bindGraph('{"modUserProfile":{"internalKey":{}}}');
$c->bindGraph('{"option":{}}', ["published" => 1, "option.published" => 1]);

$c->where('published', 1);
$c->where('option.published', 1);
$c->sortby('name','ASC');
$c->sortby('option.name','ASC');

// $products = $modx->getCollectionGraph($class, $c); // produces same output as line below
$products = $modx->getCollectionGraph($class, '{ "option":{} }', $crit);

// print_r($products);

echo  'SQL: ' . $c->toSql(); // empty
*/

Well I won’t read your 500 lines of code. :wink:

Here is how it’s usually done:

$c = $modx->newQuery('SomeClass'); //create a query
$c->where([...]); // do some filtering and maybe some joins
$total = $modx->getCount('SomeClass', $c); // query the total amount of results
$c->limit(...); // add the pagination
$c->sortby(...); // set the sorting
$items = $modx->getCollection('SomeClass', $c); // query the results of the current page
...

// set the total amount of results as a placeholder for getPage/pdoPage
$modx->setPlaceholder('total', $total);

After filtering, you run getCount() to get the total amount of results.
Then you call limit() and run the query again for the current page.

It seems that in your code you use migxLoopCollection to query the data.
migxLoopCollection does set the total to the placeholder specified in 'totalvar'.

$modx->runSnippet('migxLoopCollection',array(
  ...
  'totalvar' => $totalVar,
));

Though it’s unclear what the value of $totalVar in your code actually is.


Also I guess pdoPage (by default) expects the total in a placeholder with the name page.total (and not total).

Well I won’t read your 500 lines of code.
Of course, I’m here to learn, so I’m genuinely asking for some help to guide me towards the answer, not for you to do it for me.

Tomorrow I’ll look at the value set by $totalVar in my snippet and if this can set the placeholder page.total.

Thanks again,

Chris

Having reviewed the get_events snippet, I am unsure why the value set for &totalVar in by pdoPage call failed to set the required placeholder.

I had

[[!pdoPage?
    ...
    &totalVar=`page.total`

And writing $totalVar to screen from the [[get_events]] snippet showed the expected value page.total.

But adding the placeholder to the MODX Template returned a value of 0 where it should have been 14.

The solution was to change the pdoPage call to

[[!pdoPage?
    ...
    &totalVar=`total`

The ajax load more functionality works as expected now.

Thanks again @halftrainedharry.