Skip to content

Commit f3ab6c0

Browse files
authored
Merge pull request #16809 from craftcms/feature/cms-1371-json-field
JSON field type
2 parents 51327f2 + 6e2df51 commit f3ab6c0

File tree

16 files changed

+444
-18
lines changed

16 files changed

+444
-18
lines changed

CHANGELOG-WIP.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
### Administration
1717
- Added the “Button Group” field type. ([#16782](https://github.com/craftcms/cms/pull/16782))
18+
- Added the “JSON” field type. ([#16809](https://github.com/craftcms/cms/pull/16809))
1819
- Added “Icon” and “Color” settings to Checkboxes, Dropdown, Multi-select, and Radio Buttons field options. ([#16645](https://github.com/craftcms/cms/pull/16645))
1920
- Added support for read-only custom fields, via new “Editability Conditions” on custom fields’ field layout settings. ([#16805](https://github.com/craftcms/cms/pull/16805))
2021
- The email settings page now shows a “Test” button when `allowAdminChanges` is disabled. ([#16508](https://github.com/craftcms/cms/discussions/16508))
@@ -49,12 +50,15 @@
4950
- Added `craft\fields\BaseOptionsField::$optionColors`, which can be set to `true` by subclasses to enable the “Color” setting for field options. ([#16645](https://github.com/craftcms/cms/pull/16645))
5051
- Added `craft\fields\BaseOptionsField::$optionIcons`, which can be set to `true` by subclasses to enable the “Icon” setting for field options. ([#16645](https://github.com/craftcms/cms/pull/16645))
5152
- Added `craft\fields\data\ColorData::$label`. ([#16492](https://github.com/craftcms/cms/pull/16492))
53+
- Added `craft\fields\data\JsonData`.
5254
- Added `craft\fields\linktypes\BaseElementLinkType::elementGqlType()`.
55+
- Added `craft\helpers\Json::reindent()`.
5356
- Added `craft\models\FieldLayout::getEditableCustomFields()`.
5457
- Added `craft\queue\ReleasableQueueInterface`. ([#16672](https://github.com/craftcms/cms/pull/16672))
5558
- Added `craft\services\Elements::getBulkOpKeys()`.
5659
- Added `craft\services\Search::indexElementIfQueued()`.
5760
- Added `craft\services\Search::queueIndexElement()`.
61+
- Added `craft\web\assets\codemirror\CodeMirrorAsset`.
5862
- Added `Craft.ui.createIconPicker()`.
5963
- Added `Craft.ui.createIconPickerField()`.
6064
- `craft\base\Element::fieldLayoutFields()` now has an `$editableOnly` argument.

package-lock.json

Lines changed: 7 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
"accounting": "^0.4.1",
4747
"axios": "^1.6.8",
4848
"blueimp-file-upload": "^10.31.0",
49+
"codemirror": "^5.65.18",
4950
"d3": "^7.8.5",
5051
"fabric": "^1.7.19",
5152
"graphiql": "~1.7.2",

src/fields/Json.php

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
<?php
2+
/**
3+
* @link https://craftcms.com/
4+
* @copyright Copyright (c) Pixel & Tonic, Inc.
5+
* @license https://craftcms.github.io/license/
6+
*/
7+
8+
namespace craft\fields;
9+
10+
use Craft;
11+
use craft\base\CrossSiteCopyableFieldInterface;
12+
use craft\base\ElementInterface;
13+
use craft\base\Field;
14+
use craft\base\MergeableFieldInterface;
15+
use craft\fields\data\JsonData;
16+
use craft\helpers\Html;
17+
use craft\helpers\Json as JsonHelper;
18+
use craft\web\assets\codemirror\CodeMirrorAsset;
19+
use yii\base\InvalidArgumentException;
20+
use yii\db\Schema;
21+
22+
/**
23+
* Icon represents an icon picker field.
24+
*
25+
* @author Pixel & Tonic, Inc. <[email protected]>
26+
* @since 5.7.0
27+
*/
28+
class Json extends Field implements MergeableFieldInterface, CrossSiteCopyableFieldInterface
29+
{
30+
/**
31+
* @inheritdoc
32+
*/
33+
public static function displayName(): string
34+
{
35+
return 'JSON';
36+
}
37+
38+
/**
39+
* @inheritdoc
40+
*/
41+
public static function icon(): string
42+
{
43+
return 'brackets-curly';
44+
}
45+
46+
/**
47+
* @inheritdoc
48+
*/
49+
public static function phpType(): string
50+
{
51+
return 'array|null';
52+
}
53+
54+
/**
55+
* @inheritdoc
56+
*/
57+
public static function dbType(): string
58+
{
59+
return Schema::TYPE_JSON;
60+
}
61+
62+
/**
63+
* @inheritdoc
64+
*/
65+
public function normalizeValue(mixed $value, ?ElementInterface $element): mixed
66+
{
67+
if ($value === null || $value === '') {
68+
return null;
69+
}
70+
71+
if ($value instanceof JsonData) {
72+
return $value;
73+
}
74+
75+
return new JsonData($value);
76+
}
77+
78+
/**
79+
* @inheritdoc
80+
*/
81+
public function normalizeValueFromRequest(mixed $value, ?ElementInterface $element): mixed
82+
{
83+
if ($value === null || $value === '') {
84+
return null;
85+
}
86+
87+
try {
88+
$value = JsonHelper::decode($value);
89+
} catch (InvalidArgumentException $e) {
90+
$value = [
91+
'__ERROR__' => $e->getMessage(),
92+
'__VALUE__' => $value,
93+
];
94+
}
95+
96+
return new JsonData($value);
97+
}
98+
99+
/**
100+
* @inheritdoc
101+
*/
102+
protected function inputHtml(mixed $value, ?ElementInterface $element, bool $inline): string
103+
{
104+
return $this->_inputHtml($value, false);
105+
}
106+
107+
/**
108+
* @inheritdoc
109+
*/
110+
public function getStaticHtml(mixed $value, ElementInterface $element): string
111+
{
112+
return $this->_inputHtml($value, true);
113+
}
114+
115+
private function _inputHtml(?JsonData $value, bool $static): string
116+
{
117+
$id = $this->getInputId();
118+
119+
$view = Craft::$app->getView();
120+
$view->registerAssetBundle(CodeMirrorAsset::class);
121+
$view->registerJsWithVars(fn($id, $static) => <<<JS
122+
(() => {
123+
const textarea = document.getElementById($id);
124+
const editor = CodeMirror.fromTextArea(textarea, {
125+
mode: {
126+
name: 'javascript',
127+
json: true,
128+
},
129+
viewportMargin: Infinity,
130+
readOnly: $static,
131+
theme: [
132+
'default',
133+
$static ? 'readonly' : null,
134+
].filter(v => v).join(' '),
135+
});
136+
editor.on('change', (editor) => {
137+
editor.save();
138+
});
139+
})();
140+
JS, [
141+
$view->namespaceInputId($id),
142+
$static,
143+
]);
144+
145+
return Html::textarea($this->handle, $value?->getJson(true), [
146+
'id' => $id,
147+
]);
148+
}
149+
150+
/**
151+
* @inheritdoc
152+
*/
153+
public function getElementValidationRules(): array
154+
{
155+
return [
156+
[
157+
function(ElementInterface $element) {
158+
/** @var JsonData|null $value */
159+
$value = $element->getFieldValue($this->handle);
160+
if (isset($value['__ERROR__'])) {
161+
$element->addError("field:$this->handle", Craft::t('app', '{attribute} must be valid JSON.', [
162+
'attribute' => $this->getUiLabel(),
163+
]));
164+
}
165+
},
166+
],
167+
];
168+
}
169+
170+
/**
171+
* @inheritdoc
172+
*/
173+
public function getPreviewHtml(mixed $value, ElementInterface $element): string
174+
{
175+
if ($value === null) {
176+
return '';
177+
}
178+
/** @var JsonData $value */
179+
return Html::tag('code', $value->getJson());
180+
}
181+
182+
/**
183+
* @inheritdoc
184+
*/
185+
public function previewPlaceholderHtml(mixed $value, ?ElementInterface $element): string
186+
{
187+
return Html::tag('code', '{foo:"bar"}');
188+
}
189+
}

src/fields/data/JsonData.php

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
<?php
2+
/**
3+
* @link https://craftcms.com/
4+
* @copyright Copyright (c) Pixel & Tonic, Inc.
5+
* @license https://craftcms.github.io/license/
6+
*/
7+
8+
namespace craft\fields\data;
9+
10+
use ArrayAccess;
11+
use ArrayIterator;
12+
use craft\base\Serializable;
13+
use craft\helpers\Json;
14+
use IteratorAggregate;
15+
use Traversable;
16+
use yii\base\BaseObject;
17+
use yii\base\InvalidCallException;
18+
19+
/**
20+
* JSON field data class.
21+
*
22+
* @author Pixel & Tonic, Inc. <[email protected]>
23+
* @since 5.7.0
24+
*/
25+
class JsonData extends BaseObject implements ArrayAccess, IteratorAggregate, Serializable
26+
{
27+
public function __construct(
28+
private mixed $value,
29+
array $config = [],
30+
) {
31+
parent::__construct($config);
32+
}
33+
34+
public function __toString(): string
35+
{
36+
return $this->getJson();
37+
}
38+
39+
public function getType(): string
40+
{
41+
$type = gettype($this->value);
42+
return match ($type) {
43+
'double' => 'float',
44+
default => $type,
45+
};
46+
}
47+
48+
public function getValue(): mixed
49+
{
50+
return $this->value;
51+
}
52+
53+
public function getJson(bool $pretty = false, string $indent = ' '): string
54+
{
55+
if (isset($this->value['__ERROR__'], $this->value['__VALUE__'])) {
56+
return $this->value['__VALUE__'];
57+
}
58+
59+
$options = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE;
60+
if ($pretty) {
61+
$options |= JSON_PRETTY_PRINT;
62+
}
63+
64+
$json = Json::encode($this->value, $options);
65+
66+
if ($pretty) {
67+
return Json::reindent($json, $indent);
68+
}
69+
70+
return $json;
71+
}
72+
73+
public function offsetGet(mixed $offset): mixed
74+
{
75+
return $this->value[$offset];
76+
}
77+
78+
public function offsetSet(mixed $offset, mixed $value): void
79+
{
80+
$this->value[$offset] = $value;
81+
}
82+
83+
public function offsetExists(mixed $offset): bool
84+
{
85+
if (is_string($this->value)) {
86+
return isset($this->value[$offset]);
87+
}
88+
89+
if (is_array($this->value)) {
90+
return array_key_exists($offset, $this->value);
91+
}
92+
93+
if ($this->value instanceof ArrayAccess) {
94+
return $this->value->offsetExists($offset);
95+
}
96+
97+
return false;
98+
}
99+
100+
public function offsetUnset(mixed $offset): void
101+
{
102+
unset($this->value[$offset]);
103+
}
104+
105+
public function getIterator(): Traversable
106+
{
107+
if (is_string($this->value)) {
108+
return new ArrayIterator(str_split($this->value));
109+
}
110+
111+
if (is_array($this->value)) {
112+
return new ArrayIterator($this->value);
113+
}
114+
115+
if ($this->value instanceof Traversable) {
116+
return $this->value;
117+
}
118+
119+
throw new InvalidCallException(sprintf(
120+
'Cannot iterate over non-iterable type: %s',
121+
is_object($this->value) ? get_class($this->value) : gettype($this->value),
122+
));
123+
}
124+
125+
public function serialize(): mixed
126+
{
127+
return $this->value;
128+
}
129+
}

0 commit comments

Comments
 (0)