Custom input with properties like repeater input

I’m currently trying to create a custom input, that can take any amount of properties like the items of a repeater while creating the contentblocks field.

I could make the input type accept any amount of properties by using the fieldgroup xtype, but when I try to display them in the resource form, they don’t show up.

This is my Javascript so far - mostly copy and paste from the tutorial and the repeater.

// Wrap your stuff in this module pattern for dependency injection
(function ($, ContentBlocks) {
// Add your custom input to the fieldTypes object as a function
// The dom variable contains the injected field (from the template)
// and the data variable contains field information, properties etc.
ContentBlocks.fieldTypes.custominput = function(dom, data) {

    var wrapper = dom.find('.contentblocks-custominput-wrapper'),
        emptyRowTmpl = tmpl('contentblocks-repeater-item');

    var input = {
        // Some optional variables can be defined here
    };
    
    // Do something when the input is being loaded
    input.init = function() {

        console.log('init', dom, data);

        dom.on('click', '.contentblocks-custominput-expanded', function() {
            $(this).removeClass('contentblocks-custominput-expanded').addClass('contentblocks-custominput-collapsed').text('+').closest('.contentblocks-field-custominput').children('.contentblocks-custominput-wrapper').slideUp(300, function() {
                ContentBlocks.fixColumnHeights();
            });
        }).on('click', '.contentblocks-custominput-collapsed', function() {
            $(this).removeClass('contentblocks-custominput-collapsed').addClass('contentblocks-custominput-expanded').text('-').closest('.contentblocks-field-custominput').children('.contentblocks-custominput-wrapper').slideDown(300, function() {
                ContentBlocks.fixColumnHeights();
            });
        });

        var newRow = $(emptyRowTmpl({}));

        // Loop over each of the subfields to generate them individually, added tp the wrapper.
        $.each(data.subfields, function(idx, fld) {
            // First make sure we combine whatever values we have available
            var values = $.extend(true, {}, fld);

            // Call the ContentBlocks.addField API to create the subfield and have it injected into the canvas
            var generatedField = ContentBlocks.addField(newRow.children('div'), fld.id, values, 'bottom');

            // Add a class for the width
            if (values.parent_properties.width > 0) {
                generatedField.css('width', values.parent_properties.width  + '%');
            }

            // Keep track of the repeater-key value so we can link it back together later
            generatedField.data('repeater-key', values.parent_properties.key);

        });

        // Ensure the various columns are kept nicely aligned
        ContentBlocks.fixColumnHeights();
    };
    
    // Get the data from this input, it has to be a simple object.
    input.getData = function() {
        // return {
        //     value: dom.find('input').val()
        // }
    };
    
    // Always return the input variable.
    return input;
}
})(vcJquery, ContentBlocks);

This is my template:

<div class="contentblocks-field-actions"></div>

<label><a class="contentblocks-collapser contentblocks-custominput-collapser contentblocks-custominput-expanded" href="javascript:void(0)">-</a> {%=o.name%}</label>

<ul class="contentblocks-custominput-wrapper">
	<h1>test</h1>
</ul> 

</div>

Thanks already :slight_smile:

There is a lot more code in the Repeater input type than what you’ve shown… most notably you’re missing something that inserts the subfields into the dom, something like wrapper.append(generatedField)

Ah, now I got it. I already wondered how the actual field is added to the wrapper. But it looks like, that I have to add the empty row to the wrapper and the field is generated and added via the ContentBlocks.addField() Method like originaly thought, but without the row been added to the wrapper there is no place to put it.

Can’t make my self clearer on that at the moment, since I still havent found everything, but here is the code, which should make sense :wink:

// Wrap your stuff in this module pattern for dependency injection
(function ($, ContentBlocks) {
// Add your custom input to the fieldTypes object as a function
// The dom variable contains the injected field (from the template)
// and the data variable contains field information, properties etc.
ContentBlocks.fieldTypes.custominput = function(dom, data) {

    var wrapper = dom.find('.contentblocks-custominput-wrapper'),
        emptyRowTmpl = tmpl('contentblocks-repeater-item');

    var input = {
        // Some optional variables can be defined here
    };
    
    // Do something when the input is being loaded
    input.init = function() {

        console.log('init', dom, data);

        dom.on('click', '.contentblocks-custominput-expanded', function() {
            $(this).removeClass('contentblocks-custominput-expanded').addClass('contentblocks-custominput-collapsed').text('+').closest('.contentblocks-field-custominput').children('.contentblocks-custominput-wrapper').slideUp(300, function() {
                ContentBlocks.fixColumnHeights();
            });
        }).on('click', '.contentblocks-custominput-collapsed', function() {
            $(this).removeClass('contentblocks-custominput-collapsed').addClass('contentblocks-custominput-expanded').text('-').closest('.contentblocks-field-custominput').children('.contentblocks-custominput-wrapper').slideDown(300, function() {
                ContentBlocks.fixColumnHeights();
            });
        });

        var newRow = $(emptyRowTmpl({}));

        // append the empty to the wrapper
        wrapper.append(newRow);

        // Loop over each of the subfields to generate them individually, added tp the wrapper.
        $.each(data.subfields, function(idx, fld) {
            // First make sure we combine whatever values we have available
            var values = $.extend(true, {}, fld);

            // Call the ContentBlocks.addField API to create the subfield and have it injected into the canvas
            var generatedField = ContentBlocks.addField(newRow.children('ul'), fld.id, values, 'bottom');

            // Add a class for the width
            if (values.parent_properties.width > 0) {
                generatedField.css('width', values.parent_properties.width  + '%');
            }

            // Keep track of the repeater-key value so we can link it back together later
            generatedField.data('custominput-key', values.parent_properties.key);

        });

        // Ensure the various columns are kept nicely aligned
        ContentBlocks.fixColumnHeights();
    };
    
    // Get the data from this input, it has to be a simple object.
    input.getData = function() {
        // return {
        //     value: dom.find('input').val()
        // }
    };
    
    // Always return the input variable.
    return input;
}
})(vcJquery, ContentBlocks);

So I made it happen, this is my first beta of a fully customizable input type, which is mostly a copy and past aproach from the repeater, but it works :slight_smile:

The Class:

<?php

