Jump to content

MarkE

Members
  • Posts

    629
  • Joined

  • Last visited

  • Days Won

    6

Posts posted by MarkE

  1. On 11/22/2021 at 5:44 AM, BitPoet said:

    For full fledged field types, you'll often also want to implement Fieldtype::getConfigAllowContext and Inputfield::getConfigAllowContext. They return an array with the names of those config fields defined in their respective getConfigInputfields method that may be overridden in template or fieldgroup context.

    I have updated the post to reflect this and also added the following:

    Important If you are allowing your config fields to vary between templates then in wakeupValue() you must call getBlankValue() using the context not the field - e.g.

    public function ___wakeupValue(Page $page, Field $field, $value) {
    
       $context = $field->getContext($page->template);
       $measurement = $this->getBlankValue($page, $context);
    • Like 2
  2. Fieldtype modules

    Not really a tutorial, but a bunch of stuff I learnt in building a fieldtype module (FieldtypeMeasurement). That module is used as a starting point for many of the examples.

    Happy to take corrections and improvements 😃

    Basics

    For a (full-featured) module, you actually need two module files:

    • FieldtypeModuleName.module; and

    • InputfieldModuleName.module

    The Fieldtype module defines the general settings for the fieldtype (how it appears in the setup->field page), together with how it interacts with the database, while the Inputfield module defines how the field appears when editing it in a page.

    In addition, for complex fields, you can define a class to hold your field values in an object. This allows you to provide custom methods for use in the API. Otherwise you can store field values as any existing type or ProcessWire object

    The chart below summarises the interactions of these elements and the subsequent sections describe the methods in more detail.

    178210595_Fieldtypemodulechart.jpg.1a7e6db0bcf528207898344ab59baa91.jpg

    Fieldtype Module

    The important methods are described below.

    __construct()

    Generally not much is required here (apart from parent::__construct();). If you have a php script with the field object then include it (require_once() ).

    SQL database interaction

    getDatabaseSchema(Field $field)

    This is essential. It states what data will be saved to the SQL database (via the sleepValue function - see below). An example is:

    public function getDatabaseSchema(Field $field) {
    
        $schema = parent::getDatabaseSchema($field);
    
        $schema['data'] = 'double NOT NULL';  // value in base units
        $schema['magnitude'] = 'varchar(64)'; // value in current units - needs to be text to store composite values
        $schema['unit'] = 'text NOT NULL';
        $schema['quantity'] = 'text NOT NULL';
    
    
       return $schema;
    }

    'data' is required and is a primary key field which means that 'text' cannot be used, although varchar(64) is OK. Often (as here) it would be a numeric field of some type. Other items can be defined as required.

    ___sleepValue(Page $page, Field $field, $value)

    This determines how the ProcesssWire field object is mapped to the SQL schema. You need to return an array where the keys are the elements defined in the schema, e.g.:

        $sleepValue = array(
            'data' => $data,
            'magnitude' => $magnitude,
            'unit' => $value->unit,
            'quantity' => $value->quantity
        );
        return $sleepValue;

    ___wakeupValue(Page $page, Field $field, $value)

    This is basically the inverse of sleepValue - mapping the array from the database into the field object. In the example below, the field object (Measurement) extends WireData. Properties in WireData objects can be stored in the 'data' property via 'get' and 'set' methods. getBlankValue() (see next section) performs the initial setting of these - config values for the field (see below) can be set, but otherwise the settings are just placeholders. Properties set to a WireData object can also be set or accessed directly, like $item->property or using array access like $item[$property]

    public function ___wakeupValue(Page $page, Field $field, $value) {
            // if for some reason we already get a valid value, then just return it
            if($value instanceof Measurement) return $value;
            // start a blank value to be populated
            $measurement = $this->getBlankValue($page, $field);
            // if we were given a blank value, then we've got nothing to do: just return a blank Measurement object
            if(empty($value) || !is_array($value)) return $measurement;
            // create new Measurement object
            $measurement->quantity = $value['quantity'];
            // ... custom processing ...
            $measurement->baseMagnitude = $value['data'];
            if($value['unit']) {
                $measurement->unit = $value['unit'];
                $units = $measurement->getUnits();
                if(array_key_exists($value['unit'], $units) && isset($value['magnitude'])) {
                    $measurement->magnitude = explode('|', $value['magnitude']);
                } else {
                    $measurement->magnitude = $measurement->baseMagnitude;
                    $measurement->unit = $measurement->units->base;
                    $this->error('... error msg ...');
                }
            } else {
                //... 
            }
            if(!is_array($measurement->magnitude)) $measurement->magnitude = [$measurement->magnitude];
            return $measurement;
        }

    getBlankValue(Page $page, Field $field)

    This should return an empty item of the appropriate type. For instance, if your field object is an array, just return array(); If the field type is an object then you will need to return a 'new ObjectClassName()'. Pre-fill any config values from the Fieldtype settings but leave blank those which are set in the Inputfield,

    In the above example, the field object data was set as follows:

        public function getBlankValue(Page $page, Field $field) {
            /* @var $field FieldtypeMeasurement */
            $measurement = new Measurement($field->quantity, null, []);
            if ($field->quantity) $measurement->set('quantity', $field->quantity);
            $measurement->set('magnitude', []);
            $measurement->set('shortLabel', null);
            $measurement->set('plural', null);
            return $measurement;
        }
    

    If your object has configurable fields that can be modified according to context (as defined in getConfigAllowContext() - see below), then you will need to deal with this in getBlankValue too, e.g. :

    public function getBlankValue(Page $page, Field $field): Measurement {
        //NB Field details may differ between templates so we need to get the field in context
        $context = ($page && $page->id) ? $field->getContext($page->template) : $field;
    ...
        $measurement = new Measurement($context->quantity, null, []);
    ...
        return $measurement;
    }

    But note that this does not completely deal with the situation where the field is in repeater matrix items where the types might have different contexts - there you might need:

    public function getBlankValue(Page $page, Field $field): Measurement {
        //NB Field details may differ between templates so we need to get the field in context
        $context = ($page && $page->id) ? $field->getContext($page->template) : $field;
    ...
        // if($page instanceof RepeaterMatrixPage) { // This does not always work - see edit note
        if($page->template->pageClass == 'RepeaterMatrixPage') {
            if($page->getField($field->name)) {
                $context = $page->fieldgroup->getFieldContext($field, "matrix$page->repeater_matrix_type");
            }
        }
    ...
        $measurement = new Measurement($context->quantity, null, []);
    ...
        return $measurement;
    }

    See this post for more details.

    Configuration

    ___getConfigInputfields(Field $field)

    This defines how the Details tab in the field setup page will look. The best thing to do here is to find a fieldtype module that is similar to the one you want if you are uncertain. Broadly the process is:

    • define the config object - $inputfields = parent::___getConfigInputfields($field);

    • for each config item, use the appropriate input field, e.g . $f = $this->modules->get("InputfieldSelect");

    • assign the relevant attributes. $f->name = $f_name; is important as it enables the item to be subsequently referred to as $field->f_name in, for example, getBlankValue().

    • append each item ($inputfields->append($f);) and return $inputfields;

    ___getConfigAllowContext(Field $field)

    This determines if the above input fields are allowed to have unique values per Fieldgroup assignment enabling the user to configure them independently per template in the admin, rather than sharing the same setting globally. E.g.

    public function ___getConfigAllowContext(Field $field) {
       $a = array('quantity', 'units', 'hide_quantity', 'show_update');
       return array_merge(parent::___getConfigAllowContext($field), $a);
    }

    In this example the settings 'quantity', 'units', 'hide_quantity' and 'show_update' can be varied in different template contexts.

     

    Important If you are allowing your config fields to vary between templates then in wakeupValue() you must call getBlankValue() using the context not the field - e.g.

    public function ___wakeupValue(Page $page, Field $field, $value) {
    
       $context = $field->getContext($page->template);
       $measurement = $this->getBlankValue($page, $context);

    Link with the Inputfield module

    This is done with

    getInputfield(Page $page, Field $field)

    e.g.:

    public function getInputfield(Page $page, Field $field) {
       $inputfield = $this->wire('modules')->get('InputfieldMeasurement');
       $inputfield->setField($field);
       return $inputfield;
    }

    If you want to reference the current page in the inputfield, you will also need to include $inputfield->setPage($page);

    If your fieldtype is an object and you want full context flexibility including for different repeater matrix item types, then you may need to use this:

    	public function getInputfield(Page $page, Field $field): Inputfield {
    		$inputfield = $this->wire('modules')->get('InputfieldMeasurement');
    		if($page->template->pageClass == 'RepeaterMatrixPage') {
    			if($page->getField($field->name)) {
    				$field_in_context = $page->fieldgroup->getFieldContext($field, "matrix$page->repeater_matrix_type");
    				if($field_in_context) {
    					$field = $field_in_context;
    				}
    			}
    		}
    		$inputfield->setField($field);
    		$inputfield->setPage($page);
    		return $inputfield;
    	}

    Inputfield Module

    __construct()

    Generally not much is required here (apart from parent::__construct();). If you have a php script with the field object then include it (require_once() ).

    Configuration

    ___getConfigInputfields()

    This is pretty much exactly the same construction as the similar method in the Fieldtype class. The only difference is that these settings will appear in the 'input' tab of the fieldtype settings, rather than the 'details' tab.

     

    ___getConfigAllowContext(Field $field)

    This is the equivalent to the Fieldtype::getConfigAllowContext() method, but for the "Input" tab rather than the "Details" tab.

    Input and output

    The key methods for this module are to render it from the fieldtype and database and to process the user inputs.

    ___render()

    $field = $this->field will have the field config settings from the fieldtype module. $this->attr('value') will have the current values for the field. If there is no current values then, if using a field object, you will need to create a new object, e.g.:

    if($this->attr('value')) $value = $this->attr('value'); // Measurement object
    else {
       $value = new Measurement();
    }

    You can then use $field and $value to display the inputfield (which might be a fieldset) as required using the appropriate pre-existing inputfield modules. (Again, find an existing module that is similar, if you are uncertain).

     

    renderValue()

    This is required where the field is locked (not editable) and therefore render() does not apply. Get the value with $value = $this->attr('value'); and then apply the required formatting, returning the output string.

    ___processInput(WireInputData $input)

    Here you take the inputs and update the field values. As in render(), set $value = $this->attr('value') ; and then modify $value for the inputs.

    For example, set $name = $this->attr('name'); and then assign the inputs thus:

    $input_names = array(
       'magnitude' => "{$name}_magnitude",
       'unit' => "{$name}_unit",
       'quantity' => "{$name}_quantity",
       'update' => "{$name}_update"
    );

    You can then loop through the inputs and carry out the required updates. The example below is slightly convoluted but illustrates this:

            foreach($input_names as $key => $name) {
                if(isset($input->$name) && $value->$key != $input->$name) {
                    if($key == 'magnitude') {
                        $input->$name = trim($input->$name);
                        if(!is_numeric($input->$name)) {
                            $magnitude = explode('|', $input->$name);
                            $magnitude = array_filter($magnitude, 'is_numeric');
                            $value->set($key, $magnitude);
                        } else {
                            $value->set($key, [$input->$name]);
                        }
                    } else {
                        $value->set($key, $input->$name);
                    }
                    $this->trackChange('value');
                }
            }

    When all is done, return $this;

    Custom classes

    As mentioned earlier, for complex field types it may be useful to set up custom classes to hold the data. Typically a custom class would extend WireData, which is ProcessWire's class designed for runtime data storage. It provides this primarily through the built-in get() and set() methods for getting and setting named properties to WireData objects. The most common example of a WireData object is Page, the type used for all pages in ProcessWire.

    Properties set to a WireData object can also be set or accessed directly, like $item->property or using array access like $item[$property]. If you foreach() a WireData object, the default behaviour is to iterate all of the properties/values present within it. Do not declare any such properties in your class (or declare properties with the same name) otherwise you will end up with two properties, one in the 'data' array and one outside it and endless confusion will result.

    It is advisable to put any such classes in your own namespace. In that case, you will need to include 'use' statements in your script - e.g.

    use ProcessWire\{FieldtypeMeasurement, WireData};
    use function ProcessWire\{wire, __};

    and also include use statements in your module scripts, e.g.

    use MetaTunes\MeasurementClasses\Measurement;
    • Like 16
  3. Thanks for that @kongondo. It's just that I thought there may be some extra subleties because on some occasions $object->property did not work, but $object->get('property') did and I couldn't understand why. Unfortunately it is a bit difficult to build an example outside of a rather complex context (my FieldtypeMeasurement module), but I'll see if I can find a way of simply illustrating this behaviour.

  4. Having used PW for a while now, I'm still confused about when to use get and set for WireData objects and when to just use object operators.

    As I understand it, WireData objects have a 'data' property with associated 'get' and 'set' methods:

    /**
    	 * Array where get/set properties are stored
    	 *
    	 */
    	protected $data = array(); 
    
    	/**
    	 * Set a value to this object’s data
    	 * 
    	 * ~~~~~
    	 * // Set a value for a property
    	 * $item->set('foo', 'bar');
    	 * 
    	 * // Set a property value directly
    	 * $item->foo = 'bar';
    	 * 
    	 * // Set a property using array access
    	 * $item['foo'] = 'bar';
    	 * ~~~~~

    It also seems that you can access the data items directly using the object operator ->

    WireData.jpg.51ffa4f56851d4be1e048ffdaa697245.jpg

    However, I recall situations (which I can't replicate in this simple example) where the object operator does not return the data item. Am I imagining things? Is there any guidance on when to use 'get' and 'set' rather than the operator (which is much easier in the IDE)?

  5. Hi @adrian. What did you do to get an error in a template file - I can't replicate that? See above post - executes correctly with no errors - the code in the template was:

    		$data = include_once 'M:\laragon\www\BawdHall\site-admin\modules\FieldtypeMeasurement\Config\Temperature.php';
    		bd($data, 'data 1');
    		if($data === true) {
    			$data = $session->get('quantity' . '_data');
    		} else {
    			$session->set('quantity' . '_data', $data);
    		}
    		bd($data, 'data 2');
    		$data = include_once 'M:\laragon\www\BawdHall\site-admin\modules\FieldtypeMeasurement\Config\Temperature.php';
    		bd($data, 'data 1');
    		if($data === true) {
    			$data = $session->get('quantity' . '_data');
    		} else {
    			$session->set('quantity' . '_data', $data);
    		}
    		bd($data, 'data 2');

    The link you provided yielded no insights as it failed to catch the supposed error. Also, it seems like the 'error' is not caught by tracyConsoleShutdownHandler() in CodeProcessor.php, so I'm not sure how further to track it down.

     

  6. Thanks @adrian. That's curious because I'm not getting the exception in a template file. It executes normally with the correct result and no exceptions:

    1000694451_Measurementdebug.thumb.jpg.db9074a28e81eb8860ae10908fa9b0fc.jpg

    DATA1 is the result of the include_once and DATA2 is after the test - i.e. if DATA1 is == true it is the session variable. Also, even in the console panel everything executes normally - the exception message seems to be spurious. However, I will investigate the stackoverflow link and report back anything of interest.

    EDIT - should have expanded that second DATA2 to show the closure:

    885539978_Measurementdebuga.jpg.30bdc591b01507ffa9526269fb48ea49.jpg

  7. 39 minutes ago, adrian said:

    So that is the entire contents of the file at $path?

    Yes - apart from <?php namespace MetaTunes\MeasurementClasses; which is probably irrelevant to your test. The exception is not generated on the first include_once. It arises from the next bit which is to avoid $data just being == true on subsequent calls:

    		if($data === true) {
    			$data = $this->session->get($quantity . '_data');
    		} else {
    			$this->session->set($quantity . '_data', $data);
    		}

    EDIT: Of course, I don't know what actually generates the exception , so it may be in the code which executes the conversion function. All I do know is that without the above snippet, no exception is generated (and that it is not generated in any case outside the Tracy console context).

  8. Hi @adrian - It is like:

    return array(
    ///////Units Of Temperature///////
    	"dimension" => new Dimension([Dimension::TEMPERATURE => 1]),
    	'base' => 'Kelvin',
    	'units' => array(
    		"Kelvin" => array("shortLabel" => "K", "conversion" => 1, "plural" => "Kelvin"), //Kelvin - base unit for temperature
    		"Celsius" => array("shortLabel" => "degC", "conversion" => function ($val, $tofrom) {
    			return $tofrom ? $val - 273.15 : $val + 273.15;
    		}, "plural" => "Celsius"),
    		"Fahrenheit" => array("shortLabel" => "degF", "conversion" => function ($val, $tofrom) {
    			return $tofrom ? ($val * 9 / 5 - 459.67) : (($val + 459.67) * 5 / 9);
    		}, "plural" => "Fahrenheit"),
    	)
    );

     

  9. In the process of refactoring and improving my FieldtypeMeasurement module, I came across an odd behaviour in the Tracy console. My module uses config php files including measurement conversion details which are in arrays which may include anonymous and named functions.

    Previously I used $data = include $path; to get these config files, but that meant that I had to test for function existence (for named functions) in the config files. $data = include_once $path; doesn't work because include_once returns true (not the return value) on subsequent calls, so I decided on this:

    		$data = include_once $path;
    		if($data === true) {
    			$data = $this->session->get($quantity . '_data');
    		} else {
    			$this->session->set($quantity . '_data', $data);
    		}

    That does the business, but if $data has anonymous function and I run the code from the console, I get this error:

    Fatal error: Uncaught Exception: Serialization of 'Closure' is not allowed in [no active file]:0 Stack trace: #0 {main} thrown in [no active file] on line 0

    The odd thing is that the code all executes and outputs as expected - the 'fatal error' appears to be not at all fatal. Furthermore, if I run the exact same code from API calls in the main program, no exceptions are generated. I can't track down where the exception is being generated or why. I'm not particularly worried as everything seems to work OK, but it is a bit off-putting.

    Any ideas?

  10. 18 hours ago, monollonom said:

    What I like about this is how it's somehow reminiscent of front-end editing's method D. Maybe it could be something to rely on for the live preview as well ?

    I quite agree. I think that, stylistically, the front-end editing approach is a good model to follow (assuming it works) and has the added benefit of already being familiar to many developers. 

    • Like 1
  11. I've put quite a lot of work into this now. There are lots of additional units added plus a greatly extended API. This enhances the formatting capability and introduces 'dimensional analysis'. So it is now possible to (say) multiply measurements and properly interpret the results. - e.g (as a simple example) speed x time = distance

    $speed = new Measurement('Speed', 'furlong per fortnight', 20);
    $time = new Measurement('Time', 'hour', 3);
    $length = $speed->multiplyBy($time, 'Length', 'yard');
    d($length->render(['decimals' =>2]));

    Result is '39.29yd'

    Omitting the 'Length', 'yard' specification means that the result is returned in base units for the first compatible quantity for the computed dimension:

    $length = $speed->multiplyBy($time);
    d($length->render(['decimals' =>2]));

    Result is '35.92m'

    See https://github.com/MetaTunes/FieldtypeMeasurement for full details.

    The module is still alpha, so use in production systems is not advised. However, the functionality is now pretty complete but I have a bit of tidying up to do - and some more extensive testing in a new app. New units will probably also be added (but users can add their own anyway).

    Try it out and let me know of any issues or suggestions 🙂 

    • Like 1
  12. Not sure if I'm being silly, but it seems to me that the core Field::exportData (3.0.184) has a bug in the following two lines

    $typeData = $this->type->exportConfigData($this, $data);
    $data = array_merge($data, $typeData);

    This code seems to cause the $typedata from the default fieldtype config settings to over-ride the actual field data. I have experienced a number of errors that appear to be caused by this. 
    Shouldn't the 2nd line be this?

    $data = array_merge($typeData, $data);

    Per the PHP manual, the second array will overwrite the first if the keys match.
    I have written a hook (before Fieldtype::exportConfigData) to fix this by merging the data the 'correct' way before returning it to Field::exportData. Effectively it performs the merge the other way around and returns the already merged array (see code at end). However, I'm concerned that this may have side effects where child classes call parent::___exportConfigData($field, $data). Assuming it is a bug, would I be better just hacking the core until it is fixed. I haven't raised a core issue before, so am a bit nervous about doing so (and would need some time to build a simple case to demonstrate the issue).

    Code for hook is:

    	public function afterExportConfigData(HookEvent $event) {
    		$data = $event->arguments(0)->data;
    		$value = $event->return;
    		$event->return = array_merge($value, $data);
    	}

     

  13. This is a simple question (I think) so I'm hoping there is a simple answer...

    My new Fieldtype module 

    has an inputfield which is a fieldset comprising (up to) 4 inputfields. In the admin UI, each field has a caret to expand/collapse.  However, clicking any one of them causes the same behaviour in all of them. I am enhancing this to add extra notes to one of the fields (quantity) that the user may wish to collapse, but collapsing that collapses all the input fields. I can set the initial collapse states differently, but after the first click, they all sync. Any ideas how to avoid this?

  14. I have a selector:

    $pageArray = $pages->find("template=basic-page, length.magnitude>0, include=all");

    I would like an array of each of the 'length' fields. However, the field is an object and $pageArray->each('length') returns the formatted values; I want the unformatted objects.

    I thought perhaps I could use a callable like:

    $pageArray = $pages->find("template=basic-page, length.magnitude>0, include=all");
    $lengths = $pageArray->each(function($p) {
        $length = $p->getUnformatted('length');
        return $length;
    });

    but it seems that only strings can be returned and those are concatenated.

    So it looks like I have to use a foreach() loop - which works, but I was expecting some neater shorthand. Any thoughts?

  15. 19 minutes ago, kongondo said:

    You can decide on a base unit that will be used to store all measurement values. For instance, centimetres. In the UI

    This was my original approach, which I changed for a reason I can’t recall 🤔. I’ll reconsider. 
     

    Edit: I think the reason was that the base unit definition might change. However, now I’ve got more mileage under my belt with this, I might be able to solve that problem. 

  16. 20 minutes ago, MarkE said:

    I combined the magnitudes as pipe-separated in order to store them as a string in the DB. I am having second thoughts about this. In fact I am wondering about using arrays and then storing a json in the DB.

    Clarification: The above is a slight red herring regarding the isue raised as the multi-values are actually stored in the object as an array. The pipe format is just for the UI and the SQL DB.

×
×
  • Create New...