Jump to content

How to create your very own custom Fieldtypes in ProcessWire


bernhard

Recommended Posts

Building your very own custom Fieldtypes in ProcessWire seems to be hard, but it actually is not, once you get the concept. In this post I'll share a simple and as minimalistic module as it can get that everybody can use as a boilerplate for his own Fieldtypes.

  1. A Fieldtype that does not store any information in the database
  2. A Fieldtype that stores information in the database (to be done)
  3. Make your Fieldtype configurable

 

1. A Fieldtype that does not store any information in the database

Some of you might know the excellent RuntimeMarkup module by @kongondo. I've used it a lot in the past, but I get more and more into developing custom fieldtypes rather than hacking the runtime module to my needs. That has several advantages, but it has also one major drawback: It is always a pain to setup a new Fieldtype, because there are lots of methods (see the base class /wire/core/Fieldtype.php) and you have to know which methods you need, which ones you have to remove and adopt etc.; Not any more!

Ryan has developed a showcase module (the Events Fieldtype) to learn from, but when I first peaked into it, it felt quite complex and I thought it would be a lot of effort to learn Fieldtype development). There are also some easy and small Fieldtypes to learn from, like the FieldtypeCheckbox. But all of them require a good understanding of what is going on, you need to modify existing config inputfields that might have been added to that fieldtype and afterall it's not as easy as it could be.

With my new approach I plan to use a boilerplate fieldtype to start from (in OOP terms to "extend" from) and only change the parts i need... More on that later.

Here is the boilerplate module with some debugging info to illustrate the internal flow on the tracy console:

Spoiler

<?php namespace ProcessWire;
/**
 * Simple Fieldtype Boilerplate that does not store any data in the database
 *
 * @author Bernhard Baumrock, 03.10.2018
 * @license Licensed under MIT
 * @link https://www.baumrock.com
 */
class FieldtypeMarkup extends Fieldtype {

  public static function getModuleInfo() {
    return [
      'title' => 'Markup',
      'version' => '0.0.1',
      'summary' => 'Most simple Fieldtype to only display an InputfieldMarkup in the page editor',
      'icon' => 'code',
    ];
  }

  /**
   * link the core inputfieldmarkup to this fieldtype
   */
  public function getInputfield(Page $page, Field $field) {
    return $this->wire->modules('InputfieldMarkup');
  }

  /**
   * very first retrieval of the value
   */
  public function ___loadPageField(Page $page, Field $field) {
    d('value set manually to 1', '___loadPageField');
    return 1;
  }
  
  /**
   * value was loaded, now we can convert it
   * eg we can convert an integer page id to a page object
   */
  public function ___wakeupValue(Page $page, Field $field, $value) {
    $value = $this->wire->pages->get($value);
    d($value, '___wakeupValue');
    return $value;
  }

  /**
   * convert the wakeupValue to the given format
   * eg: convert a page object to a string
   */
  public function ___formatValue(Page $page, Field $field, $value) {
    $value = "this is page {$value->id}";
    d($value, '___formatValue');
    return $value;
  }

  /**
   * sanitize the value whenever it is stored or pulled
   */
  public function sanitizeValue(Page $page, Field $field, $value) {
    d($value, 'sanitizeValue');
    return $value;
  }

  /**
   * The following functions are defined as replacements to keep this fieldtype out of the DB
   */
  public function ___sleepValue(Page $page, Field $field, $value) { return $value; }
  public function getLoadQuery(Field $field, DatabaseQuerySelect $query) { return $query; }
  public function ___savePageField(Page $page, Field $field) { return true; }
  public function ___deletePageField(Page $page, Field $field) { return true; }
  public function ___deleteField(Field $field) { return true; }
  public function getDatabaseSchema(Field $field) { return array(); }
  public function ___createField(Field $field) { return true; }
  public function ___getCompatibleFieldtypes(Field $field) { return new Fieldtypes(); }
  public function getLoadQueryAutojoin(Field $field, DatabaseQuerySelect $query) { return null; }
  public function getMatchQuery($query, $table, $subfield, $operator, $value) {
    throw new WireException("Field '{$query->field->name}' is runtime and not queryable");
  }

}

 

See the module in action in the tracy console ( @adrian has done such an amazing job with that module!!!):

GVIBut4.png

