Trouble with virtual Pages and the toPages() method

Hey Kirby Forum,

I have a relatively big number of virtual pages in my current kirby project. Those pages are created from an API and hold job postings of our customer. One use case for these virtual pages is displaying some of these jobs inside a block. The pages should be selectable. The corresponding field in the block blueprint for this looks like this:

jobs:
    label: Spotlight Jobs
    type: pages
    max: 3
    query: site.index.filterBy('intendedTemplate', 'job')
    store: uuid

Now inside my block snippet I would expect this to work:

<?php
foreach($block->jobs()->toPages() as $job): ?>
	<!-- do something with the job page --> 
<?php endforeach; ?>

but instead the toPages() method returns nothing. When I use dump() to debug on $block->jobs() I get this:

Kirby\Cms\Field Object
(
    [jobs] => Array
        (
            [0] => page://hDh1qk0Vcu6G1Wk1
            [1] => page://XivvlXA0gMaC8FrB
            [2] => page://RO1rqciRDJOlX4UW
        )
)

which seems great but the moment I use ->toPages() it becomes

Kirby\Cms\Pages Object
(
)

I can’t get them into the template if I use the page() helper to query one of the uuids as well. This is especially weird to me since I am able to see and select pages inside my pages field of the block in the panel.

Can anyone point me in the right direction on how to make toPages() work?

The dump() output looks wrong, a field object should have a key and a value. Could you please provide

  • your Kirby version
  • your complete custom block blueprint
  • the json result stored in the content file

Thanks!

Oh, and one more thing: Do your virtual pages have a fixed UUID? Please also post your model where you create those pages.

Hey texnixe,

sure I will provide the requested data.

Kirby Version: 3.9.5

The block blueprint:

name: Job-Listing
icon: file-spreadsheet
preview: fields
wysiwyg: true
fields:
  showControls:
    type: toggle
    label: User-Filter anzeigen
    default: true

  controls:
    type: object
    label: User-Filter
    fields:
      useSearch:
        type: toggle
        default: true
        label: Suche

      useLocation:
        type: toggle
        default: true
        label: Standort

      useContract:
        type: toggle
        default: true
        label: Vollzeit/Teilzeit

  jobs:
    label: Spotlight Jobs
    type: pages
    max: 3
    query: site.index.filterBy('intendedTemplate', 'job')
    store: uuid

  extends: sections/block-style

the preview: fields entry refers to a plugin I use to display block fields directly in the panel.

The stored JSON looks like this:

"blocks":[
 {
    "content":{
       "showcontrols":"true",
       "controls":{
          "usesearch":"true",
          "uselocation":"true",
          "usecontract":"true"
       },
       "jobs":[
          "page://hDh1qk0Vcu6G1Wk1",
          "page://XivvlXA0gMaC8FrB",
          "page://RO1rqciRDJOlX4UW"
       ],
       "style":""
    },
    "id":"a427b13c-28b0-4a42-9d95-8ef9d83f2a28",
    "isHidden":false,
    "type":"JobListing"
 }
],

The pages don’t have a fixed UUID. Not strictly at least: I query the API and check if there is an updated request to the last query. If no I use a cached version as the API is unfortunately quite slow. The API updates maybe once every one or two days. Here is my code for creating the virtual pages:

<?php

use Kirby\Uuid\Uuid;
use Kirby\Toolkit\Date;
use voku\helper\HtmlDomParser;

// removes unessential tags and attributes from job data
function cleanHtml($str) {
	if(!empty($str)) {
		$parsed = new HtmlDomParser($str);

		foreach($parsed->find('*') as $e) {
			// remove all inline styles
			if($e->hasAttribute('style')) {
				$e->setAttribute('style', NULL);
			}

			// remove all declarations inside font tags
			if($e->hasAttribute('face')) {
				$e->setAttribute('face', NULL);
			}

			if($e->hasAttribute('color')) {
				$e->setAttribute('color', NULL);
			}

			if($e->hasAttribute('size')) {
				$e->setAttribute('size', NULL);
			}
		}

		return $parsed->save();
	} else {
		return null;
	}
}

