Jump to content

Fieldtype Modules with JS Requirements in Admin that Make Repeaters Happy


gornycreative
 Share

Recommended Posts

Odds are pretty good that you have had to flip this switch:

image.png.b12fc99d4606fd3be82c4b22be0ec66b.png

Because you wanted to use a field that uses javascript to stuff some sort of interface feature into the DOM.

If you have the setting above flipped to anything but Off you know that the initialization methods of most javascript tools can only pop into place if the basic structure for the field already exists on the page. If the structure isn't there because either a Repeater item hasn't been added, or it hasn't been opened and AJAX is enabled, there is nothing in the DOM for your script to stuff.

For the purposes of this conversation, Repeater and Repeater Matrix Inputfields function in the same way and create the same problem.

There has been talk of setting up a global set of events and handlers that would provide signals that scripts could listen to.

While that conversation continues, I decided I wanted to at least try to put together a design pattern that would allow these kinds of fields to instantiate with repeaters under any circumstance:

  1. The field exists on a page edit form without any complications - just the field by itself.
  2. The field exists in a repeater template fieldgroup, and that Repeater has no rows yet - they need to get added.
  3. The field exists in a repeater template fieldgroup, and the rows exists but they are closed.

Case 2 and 3, if you are using the recommended switch setting, do not load the underlying inputfield structure until their state shifts from closed to open.

Once they are open, the form fieldset structure loads, but the script calls that you have already made are done - the fields don't work.

What sort of design pattern do we need to solve this problem?

  • We need our inputfield/fieldtype module to load the appropriate javascript and css files to bring the methods we need to initialize the filed instances.
  • We need to know if our fieldtype is on a page, and if it will show up in the markup for the Page Edit form.
  • We need to know if there are Repeater fields on a page, if they include our field, and how we can specifically identify them.
  • We need to be able to pass a list of these fields to the admin page on the client side.
  • We need the admin page to initialize the fields it can see, and be ready to initialize fields that it can't see yet.

Once these tasks are all accomplished, we will have a JS powered inputfield that works seamlessly whether it is flying solo on a page, or part of a Repeater field structure - whether open at load, closed at load, or brand new.

There are parts that both the Fieldtype and Inputfield modules have to play. You will need to use both to get this job done.

Most of the heavy lifting is done in the Inputfield portion, but I'll address each file separately as we look at each step.

Loading the assets

First of all, I'm assuming that you have got your extra module assets for whatever library you are working on in a vendor subfolder beneath your module directory. You could have several packages.

Here's how I load assets in InputfieldGrapick:

	public function renderReady(Inputfield $parent = null, $renderValueMode = false) {

		// Add JS and CSS dependencies
		$config = $this->config;
		$info = $this->getModuleInfo();
		$version = $info['version'];
		$mod_url = $config->urls->{$this};
		$mod_scripts = [
			$mod_url . "vendor/grapick_0_1_10/dist/grapick.min.js?v=$version",
			$mod_url . "vendor/spectrum_1_8_1/spectrum.js?v=$version",
			$mod_url . "{$this}.js?v=$version",
		];
		$mod_styles = [
			$mod_url . "vendor/grapick_0_1_10/dist/grapick.min.css?v=$version",
			$mod_url . "vendor/spectrum_1_8_1/spectrum.css?v=$version",
			$mod_url . "{$this}.css?v=$version",
		];
		foreach($mod_scripts as $ms) {
			$config->scripts->add($ms);
		}
		foreach($mod_styles as $ms) {
			$config->styles->add($ms);
		}

		return parent::renderReady($parent, $renderValueMode);
	}

We get our module and it's current version, add it as a GET parameter to break cache when we update, and add the assets to scripts and styles.

Keep in mind that using renderReady() makes sure these scripts load on admin pages where the module's inputfield is loaded, and that they load with other scripts at the head of the document.

Now that we've added our vendor files and custom module .js and .css files to the config arrays, it's time to build out the markup for the form.

Setting up our target element on the form

There are plenty of ways to design your markup for the form. I want to focus on the elements of the __render() method in the InputfieldModule file that are important for what we are trying to do.