The code should be self-explaining. What is interesting, though, is that after the ### getUnformatted ### dump there is only one call to "___formatValue". All the other calls (loadPageField, wakeupValue and sanitizeValue) are not executed because the value is already stored in memory. Only the formatting part was not done at that point.

With that concept it is very easy to create your very own custom Fieldtypes:

<?php namespace ProcessWire;
/**
 * Demo Fieldtype Extending the Boilerplate Runtime Fieldtype
 * 
 * @author Bernhard Baumrock, 03.10.2018
 * @license Licensed under MIT
 * @link https://www.baumrock.com
 */
class FieldtypeDemo extends FieldtypeMarkup {

  public static function getModuleInfo() {
    return [
      'title' => 'Demo',
      'version' => '0.0.1',
      'summary' => 'Demo Fieldtype',
      'icon' => 'code',
    ];
  }

  /**
   * convert the wakeupValue to the given format
   * eg: convert a page object to a string
   */
  public function ___formatValue(Page $page, Field $field, $value) {
    $value = parent::___formatValue($page, $field, $value) . " - but even better!";
    d($value, '___formatValue --> convert to whatever format you need');
    return $value;
  }

}

BIMp0nn.png

Notice the change "but even better" in the last two dumps. I think it can't get any easier, can it?! ? I'll continue testing and improving this module, so any comments are welcome!

 

2. A Fieldtype that stores information in the database

See this module for an easy example of how to extend FieldtypeText:

 

  • Like 19
Link to comment
Share on other sites

5 hours ago, bernhard said:

1. A Fieldtype that does not store any information in the database

@bernhard, you can actually remove a few more methods from such a fieldtype - FieldtypeFieldsetOpen is a good one to refer to for a fieldtype that doesn't store anything in the database.

A while ago I made a simple runtime-only fieldtype that generates inputfield markup from PHP files in a subfolder within /site/templates/ - sort of like a stripped-back version of RuntimeMarkup without any config fields in admin. It was just intended for use in my own projects and I didn't think it was worth announcing publicly seeing as we already have kongondo's module, but I've moved it to a public repo in case it is interesting for you to take a look at: https://github.com/Toutouwai/FieldtypeRuntimeOnly

  • Like 4
  • Thanks 1
Link to comment
Share on other sites

Thx @Robin S, I had a look to those fieldtypes and you where partially right. I removed sleepValue and deletePageField since they should never get called on a non-db fieldtype. But your runtimeonly field does actually have too few methods if you want to keep it completely out of the db. Your fieldtype creates an empty db table. Not sure if that is intended?

 

I've invested some more time and really like this approach of building new Fieldtypes! Is really simple, see this example of a new Fieldtype called "FieldtypeNow":

I renamed the base fieldtype to "BaseFieldtypeRuntime" and it really does not do anything other than providing the boilerplate. It does not even show up in the list when you create a new field in your pw installation (screenshot later).

This is the current code:

Spoiler

<?php namespace ProcessWire;
/**
 * Simple Fieldtype boilerplate that does not store any data in the database
 *
 * @author Bernhard Baumrock, 03.10.2018
 * @license Licensed under MIT
 * @link https://www.baumrock.com
 */


class BaseFieldtypeRuntime extends Fieldtype {

  protected $page;
  protected $field;

  public static function getModuleInfo() {
    return [
      'title' => 'BaseFieldtypeRuntime',
      'version' => '1.0.0',
      'summary' => 'Simple Fieldtype boilerplate that does not store any data in the database',
      'icon' => 'code',
    ];
  }

  /**
   * link the core InputfieldMarkup to this fieldtype
   */
  public function getInputfield(Page $page, Field $field) {
    $this->page = $page;
    $this->field = $field;

    /** @var InputfieldMarkup $inputfield */
    $inputfield = $this->wire->modules('InputfieldMarkup');
    $field->label = $field->label ?: $field->name;

    // replace the inputfield's render method with our own
    $inputfield->addHookBefore('render', function(HookEvent $event) {
      $event->replace = true;
      $event->return = $this->render();
    });

    // call the renderReady method when the inputfield is available
    $inputfield->addHookAfter('renderReadyHook', function(HookEvent $event) {
      $this->renderReady();
    });

    // modify config screen to disable AJAX options
    $inputfield->addHookAfter('getConfigInputfields', function($event) {
      $inputfields = $event->return;
      $options = [
        Inputfield::collapsedNo,
        Inputfield::collapsedYes,
        Inputfield::collapsedNever,
      ];
      $collapsed = $inputfields->get('collapsed');
      foreach(array_keys($collapsed->getOptions()) as $option) {
        if(in_array($option, $options)) continue;
        $collapsed->removeOption($option);
      }
    });

    return $inputfield;
  }

