Jump to content

Use dependent selects in a module config


Robin S
 Share

Recommended Posts

@rick messaged me asking for advice about how to use dependent selects in a module config. That is, where the options of one select change depending on what is selected in a another select. I thought others might also find this info useful so I've put together a demonstration module: https://github.com/Toutouwai/DemoDependentSelects

The general idea is that the dependent (target) select needs to include all the possible options, and then the options are hidden or shown by JavaScript depending on what is selected in the source select.

See the source code for comments.

 

demo

  • Like 15
Link to comment
Share on other sites

Wow! Thank you! I was hoping for a suggestion where to look, not a complete working example. That is the cool thing about you, @Robin S, you are always above and beyond anyone's request for assistance. Much respect!

  • Like 5
Link to comment
Share on other sites

Ooh that looks useful. I’m away from my Desktop at the moment, but that looks like it might be handy to make my FieldtypeMeasurement module (even 😉) slicker. I.e no need to save the first config field before choosing the dependent one. 

  • Like 1
Link to comment
Share on other sites

  • 3 weeks later...

I looked into this further in the context of a Fieldtype module - specifically my FieldtypeMeasurement module. Unfortunately, it proved to be a bit too complex (at least for my tiny brain) to use @Robin S's idea. Instead, I attempted to use htmx (encouraged by my previous efforts with @kongondo's help) and made quite a lot of progress, but not quite enough....

I thought it would be helpful to share my efforts as a general solution for Fieldtype modules would be nice.

The basic idea is to specify the hx- attributes in the config InputFields in the module as follows:

public function ___getConfigInputfields(Field $field): InputfieldWrapper {
		/* @var $field \ProcessWire\FieldtypeMeasurement */
		$inputfields = parent::___getConfigInputfields($field);

		$f = $this->modules->get("InputfieldSelect");
		$quantities = Measurement::getQuantities();
		$f->label = __("Quantity");
		$f_name = 'quantity';
		$f->name = $f_name;
		$f->columnWidth = 50;
		$f->description = __("Type of physical quantity to be measured.");
		foreach($quantities as $quantity) {
			$f->addOption($quantity);
		}
		$f->value = $field->get('quantity');

		#### add HTMX to change the units selection ####
		$adminEditURL = $this->wire('config')->urls->admin . "setup/field/edit/";
		$adminEdit = "{$adminEditURL}?id={$field->id}&refresh=1";
		$f->attr([
			'hx-get' => $adminEdit, // caught by HTMX AJAX LISTENER in hook
			'hx-target' => "#Inputfield_unit_set", // our element to target with swap
			'hx-swap' => 'innerHTML', //
			'hx-trigger' => 'change', //
			'hx-include' => "[name=quantity]",  // the new quantity
		]);
		########## end of hx- attributes ########################

		$f->addClass('no-selectize');
		bd($f, 'quantity field');
		$inputfields->append($f);

...

		$quantity = $field->get('quantity');
		$fs = $this->unitSet($quantity, $field);
		$inputfields->append($fs);

...
		
		return $inputfields;
	}

Then in the module's ready() method, add a hook like this:

		$this->wire()->addHookAfter('ProcessField::buildEditForm', function(HookEvent $event) {
			##################### HTMX LISTENER #############################
			$input = wire('input');
			$ajax = wire('config')->ajax;
			if($ajax && $input->get('refresh') == 1) {
				$quantity = $input->get('quantity');
				$fieldId = $input->get('id');
				$field = fields()->get("id=$fieldId");
				$fs = $this->unitSet($quantity, $field);
				$inputfields = parent::___getConfigInputfields($field);
				$inputfields->append($fs);
				echo $inputfields->render();
				die();
			}
		});

I have omitted the code for unitSet() here, for simplicity, but can show it if needed. Basically, it adds the selector field for units plus a notes field, both of which are dependent on 'quantity'. An important point to note is that the same unitSet() method is called in getConfigInputfields and in the hook.

What works:

  • The dependent select field ('units') is updated by htmx on a change of quantity, as is the dependent 'notes' field.

