dmitry-kulikov/yii2-json-editor

View on GitHub
src/JsonEditor.php

Summary

Maintainability
A
3 hrs
Test Coverage
A
100%
<?php

namespace kdn\yii2;

use kdn\yii2\assets\JsonEditorFullAsset;
use kdn\yii2\assets\JsonEditorMinimalistAsset;
use yii\helpers\ArrayHelper;
use yii\helpers\Html;
use yii\helpers\Inflector;
use yii\helpers\Json;
use yii\web\JsExpression;
use yii\widgets\InputWidget;

/**
 * Class JsonEditor.
 * @package kdn\yii2
 */
class JsonEditor extends InputWidget
{
    /**
     * @var array options which will be passed to JSON editor
     * @see https://github.com/josdejong/jsoneditor/blob/master/docs/api.md#configuration-options
     */
    public $clientOptions = [];

    /**
     * @var string[] list of JSON editor modes for which all fields should be collapsed automatically;
     * allowed modes 'tree', 'view', and 'form'
     * @see https://github.com/josdejong/jsoneditor/blob/master/docs/api.md#jsoneditorcollapseall
     */
    public $collapseAll = [];

    /**
     * @var array HTML attributes to be applied to the JSON editor container tag
     * @see \yii\helpers\Html::renderTagAttributes() for details on how attributes are being rendered
     */
    public $containerOptions = [];

    /**
     * @var mixed this property can be used instead of `value`;
     * while `value` must be JSON string, `decodedValue` accepts decoded JSON, i.e. arrays, floats, booleans, etc.;
     * `decodedValue` has precedence over `value`: if `decodedValue` is set then `value` will be ignored
     * @see value
     */
    public $decodedValue;

    /**
     * @var string default value
     */
    public $defaultValue = '{}';

    /**
     * @var string[] list of JSON editor modes for which all fields should be expanded automatically;
     * allowed modes 'tree', 'view', and 'form'
     * @see https://github.com/josdejong/jsoneditor/blob/master/docs/api.md#jsoneditorexpandall
     */
    public $expandAll = [];

    /**
     * @var null|bool whether to use minimalist version of JSON editor;
     * note that "minimalist" is not the same as "minimized";
     * if property is not set then extension will try to determine automatically whether full version is needed,
     * if full version is not required then minimalist version will be used;
     * you can explicitly set this property to true or false if automatic detection does not fit for you application
     * @see https://github.com/josdejong/jsoneditor/blob/master/src/docs/which%20files%20do%20I%20need.md
     */
    public $minimalist;

    /**
     * @var string[] list of client options which should be automatically converted to `JsExpression`
     * @see clientOptions
     */
    protected $jsExpressionClientOptions = [
        'ace',
        'ajv',
        'autocomplete',
        'createQuery',
        'executeQuery',
        'languages',
        'modalAnchor',
        'onBlur',
        'onChange',
        'onChangeJSON',
        'onChangeText',
        'onClassName',
        'onColorPicker',
        'onCreateMenu',
        'onExpand',
        'onEditable',
        'onError',
        'onEvent',
        'onFocus',
        'onModeChange',
        'onNodeName',
        'onSelectionChange',
        'onTextSelectionChange',
        'onValidate',
        'onValidationError',
        'popupAnchor',
        'schema',
        'schemaRefs',
        'templates',
        'timestampFormat',
        'timestampTag',
    ];

    /**
     * @var string default JSON editor mode
     */
    private $mode = 'tree';

    /**
     * @var string[] available JSON editor modes
     */
    private $modes = [];

    /**
     * {@inheritdoc}
     */
    public function init()
    {
        parent::init();
        if (!isset($this->containerOptions['id'])) {
            $this->containerOptions['id'] = $this->options['id'] . '-json-editor';
        }

        $this->determineValue();

        foreach (['mode', 'modes'] as $parameterName) {
            $this->$parameterName = ArrayHelper::getValue($this->clientOptions, $parameterName, $this->$parameterName);
        }
        // make sure that "mode" is specified, otherwise JavaScript error can occur in some situations
        $this->clientOptions['mode'] = $this->mode;

        // if property is not set then try to determine automatically whether full version is needed
        if ($this->minimalist === null) {
            $this->minimalist = $this->mode != 'code' && !in_array('code', $this->modes);
        }
    }

    /**
     * Analyses input data and determines what should be used as value.
     * This method must set `value` and `decodedValue` properties.
     */
    protected function determineValue()
    {
        // decodedValue property has first precedence
        if ($this->decodedValue !== null) {
            $this->value = Json::encode($this->decodedValue);

            return;
        }

        // value property has second precedence
        // options['value'] property has third precedence
        if (!$this->issetValue() && isset($this->options['value'])) {
            $this->value = $this->options['value'];
        }

        // model attribute has fourth precedence
        if (!$this->issetValue() && $this->hasModel()) {
            $this->value = Html::getAttributeValue($this->model, $this->attribute);
        }

        // value is not set anywhere, use default
        if (!$this->issetValue()) {
            $this->value = $this->defaultValue;
        }

        $this->decodedValue = Json::decode($this->value, false);
    }

    /**
     * Check whether `value` property is set. For JSON string the empty string is considered as equivalent of null.
     * @return bool whether `value` property is set.
     */
    protected function issetValue()
    {
        return $this->value !== null && $this->value !== '';
    }

    /**
     * {@inheritdoc}
     */
    public function run()
    {
        $this->registerClientScript();
        if ($this->hasModel()) {
            $this->options['value'] = $this->value; // model may contain decoded JSON, override value for rendering
            echo Html::activeHiddenInput($this->model, $this->attribute, $this->options);
        } else {
            echo Html::hiddenInput($this->name, $this->value, $this->options);
        }
        echo Html::tag('div', '', $this->containerOptions);
    }

    /**
     * Initializes client options.
     */
    protected function initClientOptions()
    {
        $options = $this->clientOptions;
        foreach ($options as $key => $value) {
            if (!$value instanceof JsExpression && in_array($key, $this->jsExpressionClientOptions)) {
                $options[$key] = new JsExpression($value);
            }
        }
        $this->clientOptions = $options;
    }

    /**
     * Registers the needed client script.
     */
    public function registerClientScript()
    {
        $this->initClientOptions();
        $view = $this->getView();

        if ($this->minimalist) {
            JsonEditorMinimalistAsset::register($view);
        } else {
            JsonEditorFullAsset::register($view);
        }

        $hiddenInputId = $this->options['id'];
        $editorName = Inflector::variablize($hiddenInputId) . 'JsonEditor_' . hash('crc32', $hiddenInputId);
        $this->options['data-json-editor-name'] = $editorName;

        $jsUpdateHiddenField = "jQuery('#$hiddenInputId').val($editorName.getText());";

        if (isset($this->clientOptions['onChange'])) {
            $userFunction = " var userFunction = {$this->clientOptions['onChange']}; userFunction.call(this);";
        } else {
            $userFunction = '';
        }
        $this->clientOptions['onChange'] = new JsExpression("function() {{$jsUpdateHiddenField}$userFunction}");

        if (!empty($this->collapseAll) || !empty($this->expandAll)) {
            if (isset($this->clientOptions['onModeChange'])) {
                $userFunction = " var userFunction = {$this->clientOptions['onModeChange']}; " .
                    "userFunction.call(this, newMode, oldMode);";
            } else {
                $userFunction = '';
            }
            $jsOnModeChange = "function(newMode, oldMode) {";
            foreach (['collapseAll', 'expandAll'] as $property) {
                if (!empty($this->$property)) {
                    $jsOnModeChange .= "if (" . Json::htmlEncode($this->$property) . ".indexOf(newMode) !== -1) " .
                        "{{$editorName}.$property();}";
                }
            }
            $jsOnModeChange .= "$userFunction}";
            $this->clientOptions['onModeChange'] = new JsExpression($jsOnModeChange);
        }

        $htmlEncodedValue = Json::htmlEncode($this->decodedValue); // Json::htmlEncode is needed to prevent XSS
        $jsCode = "$editorName = new JSONEditor(document.getElementById('{$this->containerOptions['id']}'), " .
            Json::htmlEncode($this->clientOptions) . ");\n" .
            "$editorName.set($htmlEncodedValue);\n" . // have to use set method,
            // because constructor works wrong for '0', 'null', '""'; constructor turns them to {}, which may be wrong
            "jQuery('#$hiddenInputId').parents('form').submit(function() {{$jsUpdateHiddenField}});";
        if (in_array($this->mode, $this->collapseAll)) {
            $jsCode .= "\n$editorName.collapseAll();";
        }
        if (in_array($this->mode, $this->expandAll)) {
            $jsCode .= "\n$editorName.expandAll();";
        }
        $view->registerJs($jsCode);
    }
}