  /**
   * methods for providing the field markup and the renderReady actions
   * in this case nothing happens at all, but you can extend this fieldtype
   * and do whatever you need by overriding these two methods
   */
  public function render() { return ''; }
  public function renderReady() { return; }

  /**
   * The following functions are defined as replacements to keep this fieldtype out of the DB
   */
  public function sanitizeValue(Page $page, Field $field, $value) { return; }
  public function loadPageField(Page $page, Field $field) { return; }
  public function getLoadQuery(Field $field, DatabaseQuerySelect $query) { return $query; }
  public function savePageField(Page $page, Field $field) { return true; }
  public function deleteField(Field $field) { return true; }
  public function getDatabaseSchema(Field $field) { return array(); }
  public function createField(Field $field) { return true; }
  public function getCompatibleFieldtypes(Field $field) { return $this->wire(new Fieldtypes()); }
  public function getLoadQueryAutojoin(Field $field, DatabaseQuerySelect $query) { return null; }
  public function getMatchQuery($query, $table, $subfield, $operator, $value) {
    throw new WireException("Field '{$query->field->name}' is runtime and not queryable");
  }

}

 

Actually it does only define the inputfield and add some hooks to replace the render and renderReady methods by our own implementations and define all the functions necessary to keep the db out of the game:

1cMOt10.png

Simple, right? ? 

This is how the installation screen looks like:

ZoW7fJp.png

The BaseFieldtype is set as dependency, so FieldtypeNow can only be installed when the Base Fieldtype is available. Once installed, you can easily create a new field of that type:

sZHvdvK.png

Notice that there is no Fieldtype "BaseFieldtypeRuntime" in this list as I mentioned above. You can then add your field to a template and edit any page of that template:

<?php namespace ProcessWire;
/**
 * Demo Fieldtype Extending the Boilerplate Runtime Fieldtype
 * 
 * @author Bernhard Baumrock, 03.10.2018
 * @license Licensed under MIT
 * @link https://www.baumrock.com
 */
class FieldtypeNow extends BaseFieldtypeRuntime {

  public static function getModuleInfo() {
    return [
      'title' => 'FieldtypeNow',
      'version' => '0.0.1',
      'summary' => 'Fieldtype showing the current time',
      'icon' => 'code',
      'requires' => ['BaseFieldtypeRuntime'],
    ];
  }

  public function render() {
    return time();
  }

}

Hk3fo71.png

Another Fieldtype rendering the content of a php file named like the field (very similar to the existing modules by @kongondo RuntimeMarkup, @Robin S RuntimeOnly and @kixe FieldtypeMarkup). You actually only have to implement the render() method, and if you need you can load scripts in the renderReady() method...

Spoiler

<?php namespace ProcessWire;
/**
 * Demo Fieldtype Extending the Boilerplate Runtime Fieldtype
 * 
 * @author Bernhard Baumrock, 03.10.2018
 * @license Licensed under MIT
 * @link https://www.baumrock.com
 */
class FieldtypeRenderFile extends BaseFieldtypeRuntime {

  public static function getModuleInfo() {
    return [
      'title' => 'Render File',
      'version' => '0.0.1',
      'summary' => 'Fieldtype rendering a php file placed in /site/templates/'. substr(strrchr(__CLASS__, "\\"), 1),
      'icon' => 'code',
      'requires' => ['BaseFieldtypeRuntime'],
    ];
  }

  /**
   * render content of included file
   */
  public function render() {
    $filename = "{$this->field}.{$this->page->template}.php";
    if(file_exists($this->path($filename))) return $this->renderFile($filename);

    $filename = "{$this->field}.php";
    if(file_exists($this->path($filename))) return $this->renderFile($filename);
    
    return;
  }

  /**
   * add scripts and styles named like this field
   */
  public function renderReady() {
    $this->loadAssets();
  }

  // ########## helper functions ##########