class CustomInput extends cbBaseInput {
public $defaultIcon = 'chunk_A';
public $defaultTpl = '<div class="custominput">

</div>';

/**
 * @return string
 */
public function getName() {
    return 'Custom Input';
}
public function getDescription() {
    return 'Create your own cbField with any inputs that you need.';
}

/**
 * @return array
 */
public function getJavaScripts() {
    $assetsUrl = $this->modx->getOption('gpceeassets.assets_url', null, MODX_ASSETS_URL . 'gpceeassets/');
    return array(
        $assetsUrl . 'cbinputs/js/custominput.js',
    );
}

/**
 * @return array
 */
public function getTemplates()
{
    $tpls = array();
    
    // Grab the template from a .tpl file
    $corePath = $this->modx->getOption('gpceeassets.core_path', null, MODX_ASSETS_PATH . 'gpceeassets/');
    $template = file_get_contents($corePath . 'cbinputs/templates/custominput.tpl');
    // Wrap the template, giving the input a reference of "my_awesome_input", and
    // add it to the returned array.
    $tpls[] = $this->contentBlocks->wrapInputTpl('custominput', $template);

    // Grab the template from a .tpl file
    $corePath = $this->modx->getOption('gpceeassets.core_path', null, MODX_ASSETS_PATH . 'gpceeassets/');
    $template = file_get_contents($corePath . 'cbinputs/templates/custominput-row.tpl');
    // Wrap the template, giving the input a reference of "my_awesome_input", and
    // add it to the returned array.
    $tpls[] = '<script type="text/x-tmpl" id="contentblocks-field-custominput-row">'.$template.'</script>';

    return $tpls;
}

public function getFieldProperties()
{
    return array(
        array(
            'key' => 'group',
            'fieldLabel' => $this->modx->lexicon('contentblocks.repeater.group'),
            'xtype' => 'fieldgroup', // special type which creates a grid of fields under the current field
            'description' => $this->modx->lexicon('contentblocks.repeater.group.description')
        ),
    );
}

/**
 * Similar to {@see self::getFieldProperties}, except this is used when creating subfields to modify the edit panel.
 *
 * @return array
 */
public function getParentProperties()
{
    return array(
        array(
            'key' => 'key',
            'fieldLabel' => $this->modx->lexicon('contentblocks.repeater.key'),
            'xtype' => 'textfield',
            'default' => '',
            'description' => $this->modx->lexicon('contentblocks.repeater.key.description'),
            'allowBlank' => false
        ),
        array(
            'key' => 'width',
            'fieldLabel' => $this->modx->lexicon('contentblocks.width'),
            'xtype' => 'numberfield',
            'description' => $this->modx->lexicon('contentblocks.width.description'),
            'allowBlank' => false
        ),
    );
}

/**
 * Generate the HTML for the repeater
 *
 * @param cbField $field
 * @param array $data
 * @return mixed
 */
public function process(cbField $field, array $data = array())
{
    // Ensure inputs are loaded
    //$this->contentBlocks->loadInputs();

    // Grab the group fields and template
    $group = $this->getGroup($field);
    $tpl = $field->get('template');

    // Array to store the output in
    $rowsOutput = array();

    // Loop over each row
    $idx = 0;
    foreach ($data['rows'] as $row) {
        $idx++;
        $data['idx'] = $idx;
        $rowsOutput[] = $this->processRow($row, $group, $data, $tpl);
    }

    $data['total'] = count($data['rows']);

    // Glue individual rows together
    $separator = $field->get('row_separator');
    if (empty($separator)) $separator = "\n\n";
    $rowsOutput = implode($separator, $rowsOutput);

    // Throw it in a wrapper template with [[+rows]]
    $wrapperTpl = $field->get('wrapper_template');
    if (empty($wrapperTpl)) $wrapperTpl = '[[+rows]]';
    $data['rows'] = $rowsOutput;

    // Return the final output. Whew.
    return $this->contentBlocks->parse($wrapperTpl, $data);
}

/**
 * Processes a single row of the repeater
 *
 * @param array $row
 * @param cbField[] $group
 * @param array $data
 * @param string $tpl
 * @return mixed
 */
public function processRow($row, $group, $data, $tpl = '') {
    // For each row, we store placeholders in the $rowFields array
    $rowFields = array();
    // Loop over each key in the row and its value (array)
    foreach ($row as $key => $value) {
        $field = $group[$key];
        if ($field instanceof cbField) {
            $inputType = $field->get('input');

            // If it's a known input, we try to parse it
            if (isset($this->contentBlocks->inputs[$inputType])) {
                /** @var cbBaseInput $input */
                $input = $this->contentBlocks->inputs[$inputType];

                // Attempt to parse the data through that input type
                try {
                    $parseData = array_merge($data, $field->toArray(), $value);
                    $value = $input->process($field, $parseData);
                } catch (Exception $e) {
                    $value = 'Error parsing ' . $inputType . ': ' . $e->getMessage();
                }
            } else {
                $value = 'Input ' . htmlentities($inputType, ENT_QUOTES, 'UTF-8') . ' not found.';
            }
        }
        else {
            $value = 'Could not find subfield with key "' . $key . '" in the group"';
        }

        // Set the value as placeholder in $rowFields
        $rowFields[$key] = $value;
    }

    // Grab the $data and the $rowFields together so we have settings and everything
    $phs = array_merge($data, $rowFields);

    // Parse this row of fields
    return $this->contentBlocks->parse($tpl, $phs);
}

/**
 * Gets the repeater sub fields as key => cbField array
 *
 * @param cbField $field
 * @return cbField[]
 */
public function getGroup(cbField $field)
{
    $group = array();
    $fields = $field->getSubfields();
    foreach ($fields as $fld) {
        $key = $fld->getParentProperty('key');
        if (!empty($key)) {
            $group[$key] = $fld;
        }
    }
    return $group;
}
}

The Templates:

custominput

<div class="contentblocks-field contentblocks-field-custominput">

<div class="contentblocks-field-actions"></div>

<label><a class="contentblocks-collapser contentblocks-custominput-collapser contentblocks-custominput-expanded" href="javascript:void(0)">-</a> {%=o.name%}</label>

<ul class="contentblocks-custominput-wrapper"></ul> 

</div>

custominput-row

<li class="contentblocks-repeater-row contentblocks-custominput-row">
<div class="contentblocks-repeater-item-actions">
</div>
<ul class="contentblocks-repeater-item-wrapper"></ul>
</li>

The Javascript:

// Wrap your stuff in this module pattern for dependency injection
(function ($, ContentBlocks) {
// Add your custom input to the fieldTypes object as a function
// The dom variable contains the injected field (from the template)
// and the data variable contains field information, properties etc.
ContentBlocks.fieldTypes.custominput = function(dom, data) {

    var wrapper = dom.find('.contentblocks-custominput-wrapper'),
        emptyRowTmpl = tmpl('contentblocks-field-custominput-row');
        // emptyRowTmpl = tmpl('contentblocks-repeater-item');

    var input = {
        // Some optional variables can be defined here
    };
    
    // Do something when the input is being loaded
    input.init = function() {

        console.log('init', dom, data);

        if (data.rows && $.isArray(data.rows) && data.rows.length > 0) {
            $.each(data.rows, function(i, rowData) {
                input.addRow(rowData);
                console.log('rowData',rowData);
            });
        } else {
            input.addRow(data.subfields);
        }

        dom.on('click', '.contentblocks-custominput-expanded', function() {
            $(this).removeClass('contentblocks-custominput-expanded').addClass('contentblocks-custominput-collapsed').text('+').closest('.contentblocks-field-custominput').children('.contentblocks-custominput-wrapper').slideUp(300, function() {
                ContentBlocks.fixColumnHeights();
            });
        }).on('click', '.contentblocks-custominput-collapsed', function() {
            $(this).removeClass('contentblocks-custominput-collapsed').addClass('contentblocks-custominput-expanded').text('-').closest('.contentblocks-field-custominput').children('.contentblocks-custominput-wrapper').slideDown(300, function() {
                ContentBlocks.fixColumnHeights();
            });
        });
    };

    input.addRow = function(rowData, target) {
        rowData = rowData || {};

        // Generate the empty row wrapper, and inject it into the page
        var newRow = $(emptyRowTmpl({}));
        if (!target || target == 'bottom') {
            wrapper.append(newRow);
        }
        else {
            wrapper.prepend(newRow);
        }

        // Loop over each of the subfields to generate them individually, added tp the wrapper.
        $.each(data.subfields, function(idx, fld) {
            // First make sure we combine whatever values we have available
            var values = $.extend(true, {}, fld);
            if (rowData[fld.parent_properties.key]) {
                values = $.extend(true, {}, values, rowData[fld.parent_properties.key]);
            }
            // Call the ContentBlocks.addField API to create the subfield and have it injected into the canvas
            var generatedField = input.addField(newRow.children('ul'), fld.id, values, 'bottom');

            // Add a class for the width
            if (values.parent_properties.width > 0) {
                generatedField.css('width', values.parent_properties.width  + '%');
            }
            // Keep track of the repeater-key value so we can link it back together later
            generatedField.data('custominput-key', values.parent_properties.key);
        });

        // Ensure the various columns are kept nicely aligned
        ContentBlocks.fixColumnHeights();
    };
    
    // Get the data from this input, it has to be a simple object.
    input.getData = function() {
        var rows = [];

        wrapper.children('.contentblocks-custominput-row').each(function(idx, row){
            var rowFields = {};
            row = $(row);
            row.children('ul').children('li').each(function(fldIdx, field){
                field = $(field);

                var fldId = field.attr('id'),
                    input = ContentBlocks.generatedContentFields[fldId],
                    repeaterKey = field.data('custominput-key');

                if (input) {
                    var value = input.getData();
                    value.fieldId = fldId;
                    rowFields[repeaterKey] = value;
                }
                else {
                    if (console) console.error('input not found with id', fldId);
                }
            });

            rows.push(rowFields);

        });

        console.log('rows',rows);

        return {
            rows: rows
        }
    };

    input.addField = function (container, fldId, placeholders, position) {
        var fieldType = (ContentBlocksFields['_'+fldId]) ? $.extend(true, {}, ContentBlocksFields['_'+fldId]) : {
            input: (window.Ext && Ext.getCmp && Ext.getCmp('modx-resource-richtext') && Ext.getCmp('modx-resource-richtext').getValue()) ? 'richtext' : 'textarea',
            name: 'Content'
        };
        position = (position || position === 0) ? position : 'bottom';

        var settings = placeholders.settings;
        vcJquery.extend(placeholders, fieldType);

        // Add a unique ID
        ContentBlocks.fldId++;
        placeholders.generated_id = 'contentblocks-field-' + ContentBlocks.fldId;
        placeholders.field = fldId;

        // Build the field from its input template
        try {
            var generatedField = tmpl('contentblocks-field-' + fieldType.input, placeholders);
        } catch (e) {
            ContentBlocks.alert(_('contentblocks.error.input_not_found.message', {input: fieldType.input}) , _('contentblocks.error.input_not_found'));
            if (window.console) {
                console.error('Error initialising input type "' + fieldType.input + '": ' + e.message);
                console.log(e.stack);
            }
            return;
        }

        // Inject it top of bottom of the stack
        if (position == 'top') container.prepend(generatedField);
        else if (position == 'bottom') container.append(generatedField);
        else container.children('li').eq(position).after(generatedField);

        // Get the generated DOM as jQuery object
        var dom = $('#' + placeholders.generated_id);
        // Add the "delete field" button
        
        var allFieldSettings = ContentBlocks.getSettingFields(fieldType.settings);
        if(allFieldSettings.modalFields.length) {
            var modalFieldSettingsHTML = tmpl('contentblocks-button-field-settings', {});
        }

        if (settings) {
            dom.data('settings', Ext.encode(settings));
        }

        // Create a new instance of the input js
        if (typeof ContentBlocks.fieldTypes[fieldType.input] !== 'function') {
            ContentBlocks.alert('Uh oh, could not load the input type ' + fieldType.input);
            return;
        }

        var input = ContentBlocks.fieldTypes[fieldType.input](dom, placeholders);
        input.id = placeholders.generated_id;
        if (input.init) input.init();
        input.fieldId = fldId;

        // Store the input
        ContentBlocks.generatedContentFields[placeholders.generated_id] = input;
        if (allFieldSettings.exposedFields.length || allFieldSettings.exposedSettingFields.length) {
            ContentBlocks.addExposedFieldSettings(dom);
        }
        
        container.removeClass('contentblocks-column-is-empty');

        ContentBlocks.fixColumnHeights();

        return dom;
    };
    
    // Always return the input variable.
    return input;
}
})(vcJquery, ContentBlocks);