The main thing we need to get here is the 'name' property of the field instance. Might as well get the page and id as well:

	public function ___render() {

		$name = $this->attr('name');
		$page = $this->page;
		$id = $this->attr('id');

...

Once I have started building my fieldset, there is one field I want to set apart as a special field. In the case of Grapick, this is the div that the javascript is going to replace with a gradient color selection UI.

$inputfields = new InputfieldFieldset();

		$inputfields->label = 'Grapick Gradient';

		$f = $this->modules->get('InputfieldMarkup');
		$f->id = $name.'-gradient';
		$f->label = "Grapick Gradient";
		$f->name = $name.'_gradient';
		$f->columnWidth = 50;
		$f->value =  "<div class='uk-height-small grapick' id='{$name}_grapick_control'></div><br><div class='uk-dark uk-border-rounded uk-padding-remove'><span class='uk-text-meta' id='{$name}-rule'>{$rule}</span></div>";
		$inputfields->add($f);

		...

There are plenty of other fields on this form, but take a look at the value of this form. I've incorporated the field name into the id for the DIV container. I've done the same for the Rule div and I apply this principle to all of the input id values. In my case, it is always $name followed by a common target.

In this case $field_name_grapick_control is the ID that is important. We want to be able to build a list of these IDs and pass them on to Javascript so that we can manipulate them on the frontend when we are editng the form.

Bridging the gap between PHP and Javascript

In order to bridge the gap between the PHP backend and the Javascript frontend, we use $config->jsConfig();

This function creates a keyed object with nested objects as properties - like a key => value array in PHP. It can be used the same way.

You might be tempted to put the call for this here, at the end of the __render() function, and that would work if you didn't have to deal with repeaters.

You could create an array such as:

$js_array[$name][
	'field' => $name.'_grapick_control',
	'loaded' => false,
];

and then add it to a jsConfig key:

wire()->config->jsConfig('grapicks', $js_array);

and I initially did this. If you only had this single control target on the page, you'd be all set.

But with repeaters, every time an instance of the field is loaded it would overwrite your array. You'd end up with only one element on the JS side.

Instead we need to move this functionality over to the FieldtypeModule file.

Adding our singleton field to the bridge

In the FieldtypeModule we will build out a function that provides us with the data we need to get the list of IDs for fields not yet conceived.

We start with a hook:

	public function init() {
	    $this->addHookAfter('ProcessPageEdit::buildFormContent', $this, 'setJsConfig');
	}

This will provide us with access to the InputfieldWrapper containing the form via $e->form.

From here we have the following function (this is what all of the experienced folks really wanted to see):

	public function setJsConfig($e) {
		$form = $e->return;
		$js_array = [];
		foreach($form->children as $fc) {
			$f_class = $fc->className;
			switch($f_class) {
				case 'InputfieldGrapick' :
					$ctrl = $fc->name;
					$js_array[$ctrl]['loaded'] = false;
					break;
				case 'InputfieldRepeater' :
				case 'InputfieldRepeaterMatrix' :
						foreach($fc->value as $enum => $it) {
							$fg = $it->template->fieldgroup;
							foreach($fg as $r_field) {
								if($r_field->type == 'FieldtypeGrapick') {
									$ctrl = $r_field->name.'_repeater'.$it->id;
									$js_array[$ctrl]['loaded'] = false;
								}
							}
						}
					break;
			}
		}		
		wire()->config->jsConfig('grapicks', $js_array);
	}

What this function does is:

  • Grab the form object.
  • Init the js_array variable.
  • Iterate through the children of the form. This provides you with an array of all the inputfields.
    • Run a switch/case against the class name of the field - this gets you to your singleton field right away.
    • Grab the name of the field if it yours, stuff it in the box with a nested array with a single 'loaded' element.
    • In the case of Repeater or Repeater Matrix, we must go deeper.
      • Grabbing the value of each element of the repeater field gives you the template for the element.
      • From the template, you can get the fieldgroup.
        • Iterate through each field in the fieldgroup - again looking for your module InoutfieldModule - but this time as the type of field.
        • For each match, you have enough information to build out what the repeater field name will be when it is loaded from AJAX: your field's name, '_repeater' with the repeater page id.
        • Finally, add that repeater field page id combo into the js_array with a false 'loaded' flag.

Once that is done, you now has a $js_array of all the root field names that get used to manufacture the id of your inputfield.

If you remember how we added $name in the __render() Inputfields markup for the $name_grapick_control id we assigned to that special div (and the names/ids for the other fields too - you can check out the code) that $name variable will get populated with that $field_name '_repeater' $id combination.

We can now access that list on the javascript side. We don't know when the elements with our prized IDs will show up on the page, but we know what to expect when they do.

Adding our uncreated repeater babies to the bridge

As this point, the basic elements on the PHP side are done. Moving on to the javascript side.

I won't go into too many details about the structure of the js file. Suffice to say all of the subroutine functions get stacked up top, then consts and then we get into the core functionality.

The strategy here is first to get the array we just sent over the bridge from PHP. In my InoutfieldGrapick.js file, I do that here (skip past the trims and color conversion stuff):

/**
 * Begin grapick implementation
 */

//globals

var upType, unAngle, gp = [];
const stopTxt = [], swType = [], swAngle = [], swOrigin = [], swSize = [];

const pwConfig = ProcessWire.config.grapicks;

console.log('pwConfig',pwConfig);

The constant pwConfig is where I stick the array, which is Processwire.config.whatever_key_assigned - in this case 'grapicks' is the property I am grabbing.

The console log is an object with a key for each field. I don't want to get bogged down with the details, but here's what we are doing with this list.

First, the list has a 'loaded' flag because we need to keep track of what has been loaded from what we know and what has not. The reason for this, is that some fields are going to be available right away on the page, which other will require us to poll the page looking for the element to appear. Once it does, in the case of repeaters, we can initiate the instance and log in the pwConfig object that the field is loaded and ready to go. Once the repeater item is loaded you can collapse it and it doesn't disappear - once it is loaded we are good to go.

So I have a pollingRun() method - this assumes everything is loaded until a target is missing:

function pollingRun() {
	let done = true;
	for (let key in pwConfig) {
		if (pwConfig.hasOwnProperty(key)) {
			if(pwConfig[key]['loaded'] == false) {
				done = false;
				let ctrl = document.getElementById(key+'_grapick_control');
				//console.log(key, pwConfig[key], ctrl);
				if(ctrl) {
					createGrapick(key); //This is really whatever function you need to use to instantiate your special field and it passes a modification of the field name to match the ID we set up in our inputfield __render();
				}
				//console.log('Grapick field: ' + key);
				for (let key2 in pwConfig[key]) {
					//console.log('---->', key2, pwConfig[key][key2]);
				}
			}
		}
	}
	if(done) {
		//console.log('Done.');
		clearInterval(poll);
	}
}

At the end of my function that initializes the instance of the javascript activated object - in this case createGrapick() - I set the loaded flag for the given key to 'true':

var createGrapick = function(key) {
	gp[key] = new Grapick({
		el: '#' + key + '_grapick_control',
		colorEl: '<input id="' + key + '_colorpicker"/>', // I'll use this for the custom color picker
		direction: 'right',
		min: 0,
		max: 100,
		height: '2.5rem',
	});
	
	...

		//Will allow an inputfield to be picked up as changed when using the advanced features of UserActivity.
		pwStopsWrap.classList.add('InputfieldStateChanged');
		build_rule(key);
	})
	gp[key].emit('change');
	pwConfig[key]['loaded'] = true;
};