  /**
   * render file and pass variables
   */
  private function renderFile($filename) {
    return $this->wire->files->render($this->path($filename), [
      'page' => $this->page,
      'field' => $this->field,
      'inputfield' => $this,
    ]);
  }

  /**
   * add asset to scripts / styles
   */
  private function loadAssets() {
    
    $filename = "{$this->field}.{$this->page->template}.js";
    if($this->exists($filename)) $this->loadScript($filename);
    else {
      $filename = "{$this->field}.js";
      if($this->exists($filename)) $this->loadScript($filename);
    }
    
    $filename = "{$this->field}.{$this->page->template}.css";
    if($this->exists($filename)) $this->loadStyle($filename);
    else {
      $filename = "{$this->field}.css";
      if($this->exists($filename)) $this->loadStyle($filename);
    }
  }

  /**
   * load script
   */
  private function loadScript($filename) {
    $this->wire->config->scripts->add($this->url($filename));
  }

  /**
   * load style
   */
  private function loadStyle($filename) {
    $this->wire->config->styles->add($this->url($filename));
  }

  /**
   * does this file exist?
   */
  private function exists($filename) {
    return is_file($this->path($filename));
  }

  /**
   * return the assets path for php files
   */
  private function path($filename) {
    return $this->wire->config->paths->templates . $this->className . '/' . $filename;
  }
  
  /**
   * return the assets url for css and js files
   */
  private function url($filename) {
    return $this->wire->config->urls->templates . $this->className . '/' . $filename;
  }

}

 

0xa4IFR.png

This fieldtype loads files that are named like this:

  • site/templates/FieldtypeRenderFile/{fieldname}.{templatename}.[php/css/js]
  • site/templates/FieldtypeRenderFile/{fieldname}.[php/css/js]
  • Like 8
Link to comment
Share on other sites

5 hours ago, bernhard said:

But your runtimeonly field does actually have too few methods if you want to keep it completely out of the db. Your fieldtype creates an empty db table. Not sure if that is intended?

Interesting. It's based on FieldtypeFieldsetOpen, which likewise creates a database table (as does FieldtypeFieldsetClose). Not sure why these fieldtypes do that considering they don't store data, but it doesn't do any harm that I can see.

  • Like 1
Link to comment
Share on other sites

  • 4 months later...

Hm... Just got reminded about this post. Actually I think it's not the best idea to have a render() and renderReady() method in a Fieldtype module. That are methods that belong to an Inputfield module and I think it's not good to mix them up...

I'd be more than happy though to have a good and extensive documentation about developing fieldtypes for ProcessWire...

  • Like 2
Link to comment
Share on other sites

  • 4 months later...
30 minutes ago, gunter said:

Which kind of magic is used so that the BaseFieldtypeRuntime does not appear in the fieldtype list?
I want extend my own base fieldtype, thats why I want to know this.

To be honest: I don't know. But I'd be happt to know it if some knows it or finds out ? 

Link to comment
Share on other sites

17 minutes ago, bernhard said:

To be honest: I don't know. But I'd be happt to know it if some knows it or finds out ? 

Both the list on the Modules page and the fieldtype select on field edit screen expect fieldtype module names to start with "Fieldtype". BaseFieldtypeRuntime starts with "Base", so it's grouped under "Base" on the Modules page, and also not included in the fieldtypes (wire)array managed by the core (see /wire/core/Fieldtypes.php).

  • Like 2
  • Thanks 1
Link to comment
Share on other sites

  • 3 months later...

Memo to myself and mybe to safe someone else from losing hours trying to understand why a multi-language Inputfield does not render() as expected...

Learnings:

1) Inputfield::render() is only hookable for single-language Inputfields! When PW renders a multi-language Inputfield the LanguageSupport module adds a hook that fires after the original Inputfield::render(). This hook calls the render() method for each language and to avoid circular references it does that directly on $inputfield->___render() and not $inputfield->render(): https://github.com/processwire/processwire/blob/51629cdd5f381d3881133baf83e1bd2d9306f867/wire/modules/LanguageSupport/LanguageSupport.module#L445-L453

2) When building a custom Inputfield that supports a multi-language setup it is critical that its render() method is defined with 3 underscores! Otherwise the LanguageSupport hook that adds the markup for the other languages' fields would not fire.

  • Like 8
Link to comment
Share on other sites

  • 1 year later...