What doesn't work:

  • If I use an AsmSelect field for the 'units' dependent select, selected items are not added as they should be. I have checked that all the required js appears to be loaded, but maybe it is not all available in context. If I switch to a plain Select field, the dependent select appears to operate correctly, but...
  • Selected items (in the dependent 'units' field) are not retained after saving the field - i.e. the selection is emptied. Other dependent and changed fields are saved correctly.

Re-selecting and saving a second time fixes both these issues, but that rather spoils the whole point of the exercise.

I am not sure of the reason for these problems, but this is my analysis so far:

  • In saving the field, PW uses the 'form' object built by ProcessField::buildEditForm(). This form is the original form, not taking into account the change of 'quantity'. The hook inserts the new HTML so that everything displays correctly (Asm excepted) but does not update the form. This may account for the second problem (at least) because the 'options' property in the form has the dependent options for the previous quantity, not the newly-selected quantity, so the newly-selected dependent 'units' item is not in the options list.
  • I looked into modifying the form in the hook (after all, it is $event->return), but frankly struggled. I'm not sure whether it was the syntax or protected properties that was preventing me from updating 'form'; in any case, the updated form is not retained as the hook has to 'die' to prevent recursive loading - so setting $event->return has no effect.

So I'm afraid I've hit a bit of a brick wall and have run out of ideas for now. The module works fine, as is, however - you just have to save after changing quantity and then select the units, but it would be nice to have an interactive solution. I'm hoping there are cleverer/more experienced people out there with better ideas 😄

 

Link to comment
Share on other sites

EDIT NOTE: If you read this comment earlier, I have deleted that content as it was all a red herring.

I have now found the solution to the second problem:

In order to populate the form correctly (as well as swapping in the html), we need to catch the new quantity when the field is saved. Saving the field calls getConfigInputFields again so we can simply add these lines in that method iin our module:

		$input = $this->wire('input');
		if($input->post('quantity')) $field->set('quantity', $input->post('quantity'));

The processInput method called from ProcessField as part of the save will then have the correct config fields to process the changes against.

The problem with the display of AsmSelect fields remains however - as you can see from the image below:

672094549_MeasurementconfigAsmSelect.jpg.efb817c64e477312d9ee35802c9783d3.jpg

It is displaying like a multi select, not an AsmSelect. After saving, it displays correctly.

Edited by MarkE
Previous comment was daft
Link to comment
Share on other sites

All sorted now, I hope. I needed to insert the AsmSelect js (and also that for Toggle, at least on creation of new fields). Not exctly sure why, and I'm sure there is a better solution. Anyway, here is my code if it helps anyone else. (BTW I still need to get it working in modals 😐)

1. In the module ___getConfigInputfields() method:

public function ___getConfigInputfields(Field $field): InputfieldWrapper {
		/* @var $field \ProcessWire\FieldtypeMeasurement */
		$inputfields = parent::___getConfigInputfields($field);
		$input = $this->wire('input');
		if($input->post('quantity')) $field->set('quantity', $input->post('quantity'));
		$f = $this->modules->get("InputfieldSelect");
		$quantities = Measurement::getQuantities();
		$f->label = __("Quantity");
		$f_name = 'quantity';
		$f->name = $f_name;
		$f->columnWidth = 50;
		$f->description = __("Type of physical quantity to be measured.");
		$f->notes = __("Save after changing to see the options for units applicable to this quantity.");
		foreach($quantities as $quantity) {
			$f->addOption($quantity);
		}
		$f->value = $field->get('quantity');

		#### add HTMX to change the units selection ####
		$adminEditURL = $this->wire('config')->urls->admin . "setup/field/edit/";
		$adminEdit = "{$adminEditURL}?id={$field->id}&refresh=1";
		$f->attr([
			'hx-get' => $adminEdit, // caught by HTMX AJAX LISTENER in hook
			'hx-target' => "#Inputfield_unit_set", // our element to target with swap
			'hx-swap' => 'innerHTML', //
			'hx-trigger' => 'change', //
			'hx-include' => "[name=quantity]",  // the new quantity
		]);
		########## end of hx- attributes ########################

		$f->addClass('no-selectize');
		$inputfields->append($f);

		$f = $this->modules->get('InputfieldCheckbox');
		$f->attr('name', 'hide_quantity');
		$f->label = __('Hide quantity display in the input field.');
		$f->attr('checked', $field->hide_quantity ? 'checked' : '');
		$f->columnWidth = 50;
		$inputfields->append($f);

		$quantity = $field->get('quantity');
		$fs = $this->unitSet($quantity, $field);
		$inputfields->append($fs);

		$f = $this->modules->get('InputfieldCheckbox');
		$f->attr('name', 'show_remark');
		$f->label = __('Show remark');
		$f->notes = __("Show a remark entry box (default front-end rendering is as a tooltip).");
		$f->attr('checked', $field->show_remark ? 'checked' : '');
		$f->columnWidth = 100;
		$inputfields->append($f);

		return $inputfields;
	}