class CareerPage extends Page
{
    public function children()
    {

        if ($this->children instanceof Pages) {
            return $this->children;
        }

		// fetch all open jobs first so we can get details for each one later
        $results = [];
        $pages   = [];
		$shouldUpdate = false;
        $openJobsRequest = Remote::get('https://firstapicall.example');

        if ($openJobsRequest->code() === 200) {
            $results = $openJobsRequest->json(false);
        }

		if(property_exists($results, 'LastUpdateTime')) {
			$cache = kirby()->cache('zvoove');
			$apiData = $cache->get('zvoove');

			if(!empty($apiData)) {
				$cacheLastUpdated = $apiData['LastUpdateTime'];
				$apiLastUpdated = $results->LastUpdateTime;

				if($cacheLastUpdated !== $apiLastUpdated) {
					$shouldUpdate = true;
				}
			} else {
				$shouldUpdate = true;
			}
		}

		if($shouldUpdate) {
			foreach ($results->Items as $key => $item) {
				$stelleUuid = $item->StelleUuid;

				// fetch details about single job and create page from it
				if(!empty($stelleUuid)) {
					$jobRequest = Remote::get('https://secondapicall.example?stelleUuid=' . $stelleUuid);

					if ($jobRequest->code() === 200) {
						$job = $jobRequest->json(false);

						if(!empty($job)) {
							$pages[] = [
								'slug'     => property_exists($job, 'LinkSlug') ? $job->LinkSlug : $stelleUuid,
								'num'      => $key+1,
								'template' => 'job',
								'model'    => 'job',
								'content'  => [
									'title'    => $job?->Bezeichnung,
									'id'	   => $stelleUuid,
									'slug'	   => $job?->LinkSlug,
									'uuid'     => Uuid::generate(),
									'workingTimeMin' => $job?->Arbeitsstunden,
									'workingTimeMax' => $job?->ArbeitsstundenBis,
									'workingTimePeriod' => $job?->ArbeitsstundenZeitraumLookup?->Bezeichnung,
									'collectiveAgreement' => $job?->Tarifvertrag,
									'salaryMin' => $job?->Gehalt,
									'salarayMax' => $job?->GehaltBis,
									'salaryCurrency' => $job?->GehaltWaehrung,
									'salaryPeriod' => $job?->GehaltZeitraum,
									'benefits' => cleanHtml($job?->Arbeitgeberleistung),
									'tasks' => cleanHtml($job?->Aufgaben),
									'contact' => cleanHtml($job?->KontaktText),
									'employerIntroduction' => cleanHtml($job?->Arbeitgebervorstellung),
									'contractType' => $job?->Vertragsart,
									'location' => $job?->EinsatzortOrt,
								],
							];
						}
					}
				}
			}

			$jobs = Pages::factory($pages, $this);

			// save jobs to cache
			kirby()->cache('zvoove')->set('jobs', $pages);

			// save initial request to cache
			kirby()->cache('zvoove')->set('zvoove', $results);
			return $this->children = $jobs;
		} else {
			return $this->children = Pages::factory(kirby()->cache('zvoove')->get('jobs'), $this);
		}
    }
}

Thanks!

If you generate a new UUID at each request, this cannot possibly work. Then the UUID stored in your jobs field doesn’t exist the next time.

It seems that setting the UUIDs of the virtual pages to a fixed one coming from the API fixed this issue. Which makes sense as otherwise there wouldn’t be a way to properly save those jobs to the block. Maybe I got unlucky and yesterday during testing the API updated more often than expected and the block saved UUIDs of then deleted virtual pages.

Follow up question though: After overriding the career->children my page is rather slow and even the initial paints of my site take a lot longer than before introducing the virtual pages. Is this to be expected with virtual pages, even with caching? I have around 150 job pages stored.

You always make this request first, not matter if cached or not.

1 Like

That is true. My thought was that this way if our customer creates a new job posting in their corresponding system Kirby would notice instantly by the last updated field I get in the first request. Unfortunately that first request alone takes a whole second and is still missing required info which gets fetched with request two.

I now changed it so that the cache has a lifetime and the API only gets queried when there are no entries in the cache. It functions way better now. Thanks for your help!