maybe a stupid question, I'm far from being an expert, but what's so exciting about avoiding the db in the first place? Aren't inputfields and inputfieldtypes not supposed to do nothing else than just that: insert data to the db? In other words, if you don't insert/input data in the db, why even call it inputfield?

I'm trying to create an actual inputfield (that inserts data in the db) that I would use within a module I'm building. No sure if I would handle this separately or all in one module. I'm trying to start with FieldTypeEvents but it's indeed quite complex. I never know if what ever bit of code I'm looking at concerns the admin interface or the frontend. Also don't know why there are two files: FieldTypeEvents.module and InputfieldEvents.module. That goes for other Fieldtypes as well, I guess all of them.

Link to comment
Share on other sites

You can think of it like this:

  • Fieldtype = responsible for storing/sanitizing data
  • Inputfield = responsible for UI/markup

That's why it is very simple to create an inputfield. A very basic inputfield could just output "hello world" without any connection to the DB or other business logic. It's not exciting to avoid the DB, but if its not necessary because you use the Inputfield just for presentation then you can simply save time/effort that is not needed ? On the other hand using Inputfields instead of plain HTML has the benefit that you get a UI component that every PW user is familiar with and that you can control like any other Inputfield (setting columnWidth, toggling, label, icon, ...). See https://processwire.com/blog/posts/building-custom-admin-pages-with-process-modules/#adding-your-first-field-inputfieldmarkup

The name INPUTfield can be a little confusing at the beginning, but after time it made a lot of sense for me and I could not think of a better wording now.

  • Like 4
Link to comment
Share on other sites

thanks @bernhard, it helps

I'm still wondering though, as I want to create a custom fieldtype inside a custom module, should one module do both? or should one module require/install the other, i.e. two separate .module.php files or one .module.php file? Also, if I use two .module.php files, could both files be in the same module folder or separate? In my understanding, two folders would mean two modules, but I might be wrong.

thanks for advice!

 

Link to comment
Share on other sites

/site/modules/MyModule/MyModule.module.php
/site/modules/MyModule/FieldtypeMyModule.module.php
/site/modules/MyModule/InputfieldMyModule.module.php

  Then add FieldtypeMyModule and InputfieldMyModule to the "installs" array of MyModule.module.php and MyModule to "required" array of the fieldtype and inputfield.

  • Like 2
Link to comment
Share on other sites

On 10/3/2018 at 5:55 PM, bernhard said:

Building your very own custom Fieldtypes in ProcessWire seems to be hard, but it actually is not

I disagree, it is actually that hard : D

All I'm trying to do is re-use the FieldTypeEvents/InputfieldEvents to build a sort of "week" inputfield, each table cell reprenting a individual checkboxes. So instead of 3 columns, it would have 7 (1 for each weekday) + 1 on the left. So I amended where those subfields are defined accordingly. However, now I get 

Call to a member function hasPermission() on null in wire/core/Modules.php:1389

Not sure if that tells anyone anything. Then I need to circle back to my db backup.

Also, maybe a bit confusing in this particular case: Is $event always arbitrary, I mean coicidentally the name of the variable? Because you also have $event when using hooks which you can't really change, so need to be clear on this if this is prone to being confused.

Link to comment
Share on other sites

Hi @fruid. I’m not going to get into this fully right now, just some suggestions.

Most importantly: What you really want to do is just buy FieldtypeTable. It’s dope, trust me.

9 hours ago, fruid said:

Call to a member function hasPermission() on null in wire/core/Modules.php:1389

Secondly, do yourself a favor and install Tracy. I have no idea how that error came about, but the stack trace will help you figure it out. If you can’t get it to work, I suggest starting a separate thread with specific questions and code snippets and Tracy traces. Your project is kind of out of scope for Bernhard’s tutorial here ?

9 hours ago, fruid said:

I disagree, it is actually that hard : D

Well, you’re trying to make a Multi-Field (extend FieldtypeMulti), which is a little more of an endeavor than a single value field.

Lastly, you can name a hook’s $event argument anything you want, if that is what you mean. $event is kind of a convention, but you might as well use $e or $hook or $args or whatever. You can even just leave it out if you don’t need it. Hooks work with callback functions. That is to say, it’s your own function. You name the function itself and its arguments.

  • Like 3
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
  • Recently Browsing   0 members

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