Now there is something else I did here, because there is an 'on change' event handler for my particular javascript object - I add an 'InputfieldStateChanged' class to a critical inputfield.

The reason that I did this was because I am using the experimental features of UserActivity that track the changes made to a page. If I don't set this extra class on the pwStopsWrap input (which is the main data store for FieldtypeGrapick) the contents of the input will not save because PW uses the existence of this class to detect that a field has changed on the form and needs to be updated when the page is saved.

If you aren't using UserActivity with this setting:

image.png.b40f33aee55c66494c97013fb8437448.png

then the adding of that class doesn't help or hurt you - although you may see warnings that you have unsaved changes left on the page if you mess around with the controls and then try to walk away.

At the very end of this function you see I set the pwConfig[key]['loaded'] flag to true.

That field is loaded. Great!

Now that the pollingRun() function is set, near the bottom of the page I set up an event listener on the DOM because I need to wait until the page is loaded before I do the first run of the pollingRun() function:

document.addEventListener('DOMContentLoaded', pollingRun, false);

var poll = setInterval(pollingRun, 1000);

Once the page had loaded, the static plain version of our field is now active and loaded.

If you were to look at the rest of the pwConfig object you'd see that you've got one 'true' and all of the repeater item fields are false, right?

Well not always. If you have set your repeater field to remember which items are open, when you load the page you will discover that during the pollingRun() iteration those fields also instantiated.

You also see I've set up a 1 second interval call. This runs the pollingRun() function every second, waiting for you to open one of those closed repeater items. Once you do, it detects the target, initializes it and sets the loaded flag for those.

Once all of the fields in the pwConfig has true loaded flags, the job is done and the polling interval is stopped.

Setting the watchman to catch singletons, hidden babies, revealed babies and unexpected babies

At this point nearly all of our cases are covered, but there is one more. What happens when someone adds a new repeater item? There is no way to know in advance with the RepeaterPage id is going to be.

So I have another function and another event listener set up:

function getGrapickControlNodes() {
	const nodeSet = document.querySelectorAll('[id*="_grapick_control"]').forEach(item => {
		keyName = item.id.substring(0,item.id.indexOf('_grapick_control'));
		pwConfig[keyName] = {'loaded':true};
		createGrapick(keyName);
	});
}

This function may seem a little convoluted, but it builds a node set that looks for all of the control elements on the page, and for each one it pulls the root field name (minus the suffix we added - _grapick_control - to identify our javascript instance target) - adds an entry for the newly created repeater item into the pwConfig object, sets the 'loadedl' flag to true and loads the instance.

If I didn't add the representative object to the pwConfig object, I'd get a javascript error at the end of the createGrapick() method run because there would be no flag to set. I could have set the flag to 'false' here and then let createGrapick set it to 'true' when it was done.

There is no need to run this function very often. Most of the targets on the page have already been populated.

But we do want to watch the DOM and see if any new nodes are added:

document.addEventListener('DOMContentLoaded', () => {

	//Look for new grapick fields (to account for repeaters)

	// select the target node
	var target = document.getElementById('ProcessPageEdit');

	// create an observer instance
	var observer = new MutationObserver(function(mutations) {
		mutations.forEach(function(mutation) {
		var nodes = mutation.addedNodes;
		var node;
		for(var n = 0; node = nodes[n], n < nodes.length; n++) {
			test_id = node.id;
	        if(node.tagName == 'LI' && test_id.includes('repeater_item')) {
				getGrapickControlNodes();
			}
		};
		});
	});

	// configuration of the observer:
	var config = { attributes: false, childList: true, subtree: true, characterData: false };

	// pass in the target node, as well as the observer options
	observer.observe(target, config);
		
}, false);

I added this below our other DomContentLoaded listener.

This function sets up a mutation observer to watch the DOM tree starting with the ProcessPageEdit id ancestor. If any nodes are added that are LI elements with a class setup that includes the 'repeater_item' class the getGrapickControlNodes() function will fire.

Since both repeater items and repeater matrix items have this class, it will fire if either type of repeater field has an extra item added.

So that's it!

We set up our special inputfield to incorporate the field name along with a unique suffix.

We got a list of all the fields in the ProcessPageEdit Inputfield set that exist on the PHP side.

We passed that list via jsConfig() on to the Javascript side,

We iterated through that list once the page loads to catch the targets that are already part of the DOM.

We watch the page every second for the moment when the user opens an AJAX loading field and make sure those instances are started.

We killed off the interval when the list is all loaded.

We watch the DOM ProcessPageEdit node and it's descendant with a MutationObserver for any newcomers and instantiate them too.

Granted there are many ways to do this I'm sure, but this is what I did with FieldtypeGrapick.

  • Like 1
Link to comment
Share on other sites

19 minutes ago, gornycreative said:

We iterated through that list once the page loads to catch the targets that are already part of the DOM.

We watch the page every second for the moment when the user opens an AJAX loading field and make sure those instances are started.

We killed off the interval when the list is all loaded.

We watch the DOM ProcessPageEdit node and it's descendant with a MutationObserver for any newcomers and instantiate them too.

As far as I know, this is all that's needed to initialise an inputfield in all circumstances regardless of if AJAX-loaded or inside new or existing repeater items:

function initMyCustomType($el) {
	// Initialise whatever is needed for $el here...
}

$(document).ready(function() {
	$('.InputfieldMyCustomType').each(function() {
		initMyCustomType($(this));
	});
});

$(document).on('reloaded', '.InputfieldMyCustomType', function() {
	initMyCustomType($(this));
});

 

  • Like 1
Link to comment
Share on other sites

1 hour ago, Robin S said:

As far as I know, this is all that's needed to initialise an inputfield in all circumstances regardless of if AJAX-loaded or inside new or existing repeater items:

I think that's right @gornycreative. I hit this problem with htmx and repeaters - this was my solution:

// To make sure that hx- attributes fire in lazy-loaded repeater fields
$(document).on('reloaded', '.InputfieldRepeater', function (event) {
    htmx.process(this);
})

 

  • Like 1
Link to comment
Share on other sites

Can someone please make a documentation page about this file - just a short one - under Docs > Fields, types and input?

I feel stupid for having wasted my time solving for this problem, and wasting even more time writing it up and trying to be helpful, when everyone has demonstrated it was entirely unnecessary.

There's an entire area dedicated to the PHP API and literally nothing for this.

 

Link to comment
Share on other sites

Create an account or sign in to comment

You need to be a member in order to leave a comment

Create an account

Sign up for a new account in our community. It's easy!

Register a new account

Sign in

Already have an account? Sign in here.

Sign In Now
 Share

  • Recently Browsing   0 members

    • No registered users viewing this page.
×
×
  • Create New...