In the ready() method:

$this->wire()->addHookAfter('ProcessField::buildEditForm', function(HookEvent $event) {
			$object = $event->object;
			$field = $object->getField();
			if(!$field || !$field->id) {
				$this->session()->set('newField', true);
			}
			##################### HTMX LISTENER #############################
			$input = wire('input');
			$ajax = wire('config')->ajax;
			if($ajax && $input->get('refresh') == 1) {
				$quantity = $input->get('quantity');
				$fs = $this->unitSet($quantity, $field);
				$inputfields = parent::___getConfigInputfields($field);
				$inputfields->append($fs);
				/*
				 * In order to render AsmSelect and Toggle fields, we need to make sure the js and css is present
				 */
				// AsmSelect //
				$js1 = $this->config->urls->modules . "Inputfield/InputfieldAsmSelect/asmselect/jquery.asmselect.js";
				$out = "<script src='{$js1}'></script>";
				$js2 = $this->config->urls->modules . "Inputfield/InputfieldAsmSelect/InputfieldAsmSelect.js";
				$out .= "<script src='{$js2}'></script>";
				$css1 = $this->config->urls->modules . "Inputfield/InputfieldAsmSelect/asmselect/jquery.asmselect.css";
				$out .= "<link rel='stylesheet' href='{$css1}'/>";
				$css2 = $this->config->urls->modules . "Inputfield/InputfieldAsmSelect/InputfieldAsmSelect.css";
				$out .= "<link rel='stylesheet' href='{$css2}'/>";
				// Toggle // We only want to add this for new fields as it persists afterwards
				if($this->session()->get('newField')) {
					$js3 = $this->config->urls->modules . "Inputfield/InputfieldToggle/InputfieldToggle.js";
					$out .= "<script src='{$js3}'></script>";
					$css3 = $this->config->urls->modules . "Inputfield/InputfieldToggle/InputfieldToggle.css";
					$out .= "<link rel='stylesheet' href='{$css3}'/>";
					$this->session->remove('newField');
				}
				// Finally we can render the html to be swapped into 'unit_set'
				$out .= $inputfields->render();
				echo $out;
				die();
			}
		});

In the module.js file:

/*
We need to tell htmx that ProcessWire expects Ajax calls to send "X-Requested-With"
 */

const FieldtypeMeasurement = {
    initHTMXXRequestedWithXMLHttpRequest: function () {
        console.log("initHTMXXRequestedWithXMLHttpRequest - configRequest")
        document.body.addEventListener("htmx:configRequest", (event) => {
            event.detail.headers["X-Requested-With"] = "XMLHttpRequest"
        })
    },

    listenToHTMXRequests: function () {
        // before send - Just an example to show you where to hook BEFORE SEND htmx request
        htmx.on("htmx:beforeSend", function (event) {
            console.log("FieldtypeMeasurement - listenToHTMXRequests - event", event);
        })
    },
}


/**
 * DOM ready
 *
 */
document.addEventListener("DOMContentLoaded", function (event) {
    if (typeof htmx !== "undefined") {
        // CHECK THAT htmx is available
        console.log("HTMX!")
        // init htmx with X-Requested-With
        FieldtypeMeasurement.initHTMXXRequestedWithXMLHttpRequest()
        // just for testing
        FieldtypeMeasurement.listenToHTMXRequests()
    } else {
        console.log("NO HTMX!")
    }
})

You also need 

$this->wire()->config->scripts->append("https://unpkg.com/htmx.org@1.7.0");

in the module init() method

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...