The Plugin:

<?php
/**
 * @var modX $modx
 * @var ContentBlocks $contentBlocks
 * @var array $scriptProperties
 */
if ($modx->event->name == 'ContentBlocks_RegisterInputs') {
// Load your own class. No need to require cbBaseInput, that's already loaded.
$path = $modx->getOption('gpceeassets.core_path', null, MODX_ASSETS_PATH . 'gpceeassets/');
require_once($path . 'cbinputs/custominput.class.php');

// Create an instance of your input type, passing the $contentBlocks var
$instance = new CustomInput($contentBlocks);

// Pass back your input reference as key, and the instance as value
$modx->event->output(array(
    'custominput' => $instance
));
}

You will need to modify some stuff, but you should get it to work easily.

I hope it helps someone :slight_smile:

@mhamstra maybe it would be a good idea, to implement such an input type to future releases :wink:

Congrats on your working custom input type :wink:

From a quick glance over the code I don’t immediately see what it does differently from the repeater, and thus what you’re asking to see implemented in a future release… could you elaborate?

Thanks Mark :slight_smile:

The Repeater is for items that can repeat - like the name says. My input type is for single items. The reason why I don’t want to use repeaters for single occurencies of a field, is that the user has now fiewer clicks to do while using it.

Also, and thats the big improvement, we can now represent the different types of markup displayed in the frontend, better in the backend. Where the select a base input type and add some unflexible settings to it approach does not provide the this abbility.

A big improvement that I also want to mention, is that I now can let my team create new fields. Because this approach is simpler to understand and quite flexible because they now can add properties and not only settings, they can group the fields as they want and the fields do look better then the other other base input types.

Put short, it gives you the flexibility of the repeater items for single fields :wink:

Hope that It makes things clearer.

Maybe I find some time during the Christmas time so I can make a propper transport package out of it. I would be pleased seeing it in your package provider :blush:

Ohh, so it’s a repeater limited to a single row, basically?

We’ve got something like that in 1.6, if you set the minimum and maximum rows in the repeater properties to 1, it’ll remove some of the repeater-specific parts of the UI, like the “add item” and “remove item” buttons. I might need to set up your custom input in my dev site to see how it differs from that, and if we can squeeze in more improvements based on that into 1.6. :wink:

Here’s what that looks like in 1.6

Thats always good to hear that you have that in the NEXT version :joy:

And yes, thats exactly what it looks like in my version now. This will be super useful in the future. Although it seems that I have kinda wasted my time with that, I’m glad, that I already have that feature in the current version.

I think your version will probably be implemented more thoughtful, but for now I’m happy and I hope my code can help someone to achieve the same.

One question I still have, will it be needed, to select a layout if you use the repeater with a max of 1 item? Because if this is so, I would still like to have a field like my custom input here - fiewer klicks :wink: