From 4b6dd3ccf6f46c5ac1fde4dc980af023bf01b81d Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Thu, 14 Apr 2022 17:50:26 -0400 Subject: [PATCH] Track and remove nested hidden fields (#5805) Co-authored-by: StyleCI Bot Co-authored-by: Jason Varga --- .../components/data-list/HasHiddenFields.js | 15 +- .../js/components/field-conditions/Omitter.js | 91 ++++++++ .../field-conditions/ValidatorMixin.js | 4 +- .../js/components/fieldtypes/Fieldtype.vue | 2 +- .../fieldtypes/bard/BardFieldtype.vue | 11 +- .../js/components/fieldtypes/bard/Set.vue | 10 +- .../js/components/fieldtypes/grid/Cell.vue | 4 +- .../js/components/fieldtypes/grid/Grid.vue | 4 +- .../js/components/fieldtypes/grid/Row.vue | 15 +- .../js/components/fieldtypes/grid/Stacked.vue | 2 +- .../components/fieldtypes/grid/StackedRow.vue | 2 +- .../js/components/fieldtypes/grid/Table.vue | 2 +- .../js/components/fieldtypes/grid/View.vue | 4 +- .../fieldtypes/replicator/Field.vue | 8 +- .../fieldtypes/replicator/Replicator.vue | 6 +- .../components/fieldtypes/replicator/Set.vue | 12 +- resources/js/components/publish/Container.vue | 8 +- resources/js/components/publish/Field.vue | 6 +- .../js/tests/FieldConditionsOmitter.test.js | 207 ++++++++++++++++++ src/Fields/Field.php | 24 +- src/Fields/Fields.php | 44 ++-- src/Fields/Validator.php | 2 +- src/Fieldtypes/Grid.php | 4 +- tests/Fields/FieldsTest.php | 72 ++++-- tests/Fields/ValidatorTest.php | 4 +- 25 files changed, 460 insertions(+), 103 deletions(-) create mode 100644 resources/js/components/field-conditions/Omitter.js create mode 100644 resources/js/tests/FieldConditionsOmitter.test.js diff --git a/resources/js/components/data-list/HasHiddenFields.js b/resources/js/components/data-list/HasHiddenFields.js index 131f2cf10b..8944bba13d 100644 --- a/resources/js/components/data-list/HasHiddenFields.js +++ b/resources/js/components/data-list/HasHiddenFields.js @@ -1,3 +1,5 @@ +import HiddenValuesOmitter from '../field-conditions/Omitter.js'; + export default { computed: { @@ -6,10 +8,17 @@ export default { return this.$store.state.publish[this.publishContainer].hiddenFields; }, + jsonSubmittingFields() { + return this.$store.state.publish[this.publishContainer].jsonSubmittingFields; + }, + visibleValues() { - return _.omit(this.values, (_, handle) => { - return this.hiddenFields[handle]; - }); + let hiddenFields = _.chain(this.hiddenFields) + .pick(hidden => hidden) + .keys() + .value(); + + return new HiddenValuesOmitter(this.values, this.jsonSubmittingFields).omit(hiddenFields); }, } diff --git a/resources/js/components/field-conditions/Omitter.js b/resources/js/components/field-conditions/Omitter.js new file mode 100644 index 0000000000..885be92fd8 --- /dev/null +++ b/resources/js/components/field-conditions/Omitter.js @@ -0,0 +1,91 @@ +import { clone } from '../../bootstrap/globals.js' + +export default class { + constructor(values, jsonFields) { + this.values = clone(values); + + this.jsonFields = clone(jsonFields || []) + .filter((field, index) => jsonFields.indexOf(field) === index) + .sort(); + } + + omit(hiddenKeys) { + this.jsonDecode() + .omitHiddenFields(hiddenKeys) + .jsonEncode(); + + return this.values; + } + + jsonDecode() { + this.jsonFields.forEach(dottedKey => { + this.jsonDecodeValue(dottedKey); + }); + + return this; + } + + omitHiddenFields(hiddenKeys) { + hiddenKeys.forEach(dottedKey => { + this.forgetValue(dottedKey); + }); + + return this; + } + + jsonEncode() { + clone(this.jsonFields).reverse().forEach(dottedKey => { + this.jsonEncodeValue(dottedKey); + }); + + return this; + } + + dottedKeyToJsPath(dottedKey) { + return dottedKey.replace(/\.*(\d+)\./g, '[$1].'); + } + + missingValue(dottedKey) { + var properties = Array.isArray(dottedKey) ? dottedKey : dottedKey.split('.'); + var value = properties.reduce((prev, curr) => prev && prev[curr], clone(this.values)); + + return value === undefined; + } + + jsonDecodeValue(dottedKey) { + if (this.missingValue(dottedKey)) return; + + let values = clone(this.values); + let jsPath = this.dottedKeyToJsPath(dottedKey); + let fieldValue = eval('values.' + jsPath); + let decodedFieldValue = JSON.parse(fieldValue); + + eval('values.' + jsPath + ' = decodedFieldValue'); + + this.values = values; + } + + jsonEncodeValue(dottedKey) { + if (this.missingValue(dottedKey)) return; + + let values = clone(this.values); + let jsPath = this.dottedKeyToJsPath(dottedKey); + let fieldValue = eval('values.' + jsPath); + let encodedFieldValue = JSON.stringify(fieldValue); + + eval('values.' + jsPath + ' = encodedFieldValue'); + + this.values = values; + } + + forgetValue(dottedKey) { + if (this.missingValue(dottedKey)) return; + + let values = clone(this.values); + let jsPath = this.dottedKeyToJsPath(dottedKey); + + eval('delete values.' + jsPath); + + this.values = values; + } +} diff --git a/resources/js/components/field-conditions/ValidatorMixin.js b/resources/js/components/field-conditions/ValidatorMixin.js index 05c1f88b5d..f7ec8fa078 100644 --- a/resources/js/components/field-conditions/ValidatorMixin.js +++ b/resources/js/components/field-conditions/ValidatorMixin.js @@ -8,11 +8,11 @@ export default { }, methods: { - showField(field) { + showField(field, dottedKey) { let passes = new Validator(field, this.values, this.$store, this.storeName).passesConditions(); this.$store.commit(`publish/${this.storeName}/setHiddenField`, { - handle: field.handle, + dottedKey: dottedKey || field.handle, hidden: ! passes, }); diff --git a/resources/js/components/fieldtypes/Fieldtype.vue b/resources/js/components/fieldtypes/Fieldtype.vue index 3fc9060904..7ad70a143e 100644 --- a/resources/js/components/fieldtypes/Fieldtype.vue +++ b/resources/js/components/fieldtypes/Fieldtype.vue @@ -22,7 +22,7 @@ export default { default: false }, namePrefix: String, - errorKeyPrefix: String, + fieldPathPrefix: String, }, methods: { diff --git a/resources/js/components/fieldtypes/bard/BardFieldtype.vue b/resources/js/components/fieldtypes/bard/BardFieldtype.vue index d518d6e783..ba190b6210 100644 --- a/resources/js/components/fieldtypes/bard/BardFieldtype.vue +++ b/resources/js/components/fieldtypes/bard/BardFieldtype.vue @@ -245,7 +245,7 @@ export default { if (! this.storeState) return []; return Object.values(this.setIndexes).filter((setIndex) => { - const prefix = `${this.errorKeyPrefix || this.handle}.${setIndex}.`; + const prefix = `${this.fieldPathPrefix || this.handle}.${setIndex}.`; return Object.keys(this.storeState.errors).some(key => key.startsWith(prefix)); }) @@ -286,6 +286,8 @@ export default { this.$nextTick(() => this.mounted = true); this.pageHeader = document.querySelector('.global-header'); + + this.$store.commit(`publish/${this.storeName}/setFieldSubmitsJson`, this.fieldPathPrefix || this.handle); }, beforeDestroy() { @@ -297,7 +299,12 @@ export default { json(json) { if (!this.mounted) return; - // Use a json string otherwise Laravel's TrimStrings middleware will remove spaces where we need them. + // Prosemirror's JSON will include spaces between tags. + // For example (this is not the actual json)... + // "

One two three

" becomes ['OneSPACE', 'two', 'SPACEthree'] + // But, Laravel's TrimStrings middleware would remove them. + // Those spaces need to be there, otherwise it would be rendered as

Onetwothree

+ // To combat this, we submit the JSON string instead of an object. this.updateDebounced(JSON.stringify(json)); }, diff --git a/resources/js/components/fieldtypes/bard/Set.vue b/resources/js/components/fieldtypes/bard/Set.vue index 9ea26bb2c4..5982024297 100644 --- a/resources/js/components/fieldtypes/bard/Set.vue +++ b/resources/js/components/fieldtypes/bard/Set.vue @@ -36,14 +36,14 @@
handle.startsWith(prefix)); }, diff --git a/resources/js/components/fieldtypes/replicator/Replicator.vue b/resources/js/components/fieldtypes/replicator/Replicator.vue index 9c917d089e..c1fd58de12 100644 --- a/resources/js/components/fieldtypes/replicator/Replicator.vue +++ b/resources/js/components/fieldtypes/replicator/Replicator.vue @@ -30,7 +30,7 @@ :sortable-handle-class="sortableHandleClass" :is-read-only="isReadOnly" :collapsed="collapsed.includes(set._id)" - :error-key-prefix="errorKeyPrefix || handle" + :field-path-prefix="fieldPathPrefix || handle" :has-error="setHasError(index)" :previews="previews[set._id]" @collapsed="collapseSet(set._id)" @@ -90,7 +90,7 @@ export default { }, computed: { - + previews() { return this.meta.previews; }, @@ -204,7 +204,7 @@ export default { }, setHasError(index) { - const prefix = `${this.errorKeyPrefix || this.handle}.${index}.`; + const prefix = `${this.fieldPathPrefix || this.handle}.${index}.`; return Object.keys(this.storeState.errors ?? []).some(handle => handle.startsWith(prefix)); }, diff --git a/resources/js/components/fieldtypes/replicator/Set.vue b/resources/js/components/fieldtypes/replicator/Set.vue index 000b807182..6f6871123d 100644 --- a/resources/js/components/fieldtypes/replicator/Set.vue +++ b/resources/js/components/fieldtypes/replicator/Set.vue @@ -35,14 +35,14 @@
handle.startsWith(prefix)); }, diff --git a/resources/js/tests/FieldConditionsOmitter.test.js b/resources/js/tests/FieldConditionsOmitter.test.js new file mode 100644 index 0000000000..9cf05dd695 --- /dev/null +++ b/resources/js/tests/FieldConditionsOmitter.test.js @@ -0,0 +1,207 @@ +import Omitter from '../components/field-conditions/Omitter.js'; + +test('it omits values at top level', () => { + let values = { + first_name: 'Han', + last_name: 'Solo', + ship: 'Falcon', + bff: 'Chewy', + }; + + let omitted = new Omitter(values).omit([ + 'last_name', + 'bff', + ]); + + let expected = { + first_name: 'Han', + ship: 'Falcon', + }; + + expect(new Omitter(values).omit(['last_name', 'bff'])).toEqual(expected); +}); + +test('it omits nested values', () => { + let values = { + first_name: 'Han', + last_name: 'Solo', + ship: { + name: 'Falcon', + completed_kessel_run: 'less than 12 parsecs', + junk: true, + }, + bffs: [ + { + name: 'Chewy', + type: 'Wookie', + }, + { + name: 'Leia', + type: 'Woman', + crush: [ + { + name: 'Lando', + type: 'Man', + } + ], + }, + ], + }; + + let omitted = new Omitter(values).omit([ + 'last_name', + 'ship.completed_kessel_run', + 'bffs.0.type', + 'bffs.1.crush.0.name', + ]); + + let expected = { + first_name: 'Han', + ship: { + name: 'Falcon', + junk: true, + }, + bffs: [ + { + name: 'Chewy', + }, + { + name: 'Leia', + type: 'Woman', + crush: [ + { + type: 'Man', + } + ], + }, + ], + }; + + expect(omitted).toEqual(expected); +}); + +test('it omits nested json field values', () => { + let values = { + first_name: 'Han', + last_name: 'Solo', + ship: { + name: 'Falcon', + completed_kessel_run: 'less than 12 parsecs', + junk: true, + }, + bffs: JSON.stringify([ + { + name: 'Chewy', + type: 'Wookie', + }, + { + name: 'Leia', + type: 'Woman', + crush: JSON.stringify([ + { + name: 'Lando', + type: 'Man', + }, + ]), + }, + ]), + }; + + let jsonFields = [ + 'bffs.1.crush', // Intentionally passing deeper JSON value first to ensure the Omitter properly sorts these before decoding + 'bffs', + ]; + + let omitted = new Omitter(values, jsonFields).omit([ + 'last_name', + 'ship.completed_kessel_run', + 'bffs.0.type', + 'bffs.1.crush.0.name', + ]); + + let expected = { + first_name: 'Han', + ship: { + name: 'Falcon', + junk: true, + }, + bffs: JSON.stringify([ + { + name: 'Chewy', + }, + { + name: 'Leia', + type: 'Woman', + crush: JSON.stringify([ + { + type: 'Man', + }, + ]), + }, + ]), + }; + + expect(omitted).toEqual(expected); +}); + +test('it omits null hidden values', () => { + let values = { + first_name: 'Han', + last_name: 'Solo', + ship: 'Falcon', + bff: null, // this is null, but should still get removed + }; + + let omitted = new Omitter(values).omit([ + 'last_name', + 'bff', + ]); + + let expected = { + first_name: 'Han', + ship: 'Falcon', + }; + + expect(new Omitter(values).omit(['last_name', 'bff'])).toEqual(expected); +}); + +test('it gracefully handles errors', () => { + let values = { + first_name: 'Han', + last_name: 'Solo', + bffs: JSON.stringify([ + { + name: 'Chewy', + type: 'Wookie', + }, + ]), + }; + + let jsonFields = [ + 'bffs', + 'bffs', // duplicate field + 'middle_name', // non-existent field + 'bffs.0.crush', // non-existent field + ]; + + let omitted = new Omitter(values, jsonFields).omit([ + 'last_name', + 'middle_name', // non-existent field + 'bffs.0.name', + 'bffs.0.name', // duplicate field + 'bffs.1.name', // non-existent field + 'bffs.0.crush', // non-existent field + 'bffs.0.crush.0.name', // non-existent field + ]); + + let expected = { + first_name: 'Han', + bffs: JSON.stringify([ + { + type: 'Wookie', + }, + ]), + }; + + expect(omitted).toEqual(expected); +}); diff --git a/src/Fields/Field.php b/src/Fields/Field.php index fc756cb723..01c4335475 100644 --- a/src/Fields/Field.php +++ b/src/Fields/Field.php @@ -19,7 +19,6 @@ class Field implements Arrayable protected $value; protected $parent; protected $parentField; - protected $filled = false; protected $validationContext; public function __construct($handle, array $config) @@ -33,8 +32,7 @@ public function newInstance() return (new static($this->handle, $this->config)) ->setParent($this->parent) ->setParentField($this->parentField) - ->setValue($this->value) - ->setFilled($this->filled); + ->setValue($this->value); } public function setHandle(string $handle) @@ -201,11 +199,6 @@ public function isFilterable() return (bool) $this->get('filterable'); } - public function isFilled() - { - return (bool) $this->filled; - } - public function toPublishArray() { return array_merge($this->preProcessedConfig(), [ @@ -232,13 +225,6 @@ public function toBlueprintArray() ]; } - public function setFilled($filled) - { - $this->filled = $filled; - - return $this; - } - public function setValue($value) { $this->value = $value; @@ -246,14 +232,6 @@ public function setValue($value) return $this; } - public function fillValue($value) - { - $this->value = $value; - $this->filled = true; - - return $this; - } - public function value() { return $this->value; diff --git a/src/Fields/Fields.php b/src/Fields/Fields.php index ba494006ed..7607960ecd 100644 --- a/src/Fields/Fields.php +++ b/src/Fields/Fields.php @@ -15,6 +15,8 @@ class Fields protected $fields; protected $parent; protected $parentField; + protected $filled = []; + protected $withValidatableValues = false; public function __construct($items = [], $parent = null, $parentField = null) { @@ -58,6 +60,20 @@ public function setParentField($field) return $this; } + public function setFilled($dottedKeys) + { + $this->filled = $dottedKeys; + + return $this; + } + + public function withValidatableValues() + { + $this->withValidatableValues = true; + + return $this; + } + public function items() { return $this->items; @@ -84,7 +100,8 @@ public function newInstance() ->setParent($this->parent) ->setParentField($this->parentField) ->setItems($this->items) - ->setFields($this->fields); + ->setFields($this->fields) + ->setFilled($this->filled); } public function localizable() @@ -118,27 +135,28 @@ public function toPublishArray() public function addValues(array $values) { + $filled = array_keys($values); + $fields = $this->fields->map(function ($field) use ($values) { - return Arr::has($values, $field->handle()) - ? $field->newInstance()->fillValue(Arr::get($values, $field->handle())) - : $field->newInstance(); + return $field->newInstance()->setValue(Arr::get($values, $field->handle())); }); - return $this->newInstance()->setFields($fields); + return $this->newInstance()->setFilled($filled)->setFields($fields); } public function values() { - return $this->fields->mapWithKeys(function ($field) { + $values = $this->fields->mapWithKeys(function ($field) { return [$field->handle() => $field->value()]; }); - } - public function validatableValues() - { - return $this->values()->filter(function ($value, $handle) { - return $this->fields->get($handle)->isFilled(); - }); + if ($this->withValidatableValues) { + $values = $values->filter(function ($field, $handle) { + return in_array($handle, $this->filled); + }); + } + + return $values; } public function process() @@ -159,7 +177,7 @@ public function preProcessValidatables() { return $this->newInstance()->setFields( $this->fields->map->preProcessValidatable() - ); + )->withValidatableValues(); } public function augment() diff --git a/src/Fields/Validator.php b/src/Fields/Validator.php index 7f2e1267c4..2e4a4f6e5f 100644 --- a/src/Fields/Validator.php +++ b/src/Fields/Validator.php @@ -96,7 +96,7 @@ public function withReplacements($replacements) public function validate() { return LaravelValidator::validate( - $this->fields->preProcessValidatables()->validatableValues()->all(), + $this->fields->preProcessValidatables()->values()->all(), $this->rules(), $this->customMessages, $this->attributes() diff --git a/src/Fieldtypes/Grid.php b/src/Fieldtypes/Grid.php index 5b98b6db20..9a08f86ecc 100644 --- a/src/Fieldtypes/Grid.php +++ b/src/Fieldtypes/Grid.php @@ -163,8 +163,8 @@ public function extraValidationAttributes(): array $attributes = $this->fields()->validator()->attributes(); return collect($this->field->value())->map(function ($row, $index) use ($attributes) { - return collect($row)->except('_id')->mapWithKeys(function ($value, $handle) use ($attributes, $index) { - return [$this->rowRuleFieldPrefix($index).'.'.$handle => $attributes[$handle] ?? null]; + return collect($attributes)->except('_id')->mapWithKeys(function ($attribute, $handle) use ($index) { + return [$this->rowRuleFieldPrefix($index).'.'.$handle => $attribute]; }); })->reduce(function ($carry, $rules) { return $carry->merge($rules); diff --git a/tests/Fields/FieldsTest.php b/tests/Fields/FieldsTest.php index 2a97950929..622b2a6981 100644 --- a/tests/Fields/FieldsTest.php +++ b/tests/Fields/FieldsTest.php @@ -536,29 +536,67 @@ public function it_adds_values_to_fields() } /** @test */ - public function adding_values_sets_filled_status_on_fields_for_validation() + public function preprocessing_validatables_removes_unfilled_values() { - FieldRepository::shouldReceive('find')->with('one')->andReturnUsing(function () { - return new Field('one', []); - }); - - FieldRepository::shouldReceive('find')->with('two')->andReturnUsing(function () { - return new Field('two', []); - }); - $fields = new Fields([ - ['handle' => 'one', 'field' => 'one'], - ['handle' => 'two', 'field' => 'two'], + ['handle' => 'title', 'field' => ['type' => 'text']], + ['handle' => 'one', 'field' => ['type' => 'text']], + ['handle' => 'two', 'field' => ['type' => 'text']], + ['handle' => 'reppy', 'field' => ['type' => 'replicator', 'sets' => ['replicator_set' => ['fields' => [ + ['handle' => 'title', 'field' => ['type' => 'text']], + ['handle' => 'one', 'field' => ['type' => 'text']], + ['handle' => 'two', 'field' => ['type' => 'text']], + ['handle' => 'griddy_in_reppy', 'field' => ['type' => 'grid', 'fields' => [ + ['handle' => 'title', 'field' => ['type' => 'text']], + ['handle' => 'one', 'field' => ['type' => 'text']], + ['handle' => 'two', 'field' => ['type' => 'text']], + ['handle' => 'bardo_in_griddy_in_reppy', 'field' => ['type' => 'bard', 'sets' => ['bard_set' => ['fields' => [ + ['handle' => 'title', 'field' => ['type' => 'text']], + ['handle' => 'one', 'field' => ['type' => 'text']], + ['handle' => 'two', 'field' => ['type' => 'text']], + ['handle' => 'bardo_in_bardo_in_griddy_in_reppy', 'field' => ['type' => 'bard', 'sets' => ['bard_set_set' => ['fields' => [ + ['handle' => 'title', 'field' => ['type' => 'text']], + ['handle' => 'one', 'field' => ['type' => 'text']], + ['handle' => 'two', 'field' => ['type' => 'text']], + ]]]]], + ]]]]], + ]]], + ]]]]], ]); - $this->assertEquals(['one' => null, 'two' => null], $fields->values()->all()); + $this->assertEquals(['title' => null, 'one' => null, 'two' => null, 'reppy' => null], $fields->values()->all()); + $this->assertEquals([], $fields->preProcessValidatables()->values()->all()); + + $values = $expected = [ + 'title' => 'recursion madness', + 'one' => 'foo', + 'reppy' => [ + ['type' => 'replicator_set', 'two' => 'foo'], + ['type' => 'replicator_set', 'griddy_in_reppy' => [ + ['one' => 'foo'], + ['bardo_in_griddy_in_reppy' => json_encode($bardValues = [ + ['type' => 'set', 'attrs' => ['values' => ['type' => 'bard_set', 'two' => 'foo']]], + ['type' => 'paragraph', 'content' => [['type' => 'text', 'text' => 'foo']]], + ['type' => 'set', 'attrs' => ['type' => 'bard_set', 'values' => ['type' => 'bard_set', 'bardo_in_bardo_in_griddy_in_reppy' => json_encode($doubleNestedBardValues = [ + ['type' => 'set', 'attrs' => ['values' => ['type' => 'bard_set', 'two' => 'foo']]], + ['type' => 'paragraph', 'content' => [['type' => 'text', 'text' => 'foo']]], + ])]]], + ])], + ]], + ], + ]; + + // Calling `addValues()` should track which fields are filled at each level of nesting. + // When we call `preProcessValidatables()`, unfilled values should get removed, in + // order to ensure rules like `sometimes` and `required_if` work at all levels. + $validatableValues = $fields->addValues($values)->preProcessValidatables()->values()->all(); - $fields = $fields->addValues(['one' => 'foo']); + // Bard fields submit JSON values, so we'll replace them with their corresponding PHP array + // values here, since `preProcessValidatables()` will return JSON decoded decoded values. + $expected['reppy'][1]['griddy_in_reppy'][1]['bardo_in_griddy_in_reppy'] = $bardValues; + $expected['reppy'][1]['griddy_in_reppy'][1]['bardo_in_griddy_in_reppy'][2]['attrs']['values']['bardo_in_bardo_in_griddy_in_reppy'] = $doubleNestedBardValues; - $this->assertTrue($fields->get('one')->isFilled()); - $this->assertFalse($fields->get('two')->isFilled()); - $this->assertEquals(['one' => 'foo', 'two' => null], $fields->values()->all()); - $this->assertEquals(['one' => 'foo'], $fields->validatableValues()->all()); + $this->assertEquals($expected, $validatableValues); } /** @test */ diff --git a/tests/Fields/ValidatorTest.php b/tests/Fields/ValidatorTest.php index 707ac0c34f..3eb0684a15 100644 --- a/tests/Fields/ValidatorTest.php +++ b/tests/Fields/ValidatorTest.php @@ -328,7 +328,9 @@ public function it_replaces_this_in_sets() ['type' => 'replicator_set', 'nested_replicator' => [['type' => 'replicator_set', 'nested_replicator' => [['type' => 'replicator_set']]]]], ], 'replicator_with_nested_grid' => [ - ['type' => 'replicator_set', 'nested_grid' => [[]]], + ['type' => 'replicator_set', 'nested_grid' => [ + ['text' => null, 'not_in_blueprint' => 'test'], + ]], ], 'replicator_with_nested_bard' => [ ['type' => 'replicator_set', 'nested_bard' => [['type' => 'set', 'attrs' => ['values' => ['type' => 'bard_set']]]]],