From 086b8e7eb13a0b4da21e5e5c775a43cc082ca2e5 Mon Sep 17 00:00:00 2001 From: Bastian Allgeier Date: Fri, 25 Oct 2024 14:28:30 +0200 Subject: [PATCH 01/15] Move field tests into a common TestCase class --- src/Form/Field.php | 15 +- src/Form/FieldClass.php | 11 +- src/Form/Fields.php | 3 + src/Form/Mixin/Validation.php | 11 +- src/Form/Mixin/Value.php | 2 +- tests/Form/FieldClassTest.php | 638 ++--------- tests/Form/FieldTest.php | 1245 ++------------------- tests/Form/FieldTestCase.php | 1272 ++++++++++++++++++++++ tests/Form/Fields/HeadlineFieldTest.php | 2 +- tests/Form/Fields/InfoFieldTest.php | 2 +- tests/Form/Fields/LinkFieldTest.php | 2 +- tests/Form/Fields/ListFieldTest.php | 2 +- tests/Form/Fields/ObjectFieldTest.php | 2 +- tests/Form/Fields/StructureFieldTest.php | 6 +- 14 files changed, 1520 insertions(+), 1693 deletions(-) create mode 100644 tests/Form/FieldTestCase.php diff --git a/src/Form/Field.php b/src/Form/Field.php index 310f42a096..35725ef834 100644 --- a/src/Form/Field.php +++ b/src/Form/Field.php @@ -8,6 +8,7 @@ use Kirby\Toolkit\A; use Kirby\Toolkit\Component; use Kirby\Toolkit\I18n; +use Kirby\Toolkit\Str; /** * Form Field object that takes a Vue component style @@ -36,7 +37,7 @@ class Field extends Component /** * Parent collection with all fields of the current form */ - protected Fields $siblings; + public Fields $siblings; /** * Registry for all component mixins @@ -67,6 +68,12 @@ public function __construct( } $this->setModel($attrs['model'] ?? null); + $this->setValidate($attrs['validate'] ?? []); + + unset( + $attrs['model'], + $attrs['validate'] + ); // use the type as fallback for the name $attrs['name'] ??= $type; @@ -202,9 +209,7 @@ public static function defaults(): array }, 'label' => function () { /** @var \Kirby\Form\Field $this */ - if ($this->label !== null) { - return $this->model()->toString($this->label); - } + return $this->model()->toString($this->label ?? Str::ucfirst($this->name)); }, 'placeholder' => function () { /** @var \Kirby\Form\Field $this */ @@ -371,8 +376,6 @@ public function toArray(): array { $array = parent::toArray(); - unset($array['model']); - $array['hidden'] = $this->isHidden(); $array['saveable'] = $this->isSaveable(); diff --git a/src/Form/FieldClass.php b/src/Form/FieldClass.php index 3453676422..896ebc42e7 100644 --- a/src/Form/FieldClass.php +++ b/src/Form/FieldClass.php @@ -41,7 +41,7 @@ abstract class FieldClass protected string|null $name; protected string|null $placeholder; protected bool $required; - protected Fields $siblings; + public Fields $siblings; protected mixed $value = null; protected string|null $width; @@ -64,6 +64,7 @@ public function __construct( $this->setTranslate($params['translate'] ?? true); $this->setWhen($params['when'] ?? null); $this->setWidth($params['width'] ?? null); + $this->setValidate($params['validate'] ?? []); if (array_key_exists('value', $params) === true) { $this->fill($params['value']); @@ -127,6 +128,14 @@ public function fill(mixed $value = null): void $this->errors = null; } + /** + * @deprecated 5.0.0 Use `::siblings() instead + */ + public function formFields(): Fields + { + return $this->siblings; + } + /** * Optional help text below the field */ diff --git a/src/Form/Fields.php b/src/Form/Fields.php index 876029d748..5cfea91be0 100644 --- a/src/Form/Fields.php +++ b/src/Form/Fields.php @@ -53,6 +53,9 @@ public function __set(string $name, $field): void $field = Field::factory($field['type'], $field, $this); } + // set the siblings collection + $field->siblings = $this; + parent::__set($field->name(), $field); // reset the errors cache if new fields are added diff --git a/src/Form/Mixin/Validation.php b/src/Form/Mixin/Validation.php index 6b106a071d..5da7852fc3 100644 --- a/src/Form/Mixin/Validation.php +++ b/src/Form/Mixin/Validation.php @@ -22,6 +22,7 @@ trait Validation * An array of all found errors */ protected array|null $errors = null; + protected array $validate = []; /** * Runs all validations and returns an array of @@ -48,10 +49,18 @@ public function isValid(): bool return $this->errors() === []; } + /** + * Set custom validation rules for the field + */ + protected function setValidate(string|array $validate = []): void + { + $this->validate = A::wrap($validate); + } + /** * Runs the validations defined for the field */ - protected function validate(): array + public function validate(): array { $validations = $this->validations(); $value = $this->value(); diff --git a/src/Form/Mixin/Value.php b/src/Form/Mixin/Value.php index 154b77a738..df03fb1e2a 100644 --- a/src/Form/Mixin/Value.php +++ b/src/Form/Mixin/Value.php @@ -55,7 +55,7 @@ public function isEmptyValue(mixed $value = null): bool * - The field is currently empty * - The field is not currently inactive because of a `when` rule */ - protected function needsValue(): bool + public function needsValue(): bool { if ( $this->isSaveable() === false || diff --git a/tests/Form/FieldClassTest.php b/tests/Form/FieldClassTest.php index 27b9c508b2..f819850d40 100644 --- a/tests/Form/FieldClassTest.php +++ b/tests/Form/FieldClassTest.php @@ -2,607 +2,189 @@ namespace Kirby\Form; -use Exception; -use Kirby\Cms\Page; -use Kirby\TestCase; +use Kirby\Cms\ModelWithContent; +use Kirby\Exception\Exception; -class TestField extends FieldClass +class FieldWithApiRoutes extends FieldClass { -} - -class HiddenField extends FieldClass -{ - public function isHidden(): bool + public function routes(): array { - return true; + return FieldClassTest::apiRoutes(); } } -class UnsaveableField extends FieldClass +class FieldWithComputedValue extends FieldClass { - public function isSaveable(): bool + public function computedValue(): string { - return false; + return $this->value . ' computed'; } } -class ValidatedField extends FieldClass +class FieldWithCustomStoreHandler extends FieldClass { - public function validations(): array + public function toStoredValue(bool $default = false): mixed { - return [ - 'minlength', - 'custom' => function ($value) { - if ($value !== 'a') { - throw new Exception('Please enter an a'); - } - } - ]; + return implode(',', $this->value); } } -/** - * @coversDefaultClass \Kirby\Form\FieldClass - */ -class FieldClassTest extends TestCase +class FieldWithDefaultIcon extends FieldClass { - /** - * @covers ::__call - */ - public function test__call() + public function icon(): string|null { - $field = new TestField([ - 'foo' => 'bar' - ]); - - $this->assertSame('bar', $field->foo()); - } - - /** - * @covers ::after - */ - public function testAfter() - { - $field = new TestField(); - $this->assertNull($field->after()); - - $field = new TestField(['after' => 'Test']); - $this->assertSame('Test', $field->after()); - - $field = new TestField(['after' => ['en' => 'Test']]); - $this->assertSame('Test', $field->after()); - } - - /** - * @covers ::api - */ - public function testApi() - { - $field = new TestField(); - $this->assertSame([], $field->api()); - } - - /** - * @covers ::autofocus - */ - public function testAutofocus() - { - $field = new TestField(); - $this->assertFalse($field->autofocus()); - - $field = new TestField(['autofocus' => true]); - $this->assertTrue($field->autofocus()); - } - - /** - * @covers ::before - */ - public function testBefore() - { - $field = new TestField(); - $this->assertNull($field->before()); - - $field = new TestField(['before' => 'Test']); - $this->assertSame('Test', $field->before()); - - $field = new TestField(['before' => ['en' => 'Test']]); - $this->assertSame('Test', $field->before()); - } - - /** - * @covers ::data - */ - public function testData() - { - $field = new TestField(); - $this->assertNull($field->data()); - - // use default value - $field = new TestField(['default' => 'default value']); - $this->assertSame('default value', $field->data(true)); - - // don't use default value - $field = new TestField(['default' => 'default value']); - $this->assertNull($field->data()); - - // use existing value - $field = new TestField(['value' => 'test']); - $this->assertSame('test', $field->data()); - } - - /** - * @covers ::default - */ - public function testDefault() - { - $field = new TestField(); - $this->assertNull($field->default()); - - // simple default value - $field = new TestField(['default' => 'Test']); - $this->assertSame('Test', $field->default()); - - // default value from string template - $field = new TestField([ - 'model' => new Page([ - 'slug' => 'test', - 'content' => [ - 'title' => 'Test title' - ] - ]), - 'default' => '{{ page.title }}' - ]); - - $this->assertSame('Test title', $field->default()); - } - - /** - * @covers ::dialogs - */ - public function testDialogs() - { - $field = new TestField(); - $this->assertSame([], $field->dialogs()); - } - - /** - * @covers ::disabled - * @covers ::isDisabled - */ - public function testDisabled() - { - $field = new TestField(); - $this->assertFalse($field->disabled()); - $this->assertFalse($field->isDisabled()); - - $field = new TestField(['disabled' => true]); - $this->assertTrue($field->disabled()); - $this->assertTrue($field->isDisabled()); - } - - /** - * @covers ::drawers - */ - public function testDrawers() - { - $field = new TestField(); - $this->assertSame([], $field->drawers()); - } - - /** - * @covers ::errors - * @covers ::validate - * @covers ::validations - */ - public function testErrors() - { - $field = new TestField(); - $this->assertSame([], $field->errors()); - - $field = new TestField(['required' => true]); - $this->assertSame(['required' => 'Please enter something'], $field->errors()); - - $field = new ValidatedField(['value' => 'a']); - $this->assertSame([], $field->errors()); - - $field = new ValidatedField(['value' => 'a', 'minlength' => 4]); - $this->assertSame(['minlength' => 'Please enter a longer value. (min. 4 characters)'], $field->errors()); - - $field = new ValidatedField(['value' => 'b']); - $this->assertSame(['custom' => 'Please enter an a'], $field->errors()); + return $this->icon ?? 'test'; } +} - /** - * @covers ::fill - */ - public function testFill() +class FieldWithDialogs extends FieldClass +{ + public function dialogs(): array { - $field = new TestField(); - $this->assertNull($field->value()); - $field->fill('Test value'); - $this->assertSame('Test value', $field->value()); + return FieldClassTest::dialogRoutes(); } +} - /** - * @covers ::isEmpty - * @covers ::isEmptyValue - */ - public function testIsEmpty() +class FieldWithDrawers extends FieldClass +{ + public function drawers(): array { - $field = new TestField(); - $this->assertTrue($field->isEmpty()); - - $field = new TestField(['value' => 'Test']); - $this->assertFalse($field->isEmpty()); + return FieldClassTest::drawerRoutes(); } +} - /** - * @covers ::isEmptyValue - */ - public function testIsEmptyValue() +class FieldWithHiddenFlag extends FieldClass +{ + public function isHidden(): bool { - $field = new TestField(); - - $this->assertTrue($field->isEmptyValue()); - $this->assertTrue($field->isEmptyValue('')); - $this->assertTrue($field->isEmptyValue(null)); - $this->assertTrue($field->isEmptyValue([])); - - $this->assertFalse($field->isEmptyValue(' ')); - $this->assertFalse($field->isEmptyValue(0)); - $this->assertFalse($field->isEmptyValue('0')); + return true; } +} - /** - * @covers ::isHidden - */ - public function testIsHidden() +class FieldWithUnsaveableFlag extends FieldClass +{ + public function isSaveable(): bool { - $field = new TestField(); - $this->assertFalse($field->isHidden()); - - $field = new HiddenField(); - $this->assertTrue($field->isHidden()); + return false; } +} - /** - * @covers ::isInvalid - * @covers ::isValid - */ - public function testInvalid() +class FieldWithValidations extends FieldClass +{ + public function maxlength(): int { - $field = new TestField(); - $this->assertFalse($field->isInvalid()); - - $field = new TestField(['required' => true]); - $this->assertTrue($field->isInvalid()); - - $field = new TestField(['required' => true, 'value' => 'Test']); - $this->assertFalse($field->isInvalid()); + return 5; } - /** - * @covers ::isRequired - * @covers ::required - */ - public function testIsRequired() + public function validations(): array { - $field = new TestField(); - $this->assertFalse($field->isRequired()); - $this->assertFalse($field->required()); - - $field = new TestField(['required' => true]); - $this->assertTrue($field->isRequired()); - $this->assertTrue($field->required()); + return [ + 'maxlength', + 'custom' => function ($value) { + if ($value !== null && $value !== 'a') { + throw new Exception('Please enter an a'); + } + } + ]; } +} - /** - * @covers ::isSaveable - */ - public function testIsSaveable() +class FieldWithProps extends FieldClass +{ + public function type(): string { - $field = new TestField(); - $this->assertTrue($field->isSaveable()); - - $field = new UnsaveableField(); - $this->assertFalse($field->isSaveable()); + return 'test'; } +} - /** - * @covers ::help - */ - public function testHelp() - { - $field = new TestField(); - $this->assertNull($field->help()); - - // regular help - $field = new TestField(['help' => 'Test']); - $this->assertSame('

Test

', $field->help()); - - // translated help - $field = new TestField(['help' => ['en' => 'Test']]); - $this->assertSame('

Test

', $field->help()); - // help from string template - $field = new TestField([ - 'model' => new Page([ - 'slug' => 'test', - 'content' => [ - 'title' => 'Test title' - ] - ]), - 'help' => 'A field for {{ page.title }}' +/** + * @coversDefaultClass \Kirby\Form\FieldClass + */ +class FieldClassTest extends FieldTestCase +{ + public const TMP = KIRBY_TMP_DIR . '/Form.FieldClass'; + + protected function field( + array $props = [], + ModelWithContent|null $model = null + ): Field|FieldClass { + return new FieldWithProps([ + 'model' => $model ?? $this->model, + 'name' => 'test', + ...$props ]); - - $this->assertSame('

A field for Test title

', $field->help()); - } - - /** - * @covers ::icon - */ - public function testIcon() - { - $field = new TestField(); - $this->assertNull($field->icon()); - - $field = new TestField(['icon' => 'Test']); - $this->assertSame('Test', $field->icon()); - } - - /** - * @covers ::id - */ - public function testId() - { - $field = new TestField(); - $this->assertSame('test', $field->id()); - - $field = new TestField(['name' => 'test-id']); - $this->assertSame('test-id', $field->id()); - } - - /** - * @covers ::kirby - */ - public function testKirby() - { - $field = new TestField(); - $this->assertSame(kirby(), $field->kirby()); - } - - /** - * @covers ::label - */ - public function testLabel() - { - $field = new TestField(); - $this->assertSame('Test', $field->label()); - - $field = new TestField(['label' => 'Test']); - $this->assertSame('Test', $field->label()); - - $field = new TestField(['label' => ['en' => 'Test']]); - $this->assertSame('Test', $field->label()); } - /** - * @covers ::model - */ - public function testModel() + protected function fieldWithApiRoutes(): Field|FieldClass { - $field = new TestField(); - $site = site(); - $this->assertIsSite($site, $field->model()); - - $page = new Page(['slug' => 'test']); - $field = new TestField(['model' => $page]); - $this->assertIsPage($page, $field->model()); - } - - /** - * @covers ::name - */ - public function testName() - { - $field = new TestField(); - $this->assertSame('test', $field->name()); - - $field = new TestField(['name' => 'test-name']); - $this->assertSame('test-name', $field->name()); - } - - /** - * @covers ::params - */ - public function testParams() - { - $field = new TestField($params = [ - 'foo' => 'bar', - 'name' => 'Test name', - 'required' => true + return new FieldWithApiRoutes([ + 'model' => $this->model, + 'name' => 'test' ]); - - $this->assertSame($params, $field->params()); } - /** - * @covers ::placeholder - */ - public function testPlaceholder() + protected function fieldWithComputedValue(): Field|FieldClass { - $field = new TestField(); - $this->assertNull($field->placeholder()); - - // regular placeholder - $field = new TestField(['placeholder' => 'Test']); - $this->assertSame('Test', $field->placeholder()); - - // translated placeholder - $field = new TestField(['placeholder' => ['en' => 'Test']]); - $this->assertSame('Test', $field->placeholder()); - - // placeholder from string template - $field = new TestField([ - 'model' => new Page([ - 'slug' => 'test', - 'content' => [ - 'title' => 'Test title' - ] - ]), - 'placeholder' => 'Placeholder for {{ page.title }}' + return new FieldWithComputedValue([ + 'model' => $this->model, + 'name' => 'test' ]); - - $this->assertSame('Placeholder for Test title', $field->placeholder()); } - /** - * @covers ::props - * @covers ::toArray - */ - public function testProps() + protected function fieldWithCustomStoreHandler(): Field|FieldClass { - $field = new TestField($props = [ - 'after' => 'After value', - 'autofocus' => true, - 'before' => 'Before value', - 'default' => 'Default value', - 'disabled' => false, - 'help' => 'Help value', - 'hidden' => false, - 'icon' => 'Icon value', - 'label' => 'Label value', - 'name' => 'name-value', - 'placeholder' => 'Placeholder value', - 'required' => true, - 'saveable' => true, - 'translate' => false, - 'type' => 'test', - 'when' => ['a' => 'b'], - 'width' => '1/2' + return new FieldWithCustomStoreHandler([ + 'model' => $this->model, + 'name' => 'test' ]); - - $props['help'] = '

Help value

'; - - $array = $field->toArray(); - - $this->assertSame($props, $field->props()); - } - - /** - * @covers ::routes - */ - public function testRoutes() - { - $field = new TestField(); - $this->assertSame([], $field->routes()); - } - - /** - * @covers ::save - */ - public function testSave() - { - $field = new TestField(); - $this->assertTrue($field->save()); } - /** - * @covers ::siblings - */ - public function testSiblings() + protected function fieldWithDefaultIcon(): Field|FieldClass { - $field = new TestField(); - $this->assertInstanceOf(Fields::class, $field->siblings()); - $this->assertCount(1, $field->siblings()); - $this->assertSame($field, $field->siblings()->first()); - - $field = new TestField([ - 'siblings' => new Fields([ - new TestField(['name' => 'a']), - new TestField(['name' => 'b']), - ]) + return new FieldWithDefaultIcon([ + 'model' => $this->model, + 'name' => 'test' ]); - - $this->assertCount(2, $field->siblings()); - $this->assertSame('a', $field->siblings()->first()->name()); - $this->assertSame('b', $field->siblings()->last()->name()); - } - - /** - * @covers ::toStoredValue - */ - public function testToStoredValue() - { - $field = new TestField(); - $field->fill('test'); - - $this->assertSame('test', $field->toStoredValue()); } - /** - * @covers ::translate - */ - public function testTranslate() + protected function fieldWithDialogs(): Field|FieldClass { - $field = new TestField(); - $this->assertTrue($field->translate()); - - $field = new TestField(['translate' => false]); - $this->assertFalse($field->translate()); + return new FieldWithDialogs([ + 'model' => $this->model, + 'name' => 'test' + ]); } - /** - * @covers ::type - */ - public function testType() + protected function fieldWithDrawers(): Field|FieldClass { - $field = new TestField(); - $this->assertSame('test', $field->type()); + return new FieldWithDrawers([ + 'model' => $this->model, + 'name' => 'test' + ]); } - /** - * @covers ::value - */ - public function testValue() + protected function fieldWithHiddenFlag(): Field|FieldClass { - $field = new TestField(); - $this->assertNull($field->value()); - - $field = new TestField(['value' => 'Test']); - $this->assertSame('Test', $field->value()); - - $field = new TestField(['default' => 'Default value']); - $this->assertNull($field->value()); - - $field = new TestField(['default' => 'Default value']); - $this->assertSame('Default value', $field->value(true)); - - $field = new UnsaveableField(['value' => 'Test']); - $this->assertNull($field->value()); + return new FieldWithHiddenFlag([ + 'model' => $this->model, + 'name' => 'test' + ]); } - /** - * @covers ::when - */ - public function testWhen() + protected function fieldWithUnsaveableFlag(): Field|FieldClass { - $field = new TestField(); - $this->assertNull($field->when()); - - $field = new TestField(['when' => ['a' => 'test']]); - $this->assertSame(['a' => 'test'], $field->when()); + return new FieldWithUnsaveableFlag([ + 'model' => $this->model, + 'name' => 'test' + ]); } - /** - * @covers ::width - */ - public function testWidth() + protected function fieldWithValidations(): Field|FieldClass { - $field = new TestField(); - $this->assertSame('1/1', $field->width()); - - $field = new TestField(['width' => '1/2']); - $this->assertSame('1/2', $field->width()); + return new FieldWithValidations([ + 'model' => $this->model, + 'name' => 'test', + ]); } } diff --git a/tests/Form/FieldTest.php b/tests/Form/FieldTest.php index 119a58a4c9..2f969723d4 100644 --- a/tests/Form/FieldTest.php +++ b/tests/Form/FieldTest.php @@ -2,25 +2,22 @@ namespace Kirby\Form; -use Kirby\Cms\App; -use Kirby\Cms\Page; +use Kirby\Cms\ModelWithContent; +use Kirby\Exception\Exception; use Kirby\Exception\InvalidArgumentException; -use Kirby\TestCase; /** * @coversDefaultClass \Kirby\Form\Field */ -class FieldTest extends TestCase +class FieldTest extends FieldTestCase { + public const TMP = KIRBY_TMP_DIR . '/Form.Field'; + protected array $originalMixins; public function setUp(): void { - new App([ - 'roots' => [ - 'index' => '/dev/null' - ] - ]); + parent::setUp(); Field::$types = []; @@ -30,1264 +27,216 @@ public function setUp(): void public function tearDown(): void { - Field::$types = []; + parent::tearDown(); + Field::$types = []; Field::$mixins = $this->originalMixins; } - /** - * @covers ::__construct - */ - public function testConstructInvalidType(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Field "foo": The field type "test" does not exist'); - - new Field('test', [ - 'name' => 'foo', - 'type' => 'foo' - ]); - } - - public function testAfter() - { + protected function field( + array $props = [], + ModelWithContent|null $model = null + ): Field|FieldClass { + // create the field type for the test Field::$types = [ 'test' => [] ]; - $page = new Page(['slug' => 'blog']); - - // untranslated - $field = new Field('test', [ - 'model' => $page, - 'after' => 'test' - ]); - - $this->assertSame('test', $field->after()); - $this->assertSame('test', $field->after); - - // translated - $field = new Field('test', [ - 'model' => $page, - 'after' => [ - 'en' => 'en', - 'de' => 'de' - ] - ]); - - $this->assertSame('en', $field->after()); - $this->assertSame('en', $field->after); - - // with query - $field = new Field('test', [ - 'model' => $page, - 'after' => '{{ page.slug }}' + return new Field('test', [ + 'model' => $model ?? $this->model, + ...$props ]); - - $this->assertSame('blog', $field->after()); - $this->assertSame('blog', $field->after); } - /** - * @covers ::api - * @covers ::routes - */ - public function testApi() + protected function fieldWithApiRoutes(): Field|FieldClass { - // no defined as default - Field::$types = [ - 'test' => [] - ]; - - $model = new Page(['slug' => 'test']); - - $field = new Field('test', [ - 'model' => $model, - ]); + $routes = $this->apiRoutes(); - $this->assertSame([], $field->api()); - - $routes = [ - [ - 'pattern' => '/', - 'action' => fn () => 'Hello World' - ] - ]; - - // return simple string Field::$types = [ 'test' => [ 'api' => fn () => $routes ] ]; - $model = new Page(['slug' => 'test']); - - $field = new Field('test', [ - 'model' => $model, + return new Field('test', [ + 'model' => $this->model ]); - - $this->assertSame($routes, $field->api()); } - public function testAutofocus() + protected function fieldWithComputedValue(): Field|FieldClass { Field::$types = [ - 'test' => [] + 'test' => [ + 'computed' => [ + 'computedValue' => fn () => $this->value . ' computed' + ] + ] ]; - $page = new Page(['slug' => 'test']); - - // default autofocus - $field = new Field('test', [ - 'model' => $page, - ]); - - $this->assertFalse($field->autofocus()); - $this->assertFalse($field->autofocus); - - // enabled autofocus - $field = new Field('test', [ - 'model' => $page, - 'autofocus' => true + return new Field('test', [ + 'model' => $this->model ]); - - $this->assertTrue($field->autofocus()); - $this->assertTrue($field->autofocus); } - public function testBefore() + protected function fieldWithCustomStoreHandler(): Field|FieldClass { Field::$types = [ - 'test' => [] - ]; - - $page = new Page(['slug' => 'blog']); - - // untranslated - $field = new Field('test', [ - 'model' => $page, - 'before' => 'test' - ]); - - $this->assertSame('test', $field->before()); - $this->assertSame('test', $field->before); - - // translated - $field = new Field('test', [ - 'model' => $page, - 'before' => [ - 'en' => 'en', - 'de' => 'de' + 'test' => [ + 'save' => function (array $value = []): string { + return implode(',', $value); + } ] - ]); - - $this->assertSame('en', $field->before()); - $this->assertSame('en', $field->before); + ]; - // with query - $field = new Field('test', [ - 'model' => $page, - 'before' => '{{ page.slug }}' + return new Field('test', [ + 'model' => $this->model ]); - - $this->assertSame('blog', $field->before()); - $this->assertSame('blog', $field->before); } - public function testDefault() + protected function fieldWithDefaultIcon(): Field|FieldClass { Field::$types = [ - 'test' => [] + 'test' => [ + 'props' => [ + 'icon' => fn (string $icon = 'test') => $icon + ] + ] ]; - $page = new Page(['slug' => 'blog']); - - // default - $field = new Field('test', [ - 'model' => $page - ]); - - $this->assertNull($field->default()); - $this->assertNull($field->default); - $this->assertNull($field->value()); - $this->assertNull($field->value); - - // specific default - $field = new Field('test', [ - 'model' => $page, - 'default' => 'test' - ]); - - $this->assertSame('test', $field->default()); - $this->assertSame('test', $field->default); - $this->assertSame('test', $field->data(true)); - - // don't overwrite existing values - $field = new Field('test', [ - 'model' => $page, - 'default' => 'test', - 'value' => 'something' - ]); - - $this->assertSame('test', $field->default()); - $this->assertSame('test', $field->default); - $this->assertSame('something', $field->value()); - $this->assertSame('something', $field->value); - $this->assertSame('something', $field->data(true)); - - // with query - $field = new Field('test', [ - 'model' => $page, - 'default' => '{{ page.slug }}' + return new Field('test', [ + 'model' => $this->model ]); - - $this->assertSame('blog', $field->default()); - $this->assertSame('blog', $field->default); - $this->assertSame('blog', $field->data(true)); } - /** - * @covers ::dialogs - */ - public function testDialogs() + protected function fieldWithDialogs(): Field|FieldClass { - // no defined as default - Field::$types = [ - 'test' => [] - ]; - - $model = new Page(['slug' => 'test']); - - $field = new Field('test', [ - 'model' => $model, - ]); - - $this->assertSame([], $field->dialogs()); - - // test dialogs - $routes = [ - [ - 'pattern' => 'foo', - 'load' => function () { - }, - 'submit' => function () { - } - ] - ]; + $routes = $this->dialogRoutes(); - // return routes Field::$types = [ 'test' => [ 'dialogs' => fn () => $routes ] ]; - $field = new Field('test', [ - 'model' => $model, + return new Field('test', [ + 'model' => $this->model ]); - - $this->assertSame($routes, $field->dialogs()); } - /** - * @covers ::drawers - */ - public function testDrawers() + protected function fieldWithDrawers(): Field|FieldClass { - // no defined as default - Field::$types = [ - 'test' => [] - ]; - - $model = new Page(['slug' => 'test']); - $field = new Field('test', [ - 'model' => $model, - ]); - - $this->assertSame([], $field->drawers()); - - // test drawers - $routes = [ - [ - 'pattern' => 'foo', - 'load' => function () { - }, - 'submit' => function () { - } - ] - ]; + $routes = $this->drawerRoutes(); - // return routes Field::$types = [ 'test' => [ 'drawers' => fn () => $routes ] ]; - $field = new Field('test', [ - 'model' => $model, - ]); - - $this->assertSame($routes, $field->drawers()); - } - - /** - * @covers ::errors - */ - public function testErrors() - { - Field::$types = [ - 'test' => [] - ]; - - $page = new Page(['slug' => 'test']); - - // default - $field = new Field('test', [ - 'model' => $page, - ]); - - $this->assertSame([], $field->errors()); - - // required - $field = new Field('test', [ - 'model' => $page, - 'required' => true + return new Field('test', [ + 'model' => $this->model ]); - - $expected = [ - 'required' => 'Please enter something', - ]; - - $this->assertSame($expected, $field->errors()); } - /** - * @covers ::fill - */ - public function testFill() + protected function fieldWithHiddenFlag(): Field|FieldClass { Field::$types = [ 'test' => [ - 'computed' => [ - 'computedValue' => fn () => $this->value . ' computed' - ] + 'hidden' => true ] ]; - $page = new Page(['slug' => 'test']); - - $field = new Field('test', [ - 'model' => $page, - 'value' => 'test' + return new Field('test', [ + 'model' => $this->model ]); - - $this->assertSame('test', $field->value()); - $this->assertSame('test', $field->value); - $this->assertSame('test computed', $field->computedValue()); - - $field->fill('test2'); - - $this->assertSame('test2', $field->value()); - $this->assertSame('test2', $field->value); - $this->assertSame('test2 computed', $field->computedValue()); } - public function testHelp() + protected function fieldWithUnsaveableFlag(): Field|FieldClass { Field::$types = [ - 'test' => [] + 'test' => [ + 'save' => false + ] ]; - $page = new Page(['slug' => 'test']); - - // untranslated - $field = new Field('test', [ - 'model' => $page, - 'help' => 'test' - ]); - - $this->assertSame('

test

', $field->help()); - $this->assertSame('

test

', $field->help); - - // translated - $field = new Field('test', [ - 'model' => $page, - 'help' => [ - 'en' => 'en', - 'de' => 'de' - ] + return new Field('test', [ + 'model' => $this->model ]); - - $this->assertSame('

en

', $field->help()); - $this->assertSame('

en

', $field->help); } - public function testIcon() + protected function fieldWithValidations(): Field|FieldClass { - Field::$types = [ - 'test' => [] - ]; - - $page = new Page(['slug' => 'test']); - - // default - $field = new Field('test', [ - 'model' => $page, - ]); - - $this->assertNull($field->icon()); - $this->assertNull($field->icon); - - // specific icon - $field = new Field('test', [ - 'model' => $page, - 'icon' => 'test' - ]); - - $this->assertSame('test', $field->icon()); - $this->assertSame('test', $field->icon); - Field::$types = [ 'test' => [ - 'props' => [ - 'icon' => fn (string $icon = 'test') => $icon + 'validations' => [ + 'maxlength', + 'custom' => function ($value) { + if ($value !== null && $value !== 'a') { + throw new Exception('Please enter an a'); + } + } ] ] ]; - // prop default - $field = new Field('test', [ - 'model' => $page, + return new Field('test', [ + 'model' => $this->model, + 'maxlength' => 5 ]); - - $this->assertSame('test', $field->icon()); - $this->assertSame('test', $field->icon); - } - - public static function emptyValuesProvider(): array - { - return [ - ['', true], - [null, true], - [[], true], - [0, false], - ['0', false] - ]; } /** - * @covers ::isDisabled + * @covers ::__construct */ - public function testDisabled() + public function testConstructInvalidType(): void { - Field::$types = [ - 'test' => [] - ]; - - $page = new Page(['slug' => 'test']); - - // default state - $field = new Field('test', [ - 'model' => $page - ]); - - $this->assertFalse($field->disabled()); - $this->assertFalse($field->disabled); - $this->assertFalse($field->isDisabled()); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Field "foo": The field type "test" does not exist'); - // disabled - $field = new Field('test', [ - 'model' => $page, - 'disabled' => true + new Field('test', [ + 'name' => 'foo', + 'type' => 'foo' ]); - - $this->assertTrue($field->disabled()); - $this->assertTrue($field->disabled); - $this->assertTrue($field->isDisabled()); } - /** - * @covers ::isEmpty - * @covers ::isEmptyValue - * @dataProvider emptyValuesProvider - */ - public function testIsEmpty($value, $expected) + public function testMixinMin() { - Field::$types = [ - 'test' => [] - ]; - - $page = new Page(['slug' => 'test']); - - $field = new Field('test', [ - 'model' => $page, - 'value' => $value - ]); - - $this->assertSame($expected, $field->isEmpty()); - $this->assertSame($expected, $field->isEmptyValue($value)); - } + Field::$mixins['min'] = include kirby()->root('kirby') . '/config/fields/mixins/min.php'; - /** - * @covers ::isHidden - */ - public function testIsHidden() - { - // default Field::$types = [ - 'test' => [] + 'test' => ['mixins' => ['min']] ]; - $page = new Page(['slug' => 'test']); - $field = new Field('test', [ - 'model' => $page, + 'model' => $this->model, ]); - $this->assertFalse($field->isHidden()); - - // hidden - Field::$types = [ - 'test' => [ - 'hidden' => true - ] - ]; + $this->assertFalse($field->isRequired()); + $this->assertNull($field->min()); $field = new Field('test', [ - 'model' => $page, + 'model' => $this->model, + 'min' => 5 ]); - $this->assertTrue($field->isHidden()); - } - - /** - * @covers ::isInvalid - * @covers ::isValid - */ - public function testIsInvalidOrValid() - { - Field::$types = [ - 'test' => [] - ]; - - $page = new Page(['slug' => 'test']); + $this->assertTrue($field->isRequired()); + $this->assertSame(5, $field->min()); - // default $field = new Field('test', [ - 'model' => $page, + 'model' => $this->model, + 'required' => true ]); - $this->assertTrue($field->isValid()); - $this->assertFalse($field->isInvalid()); + $this->assertTrue($field->isRequired()); + $this->assertSame(1, $field->min()); - // required $field = new Field('test', [ - 'model' => $page, - 'required' => true - ]); - - $this->assertFalse($field->isValid()); - $this->assertTrue($field->isInvalid()); - } - - /** - * @covers ::isRequired - */ - public function testIsRequired() - { - Field::$types = [ - 'test' => [] - ]; - - $page = new Page(['slug' => 'test']); - - $field = new Field('test', [ - 'model' => $page, - ]); - - $this->assertFalse($field->isRequired()); - - $field = new Field('test', [ - 'model' => $page, - 'required' => true - ]); - - $this->assertTrue($field->isRequired()); - } - - /** - * @covers ::isSaveable - * @covers ::save - */ - public function testIsSaveable() - { - Field::$types = [ - 'store-me' => [ - 'save' => true - ], - 'dont-store-me' => [ - 'save' => false - ] - ]; - - $page = new Page(['slug' => 'test']); - - $a = new Field('store-me', [ - 'model' => $page - ]); - - $this->assertTrue($a->isSaveable()); - $this->assertTrue($a->save()); - - $b = new Field('dont-store-me', [ - 'model' => $page - ]); - - $this->assertFalse($b->isSaveable()); - $this->assertFalse($b->save()); - } - - /** - * @covers ::kirby - */ - public function testKirby() - { - Field::$types = [ - 'test' => [] - ]; - - $field = new Field('test', [ - 'model' => $model = new Page(['slug' => 'test']) - ]); - - $this->assertSame($model->kirby(), $field->kirby()); - } - - public function testLabel() - { - Field::$types = [ - 'test' => [] - ]; - - $page = new Page(['slug' => 'blog']); - - // untranslated - $field = new Field('test', [ - 'model' => $page, - 'label' => 'test' - ]); - - $this->assertSame('test', $field->label()); - $this->assertSame('test', $field->label); - - // translated - $field = new Field('test', [ - 'model' => $page, - 'label' => [ - 'en' => 'en', - 'de' => 'de' - ] - ]); - - $this->assertSame('en', $field->label()); - $this->assertSame('en', $field->label); - - // with query - $field = new Field('test', [ - 'model' => $page, - 'label' => '{{ page.slug }}' - ]); - - $this->assertSame('blog', $field->label()); - $this->assertSame('blog', $field->label); - } - - public function testMixinMin() - { - Field::$mixins['min'] = include kirby()->root('kirby') . '/config/fields/mixins/min.php'; - - Field::$types = [ - 'test' => ['mixins' => ['min']] - ]; - - $page = new Page(['slug' => 'test']); - - $field = new Field('test', [ - 'model' => $page, - ]); - - $this->assertFalse($field->isRequired()); - $this->assertNull($field->min()); - - $field = new Field('test', [ - 'model' => $page, - 'min' => 5 + 'model' => $this->model, + 'required' => true, + 'min' => 5 ]); $this->assertTrue($field->isRequired()); $this->assertSame(5, $field->min()); - - $field = new Field('test', [ - 'model' => $page, - 'required' => true - ]); - - $this->assertTrue($field->isRequired()); - $this->assertSame(1, $field->min()); - - $field = new Field('test', [ - 'model' => $page, - 'required' => true, - 'min' => 5 - ]); - - $this->assertTrue($field->isRequired()); - $this->assertSame(5, $field->min()); - } - - /** - * @covers ::model - */ - public function testModel() - { - Field::$types = [ - 'test' => [] - ]; - - $field = new Field('test', [ - 'model' => $model = new Page(['slug' => 'test']) - ]); - - $this->assertSame($model, $field->model()); - } - - public function testName() - { - Field::$types = [ - 'test' => [] - ]; - - // no specific name. type should be used - $field = new Field('test', [ - 'model' => $model = new Page(['slug' => 'test']) - ]); - - $this->assertSame('test', $field->name()); - - // specific name - $field = new Field('test', [ - 'model' => $model = new Page(['slug' => 'test']), - 'name' => 'mytest' - ]); - - $this->assertSame('mytest', $field->name()); - } - - /** - * @covers ::needsValue - * @covers ::errors - */ - public function testNeedsValue() - { - $page = new Page(['slug' => 'test']); - - Field::$types = [ - 'foo' => [], - 'bar' => [], - 'baz' => [], - ]; - - $fields = new Fields([ - 'foo' => [ - 'type' => 'foo', - 'model' => $page, - 'value' => 'a' - ], - 'bar' => [ - 'type' => 'bar', - 'model' => $page, - 'value' => 'b' - ], - 'baz' => [ - 'type' => 'baz', - 'model' => $page, - 'value' => 'c' - ] - ]); - - // default - $field = new Field('foo', [ - 'model' => $page, - ]); - - $this->assertSame([], $field->errors()); - - // passed (simple) - // 'bar' is required if 'foo' value is 'x' - $field = new Field('bar', [ - 'model' => $page, - 'required' => true, - 'when' => [ - 'foo' => 'x' - ] - ], $fields); - - $this->assertSame([], $field->errors()); - - // passed (multiple conditions without any match) - // 'baz' is required if 'foo' value is 'x' and 'bar' value is 'y' - $field = new Field('baz', [ - 'model' => $page, - 'required' => true, - 'when' => [ - 'foo' => 'x', - 'bar' => 'y' - ] - ], $fields); - - $this->assertSame([], $field->errors()); - - // passed (multiple conditions with single match) - // 'baz' is required if 'foo' value is 'a' and 'bar' value is 'y' - $field = new Field('baz', [ - 'model' => $page, - 'required' => true, - 'when' => [ - 'foo' => 'a', - 'bar' => 'y' - ] - ], $fields); - - $this->assertSame([], $field->errors()); - - // failed (simple) - // 'bar' is required if 'foo' value is 'a' - $field = new Field('bar', [ - 'model' => $page, - 'required' => true, - 'when' => [ - 'foo' => 'a' - ] - ], $fields); - - $expected = [ - 'required' => 'Please enter something', - ]; - - $this->assertSame($expected, $field->errors()); - - // failed (multiple conditions) - // 'baz' is required if 'foo' value is 'a' and 'bar' value is 'b' - $field = new Field('baz', [ - 'model' => $page, - 'required' => true, - 'when' => [ - 'foo' => 'a', - 'bar' => 'b' - ] - ], $fields); - - $this->assertSame($expected, $field->errors()); - } - - public function testPlaceholder() - { - Field::$types = [ - 'test' => [] - ]; - - $page = new Page(['slug' => 'blog']); - - // untranslated - $field = new Field('test', [ - 'model' => $page, - 'placeholder' => 'test' - ]); - - $this->assertSame('test', $field->placeholder()); - $this->assertSame('test', $field->placeholder); - - // translated - $field = new Field('test', [ - 'model' => $page, - 'placeholder' => [ - 'en' => 'en', - 'de' => 'de' - ] - ]); - - $this->assertSame('en', $field->placeholder()); - $this->assertSame('en', $field->placeholder); - - // with query - $field = new Field('test', [ - 'model' => $page, - 'placeholder' => '{{ page.slug }}' - ]); - - $this->assertSame('blog', $field->placeholder()); - $this->assertSame('blog', $field->placeholder); - } - - /** - * @covers ::next - * @covers ::prev - * @covers ::siblingsCollection - */ - public function testPrevNext() - { - Field::$types = [ - 'test' => [] - ]; - - $model = new Page(['slug' => 'test']); - - $siblings = new Fields([ - [ - 'type' => 'test', - 'name' => 'a' - ], - [ - 'type' => 'test', - 'name' => 'b' - ] - ], $model); - - $this->assertNull($siblings->first()->prev()); - $this->assertNull($siblings->last()->next()); - $this->assertSame('b', $siblings->first()->next()->name()); - $this->assertSame('a', $siblings->last()->prev()->name()); - } - - /** - * @covers ::siblings - * @covers ::formFields - */ - public function testSiblings() - { - Field::$types = [ - 'test' => [] - ]; - - $model = new Page(['slug' => 'test']); - - $field = new Field('test', [ - 'model' => $model, - ]); - - $this->assertInstanceOf(Fields::class, $field->siblings()); - $this->assertInstanceOf(Fields::class, $field->formFields()); - $this->assertCount(1, $field->siblings()); - $this->assertCount(1, $field->formFields()); - $this->assertSame($field, $field->siblings()->first()); - $this->assertSame($field, $field->formFields()->first()); - - $field = new Field( - type: 'test', - attrs: [ - 'model' => $model, - ], - siblings: new Fields([ - new Field('test', [ - 'model' => $model, - 'name' => 'a' - ]), - new Field('test', [ - 'model' => $model, - 'name' => 'b' - ]), - ]) - ); - - $this->assertCount(2, $field->siblings()); - $this->assertCount(2, $field->formFields()); - $this->assertSame('a', $field->siblings()->first()->name()); - $this->assertSame('a', $field->formFields()->first()->name()); - $this->assertSame('b', $field->siblings()->last()->name()); - $this->assertSame('b', $field->formFields()->last()->name()); - } - - /** - * @covers ::toArray - */ - public function testToArray() - { - Field::$types = [ - 'test' => [ - 'props' => [ - 'foo' => fn ($foo) => $foo - ] - ] - ]; - - $field = new Field('test', [ - 'model' => $model = new Page(['slug' => 'test']), - 'foo' => 'bar' - ]); - - $array = $field->toArray(); - - $this->assertSame('test', $array['name']); - $this->assertSame('test', $array['type']); - $this->assertSame('bar', $array['foo']); - $this->assertSame('1/1', $array['width']); - - $this->assertArrayNotHasKey('model', $array); - } - - /** - * @covers ::toFormValue - * @covers ::value - */ - public function testToFormValue() - { - Field::$types['test'] = []; - - $field = new Field('test'); - $this->assertNull($field->toFormValue()); - $this->assertNull($field->value()); - - $field = new Field('test', ['value' => 'Test']); - $this->assertSame('Test', $field->toFormValue()); - $this->assertSame('Test', $field->value()); - - $field = new Field('test', ['default' => 'Default value']); - $this->assertNull($field->toFormValue()); - $this->assertNull($field->value()); - - $field = new Field('test', ['default' => 'Default value']); - $this->assertSame('Default value', $field->toFormValue(true)); - $this->assertSame('Default value', $field->value(true)); - - Field::$types['test'] = [ - 'save' => false - ]; - - $field = new Field('test', ['value' => 'Test']); - $this->assertNull($field->toFormValue()); - $this->assertNull($field->value()); - } - - /** - * @covers ::toStoredValue - * @covers ::data - */ - public function testToStoredValue() - { - Field::$types = [ - 'test' => [ - 'props' => [ - 'value' => fn ($value) => $value - ], - 'save' => fn ($value) => implode(', ', $value) - ] - ]; - - $page = new Page(['slug' => 'test']); - - $field = new Field('test', [ - 'model' => $page, - 'value' => ['a', 'b', 'c'] - ]); - - $this->assertSame('a, b, c', $field->toStoredValue()); - $this->assertSame('a, b, c', $field->data()); - } - - /** - * @covers ::toStoredValue - * @covers ::data - */ - public function testToStoredValueWhenUnsaveable() - { - Field::$types = [ - 'test' => [ - 'save' => false - ] - ]; - - $model = new Page(['slug' => 'test']); - - $field = new Field('test', [ - 'model' => $model, - 'value' => 'something' - ]); - - $this->assertNull($field->toStoredValue()); - $this->assertNull($field->data()); - } - - /** - * @covers ::validate - * @covers ::validations - * @covers ::errors - */ - public function testValidate() - { - Field::$types = [ - 'test' => [] - ]; - - $page = new Page(['slug' => 'test']); - - // default - $field = new Field('test', [ - 'model' => $page, - 'validate' => [ - 'integer' - ], - ]); - - $this->assertSame([], $field->errors()); - - // required - $field = new Field('test', [ - 'model' => $page, - 'required' => true, - 'validate' => [ - 'integer' - ], - ]); - - $expected = [ - 'required' => 'Please enter something', - 'integer' => 'Please enter a valid integer', - ]; - - $this->assertSame($expected, $field->errors()); - - // invalid - $field = new Field('test', [ - 'model' => $page, - 'value' => 'abc', - 'validate' => [ - 'integer' - ], - ]); - - $expected = [ - 'integer' => 'Please enter a valid integer', - ]; - - $this->assertSame($expected, $field->errors()); - } - - /** - * @covers ::validate - * @covers ::validations - * @covers ::isValid - */ - public function testValidateByAttr() - { - Field::$types = [ - 'test' => [] - ]; - - $model = new Page(['slug' => 'test']); - - // with simple string validation - $field = new Field('test', [ - 'model' => $model, - 'value' => 'https://getkirby.com', - 'validate' => 'url' - ]); - $this->assertTrue($field->isValid()); - - $field = new Field('test', [ - 'model' => $model, - 'value' => 'definitely not a URL', - 'validate' => 'url' - ]); - $this->assertFalse($field->isValid()); - - // with an array of validators - $field = new Field('test', [ - 'model' => $model, - 'value' => 'thisIsATest', - 'validate' => [ - 'startsWith' => 'this', - 'alpha' - ] - ]); - $this->assertTrue($field->isValid()); - - $field = new Field('test', [ - 'model' => $model, - 'value' => 'thisIsATest', - 'validate' => [ - 'startsWith' => 'that', - 'alpha' - ] - ]); - $this->assertFalse($field->isValid()); - - $field = new Field('test', [ - 'model' => $model, - 'value' => 'thisIsA123', - 'validate' => [ - 'startsWith' => 'this', - 'alpha' - ] - ]); - $this->assertFalse($field->isValid()); - } - - /** - * @covers ::validate - * @covers ::validations - * @covers ::errors - * @covers ::isValid - */ - public function testValidateWithCustomValidator() - { - Field::$types = [ - 'test' => [ - 'validations' => [ - 'test' => function ($value) { - throw new InvalidArgumentException( - message: 'Invalid value: ' . $value - ); - } - ] - ] - ]; - - $model = new Page(['slug' => 'test']); - - $field = new Field('test', [ - 'model' => $model, - 'value' => 'abc' - ]); - - $this->assertFalse($field->isValid()); - $this->assertSame(['test' => 'Invalid value: abc'], $field->errors()); - } - - public function testWidth() - { - Field::$types = [ - 'test' => [] - ]; - - $page = new Page(['slug' => 'test']); - - // default width - $field = new Field('test', [ - 'model' => $page, - ]); - - $this->assertSame('1/1', $field->width()); - $this->assertSame('1/1', $field->width); - - // specific width - $field = new Field('test', [ - 'model' => $page, - 'width' => '1/2' - ]); - - $this->assertSame('1/2', $field->width()); - $this->assertSame('1/2', $field->width); } } diff --git a/tests/Form/FieldTestCase.php b/tests/Form/FieldTestCase.php new file mode 100644 index 0000000000..5447ae98a2 --- /dev/null +++ b/tests/Form/FieldTestCase.php @@ -0,0 +1,1272 @@ +setUpSingleLanguage([ + 'children' => [ + [ + 'slug' => 'test' + ] + ] + ]); + + $this->model = $this->app->page('test'); + $this->setUpTmp(); + } + + public function tearDown(): void + { + App::destroy(); + $this->tearDownTmp(); + } + + public static function apiRoutes(): array + { + return [ + [ + 'pattern' => '/', + 'action' => fn () => 'Hello world', + ] + ]; + } + + public static function dialogRoutes(): array + { + return [ + [ + 'pattern' => '/', + 'load' => fn () => 'loaded', + 'submit' => fn () => 'submitted' + ] + ]; + } + + public static function drawerRoutes(): array + { + return [ + [ + 'pattern' => '/', + 'load' => fn () => 'loaded', + 'submit' => fn () => 'submitted' + ] + ]; + } + + abstract protected function field( + array $props = [], + ModelWithContent|null $model = null + ): Field|FieldClass; + + abstract protected function fieldWithApiRoutes(): Field|FieldClass; + abstract protected function fieldWithComputedValue(): Field|FieldClass; + abstract protected function fieldWithCustomStoreHandler(): Field|FieldClass; + abstract protected function fieldWithDefaultIcon(): Field|FieldClass; + abstract protected function fieldWithDialogs(): Field|FieldClass; + abstract protected function fieldWithDrawers(): Field|FieldClass; + abstract protected function fieldWithHiddenFlag(): Field|FieldClass; + abstract protected function fieldWithUnsaveableFlag(): Field|FieldClass; + + /** + * @covers ::after + */ + public function testAfter() + { + $field = $this->field( + props: [ + 'after' => 'test' + ] + ); + + $this->assertSame('test', $field->after()); + } + + /** + * @covers ::after + */ + public function testAfterWhenNotSet() + { + $field = $this->field(); + $this->assertNull($field->after()); + } + + /** + * @covers ::after + */ + public function testAfterMultiLanguage() + { + $this->setUpMultiLanguage(); + + $field = $this->field( + props: [ + 'after' => [ + 'en' => 'en', + 'de' => 'de', + ] + ] + ); + + $this->assertSame('en', $field->after()); + } + + /** + * @covers ::after + */ + public function testAfterWithQuery() + { + $field = $this->field( + props: [ + 'after' => '{{ page.slug }}', + ], + model: new Page(['slug' => 'blog']) + ); + + $this->assertSame('blog', $field->after()); + } + + /** + * @covers ::api + * @covers ::routes + */ + public function testApi() + { + $field = $this->fieldWithApiRoutes(); + $route = $field->api()[0]; + $expected = $this->apiRoutes()[0]; + + $this->assertSame($expected['pattern'], $route['pattern']); + $this->assertSame($expected['action'](), $route['action']()); + } + + /** + * @covers ::api + * @covers ::routes + */ + public function testApiWithoutRoutes() + { + $field = $this->field(); + $this->assertSame([], $field->api()); + } + + /** + * @covers ::autofocus + */ + public function testAutofocus() + { + $field = $this->field(); + $this->assertFalse($field->autofocus()); + + $field = $this->field(props: ['autofocus' => true]); + $this->assertTrue($field->autofocus()); + } + + /** + * @covers ::before + */ + public function testBefore() + { + $field = $this->field( + props: [ + 'before' => 'test' + ] + ); + + $this->assertSame('test', $field->before()); + } + + /** + * @covers ::before + */ + public function testBeforeWhenNotSet() + { + $field = $this->field(); + $this->assertNull($field->before()); + } + + /** + * @covers ::before + */ + public function testBeforeMultiLanguage() + { + $this->setUpMultiLanguage(); + + $field = $this->field( + props: [ + 'before' => [ + 'en' => 'en', + 'de' => 'de', + ] + ] + ); + + $this->assertSame('en', $field->before()); + } + + /** + * @covers ::before + */ + public function testBeforeWithQuery() + { + $field = $this->field( + props: [ + 'before' => '{{ page.slug }}', + ] + ); + + $this->assertSame($this->model->slug(), $field->before()); + } + + /** + * @covers ::default + */ + public function testDefault() + { + $field = $this->field( + props: [ + 'default' => 'test' + ] + ); + + $this->assertSame('test', $field->default()); + } + + /** + * @covers ::default + */ + public function testDefaultWhenNotSet() + { + $field = $this->field(); + $this->assertNull($field->default()); + } + + /** + * @covers ::default + */ + public function testDefaultWithQuery() + { + $field = $this->field( + props: [ + 'default' => '{{ page.slug }}', + ] + ); + + $this->assertSame($this->model->slug(), $field->default()); + } + + /** + * @covers ::dialogs + */ + public function testDialogs() + { + $field = $this->fieldWithDialogs(); + $route = $field->dialogs()[0]; + $expected = $this->dialogRoutes()[0]; + + $this->assertSame($expected['pattern'], $route['pattern']); + $this->assertSame($expected['load'](), $route['load']()); + $this->assertSame($expected['submit'](), $route['submit']()); + } + + /** + * @covers ::dialogs + */ + public function testDialogsWhenNotSet() + { + $field = $this->field(); + $this->assertSame([], $field->dialogs()); + } + + /** + * @covers ::disabled + */ + public function testDisabled() + { + $field = $this->field( + props: [ + 'disabled' => true + ] + ); + + $this->assertTrue($field->disabled()); + } + + /** + * @covers ::disabled + */ + public function testDisabledWhenNotSet() + { + $field = $this->field(); + $this->assertFalse($field->disabled()); + } + + /** + * @covers ::drawers + */ + public function testDrawers() + { + $field = $this->fieldWithDrawers(); + $route = $field->drawers()[0]; + $expected = $this->drawerRoutes()[0]; + + $this->assertSame($expected['pattern'], $route['pattern']); + $this->assertSame($expected['load'](), $route['load']()); + $this->assertSame($expected['submit'](), $route['submit']()); + } + + /** + * @covers ::drawers + */ + public function testDrawersWhenNotSet() + { + $field = $this->field(); + $this->assertSame([], $field->drawers()); + } + + /** + * @covers ::errors + */ + public function testErrors() + { + $field = $this->field(); + $this->assertSame([], $field->errors()); + } + + /** + * @covers ::errors + */ + public function testErrorsWhenRequired() + { + $field = $this->field( + props: [ + 'required' => true + ] + ); + + $this->assertSame(['required' => 'Please enter something'], $field->errors()); + } + + /** + * @covers ::fill + */ + public function testFill() + { + $field = $this->field(); + $this->assertNull($field->value()); + + $field->fill('Test value'); + $this->assertSame('Test value', $field->value()); + } + + /** + * @covers ::fill + */ + public function testFillWithComputedValue() + { + $field = $this->fieldWithComputedValue(); + $this->assertSame(' computed', $field->computedValue()); + + $field->fill('Test value'); + $this->assertSame('Test value computed', $field->computedValue()); + + $field->fill('Test value 2'); + $this->assertSame('Test value 2 computed', $field->computedValue()); + } + + /** + * @covers ::formFields + */ + public function testFormFields() + { + $field = $this->field(); + + $this->assertInstanceOf(Fields::class, $field->formFields()); + $this->assertSame($field->formFields(), $field->siblings()); + } + + /** + * @covers ::help + */ + public function testHelp() + { + $field = $this->field( + props: [ + 'help' => 'Test' + ] + ); + + $this->assertSame('

Test

', $field->help()); + } + + /** + * @covers ::help + */ + public function testHelpWhenNotSet() + { + $field = $this->field(); + $this->assertNull($field->help()); + } + + /** + * @covers ::help + */ + public function testHelpMultilang() + { + $field = $this->field( + props: [ + 'help' => [ + 'en' => 'en', + 'de' => 'de' + ] + ] + ); + + $this->assertSame('

en

', $field->help()); + } + + /** + * @covers ::icon + */ + public function testIcon() + { + $field = $this->field( + props: [ + 'icon' => 'test' + ] + ); + + $this->assertSame('test', $field->icon()); + } + + /** + * @covers ::icon + */ + public function testIconDefault() + { + $field = $this->fieldWithDefaultIcon(); + $this->assertSame('test', $field->icon()); + } + + /** + * @covers ::icon + */ + public function testIconWhenNotSet() + { + $field = $this->field(); + $this->assertNull($field->icon()); + } + + public static function emptyValuesProvider(): array + { + return [ + ['', true], + [null, true], + [[], true], + [0, false], + ['0', false] + ]; + } + + /** + * @covers ::isActive + */ + public function testIsActive() + { + $a = $this->field( + props: [ + 'name' => 'a' + ] + ); + + $b = $this->field( + props: [ + 'name' => 'b', + 'when' => [ + 'a' => 'test' + ] + ] + ); + + // attach siblings, otherwise the when queries won't work + new Fields([ + $a, + $b + ]); + + $this->assertTrue($a->isActive()); + $this->assertFalse($b->isActive()); + + $a->fill('test'); + + $this->assertTrue($a->isActive()); + $this->assertTrue($b->isActive()); + } + + /** + * @covers ::isActive + */ + public function testIsActiveWithTwoRequirements() + { + $a = $this->field( + props: [ + 'name' => 'a' + ] + ); + + $b = $this->field( + props: [ + 'name' => 'b' + ] + ); + + $c = $this->field( + props: [ + 'name' => 'c', + 'when' => [ + 'a' => 'test a', + 'b' => 'test b' + ] + ] + ); + + // attach siblings, otherwise the when queries won't work + new Fields([ + $a, + $b, + $c + ]); + + $this->assertTrue($a->isActive()); + $this->assertTrue($b->isActive()); + $this->assertFalse($c->isActive()); + + $a->fill('test a'); + + $this->assertTrue($a->isActive()); + $this->assertTrue($b->isActive()); + $this->assertFalse($c->isActive()); + + $b->fill('test b'); + + $this->assertTrue($a->isActive()); + $this->assertTrue($b->isActive()); + $this->assertTrue($c->isActive()); + } + + /** + * @covers ::isEmpty + * @covers ::isEmptyValue + * @dataProvider emptyValuesProvider + */ + public function testIsEmpty($value, $expected) + { + $field = $this->field( + props: [ + 'value' => $value + ] + ); + + $this->assertSame($expected, $field->isEmpty()); + $this->assertSame($expected, $field->isEmptyValue($value)); + } + + /** + * @covers ::isHidden + */ + public function testIsHidden() + { + $field = $this->field(); + $this->assertFalse($field->isHidden()); + } + + /** + * @covers ::isHidden + */ + public function testIsHiddenWhenFieldIsHidden() + { + $field = $this->fieldWithHiddenFlag(); + $this->assertTrue($field->isHidden()); + } + + /** + * @covers ::isInvalid + */ + public function testIsInvalid() + { + $field = $this->field( + props: [ + 'required' => true + ] + ); + + $this->assertTrue($field->isInvalid()); + } + + /** + * @covers ::isInvalid + */ + public function testIsInvalidWhenValid() + { + $field = $this->field(); + $this->assertFalse($field->isInvalid()); + } + + /** + * @covers ::isRequired + */ + public function testIsRequired() + { + $field = $this->field( + props: [ + 'required' => true + ] + ); + + $this->assertTrue($field->isRequired()); + } + + /** + * @covers ::isRequired + */ + public function testIsRequiredWhenNotRequired() + { + $field = $this->field(); + $this->assertFalse($field->isRequired()); + } + + /** + * @covers ::isSaveable + * @covers ::save + */ + public function testIsSaveable() + { + $field = $this->field(); + $this->assertTrue($field->isSaveable()); + $this->assertTrue($field->save()); + } + + /** + * @covers ::isSaveable + * @covers ::save + */ + public function testIsSaveableWhenNotSaveable() + { + $field = $this->fieldWithUnsaveableFlag(); + $this->assertFalse($field->isSaveable()); + $this->assertFalse($field->save()); + } + + /** + * @covers ::isValid + */ + public function testIsValid() + { + $field = $this->field(); + $this->assertTrue($field->isValid()); + } + + /** + * @covers ::isValid + */ + public function testIsValidWhenInvalid() + { + $field = $this->field( + props: [ + 'required' => true + ] + ); + + $this->assertFalse($field->isValid()); + } + + /** + * @covers ::kirby + */ + public function testKirby() + { + $field = $this->field(); + $this->assertInstanceOf(App::class, $field->kirby()); + } + + /** + * @covers ::label + */ + public function testLabel() + { + $field = $this->field( + props: [ + 'label' => 'test' + ] + ); + + $this->assertSame('test', $field->label()); + } + + /** + * @covers ::label + */ + public function testLabelMultilang() + { + $field = $this->field( + props: [ + 'label' => [ + 'en' => 'en', + 'de' => 'de' + ] + ] + ); + + $this->assertSame('en', $field->label()); + } + + /** + * @covers ::label + */ + public function testLabelWithQuery() + { + $field = $this->field( + props: [ + 'label' => '{{ page.slug }}' + ] + ); + + $this->assertSame($this->model->slug(), $field->label()); + } + + /** + * @covers ::label + */ + public function testLabelWhenNotSet() + { + $field = $this->field(); + + // fall back to the field type + $this->assertSame('Test', $field->label()); + } + + /** + * @covers ::model + */ + public function testModel() + { + $field = $this->field(); + $this->assertSame($this->model, $field->model()); + } + + /** + * @covers ::name + */ + public function testName() + { + $field = $this->field( + props: [ + 'name' => 'the-name' + ] + ); + + $this->assertSame('the-name', $field->name()); + } + + /** + * @covers ::name + */ + public function testNameWhenNotSet() + { + $field = $this->field(); + + // the field type should be used as name + $this->assertSame('test', $field->name()); + } + + /** + * @covers ::needsValue + */ + public function testNeedsValue() + { + $field = $this->field(); + $this->assertFalse($field->needsValue()); + } + + /** + * @covers ::needsValue + */ + public function testNeedsValueWhenRequired() + { + $field = $this->field( + props: [ + 'required' => true + ] + ); + + $this->assertTrue($field->needsValue()); + } + + /** + * @covers ::needsValue + */ + public function testNeedsValueWhenRequiredAndNotEmpty() + { + $field = $this->field( + props: [ + 'required' => true, + 'value' => 'Some value' + ] + ); + + $this->assertFalse($field->needsValue()); + } + + /** + * @covers ::needsValue + */ + public function testNeedsValueWhenRequiredAndNotActive() + { + $a = $this->field( + props: [ + 'name' => 'a' + ] + ); + + $b = $this->field( + props: [ + 'name' => 'b', + 'required' => true, + 'when' => [ + 'a' => 'test' + ] + ] + ); + + // attach siblings, otherwise the when queries won't work + new Fields([ + $a, + $b + ]); + + $this->assertFalse($b->needsValue()); + + $a->fill('test'); + + $this->assertTrue($b->needsValue()); + + $b->fill('test'); + + $this->assertFalse($b->needsValue()); + } + + /** + * @covers ::next + * @covers ::siblingsCollection + */ + public function testNext() + { + $siblings = new Fields([ + $this->field(['name' => 'a']), + $this->field(['name' => 'b']), + ], $this->model); + + $this->assertSame('b', $siblings->first()->next()->name()); + $this->assertNull($siblings->last()->next()); + } + + /** + * @covers ::next + * @covers ::siblingsCollection + */ + public function testNextWhenNotSet() + { + $field = $this->field(); + $this->assertNull($field->next()); + } + + /** + * @covers ::placeholder + */ + public function testPlaceholder() + { + $field = $this->field( + props: [ + 'placeholder' => 'test' + ] + ); + + $this->assertSame('test', $field->placeholder()); + } + + /** + * @covers ::placeholder + */ + public function testPlaceholderMultilang() + { + $field = $this->field( + props: [ + 'placeholder' => [ + 'en' => 'en', + 'de' => 'de' + ] + ] + ); + + $this->assertSame('en', $field->placeholder()); + } + + /** + * @covers ::placeholder + */ + public function testPlaceholderWithQuery() + { + $field = $this->field( + props: [ + 'placeholder' => '{{ page.slug }}' + ] + ); + + $this->assertSame($this->model->slug(), $field->placeholder()); + } + + /** + * @covers ::placeholder + */ + public function testPlaceholderWhenNotSet() + { + $field = $this->field(); + $this->assertNull($field->placeholder()); + } + + /** + * @covers ::prev + * @covers ::siblingsCollection + */ + public function testPrev() + { + $siblings = new Fields([ + $this->field(['name' => 'a']), + $this->field(['name' => 'b']), + ], $this->model); + + $this->assertSame('a', $siblings->last()->prev()->name()); + $this->assertNull($siblings->first()->prev()); + } + + /** + * @covers ::prev + * @covers ::siblingsCollection + */ + public function testPrevWhenNotSet() + { + $field = $this->field(); + $this->assertNull($field->prev()); + } + + /** + * @covers ::siblings + */ + public function testSiblings() + { + $siblings = new Fields([ + $a = $this->field(['name' => 'a']), + $b = $this->field(['name' => 'b']), + ], $this->model); + + $this->assertSame($siblings, $a->siblings()); + $this->assertSame($siblings, $b->siblings()); + } + + /** + * @covers ::siblings + */ + public function testSiblingsWhenNotSet() + { + $field = $this->field(); + + $this->assertInstanceOf(Fields::class, $field->siblings()); + + $this->assertCount(1, $field->siblings()); + + $this->assertSame($field, $field->siblings()->first()); + $this->assertSame($field, $field->siblings()->last()); + } + + /** + * @covers ::toArray + */ + public function testToArray() + { + $field = $this->field(); + $expected = [ + 'autofocus' => false, + 'disabled' => false, + 'hidden' => false, + 'label' => 'Test', + 'name' => 'test', + 'required' => false, + 'saveable' => true, + 'translate' => true, + 'type' => 'test', + 'width' => '1/1', + ]; + + $this->assertSame($expected, $field->toArray()); + } + + /** + * @covers ::toFormValue + * @covers ::value + */ + public function testToFormValue() + { + $field = $this->field( + props: [ + 'value' => 'test' + ] + ); + + $this->assertSame('test', $field->toFormValue()); + $this->assertSame('test', $field->value()); + } + + /** + * @covers ::toFormValue + * @covers ::value + */ + public function testToFormValueWhenNotSet() + { + $field = $this->field(); + $this->assertNull($field->toFormValue()); + $this->assertNull($field->value()); + } + + /** + * @covers ::toFormValue + */ + public function testToFormValueWhenUnsaveable() + { + $field = $this->fieldWithUnsaveableFlag(); + $field->fill('test'); + + $this->assertNull($field->toFormValue()); + } + + /** + * @covers ::toFormValue + * @covers ::value + */ + public function testToFormValueWithDefault() + { + $field = $this->field( + props: [ + 'default' => 'default value' + ] + ); + + $this->assertNull($field->toFormValue()); + $this->assertSame('default value', $field->toFormValue(true)); + $this->assertSame('default value', $field->value(true)); + } + + /** + * @covers ::toStoredValue + */ + public function testToStoredValue() + { + $field = $this->field( + props: [ + 'value' => 'test' + ] + ); + + $this->assertSame('test', $field->toStoredValue()); + } + + /** + * @covers ::toStoredValue + * @covers ::store + * @covers ::data + */ + public function testToStoredValueWhenNotSet() + { + $field = $this->field(); + $this->assertNull($field->toStoredValue()); + $this->assertNull($field->data()); + } + + /** + * @covers ::toStoredValue + * @covers ::store + * @covers ::data + */ + public function testToStoredValueWhenUnsaveable() + { + $field = $this->fieldWithUnsaveableFlag(); + $field->fill('test'); + + $this->assertNull($field->toStoredValue()); + $this->assertNull($field->data()); + } + + /** + * @covers ::toStoredValue + * @covers ::store + * @covers ::data + */ + public function testToStoredValueWithDefault() + { + $field = $this->field( + props: [ + 'default' => 'default value' + ] + ); + + $this->assertNull($field->toStoredValue()); + $this->assertNull($field->data()); + $this->assertSame('default value', $field->toStoredValue(true)); + $this->assertSame('default value', $field->data(true)); + } + + /** + * @covers ::toStoredValue + * @covers ::store + * @covers ::data + */ + public function testToStoredValueWithCustomStoreHandler() + { + $field = $this->fieldWithCustomStoreHandler(); + $field->fill([ + 'a', + 'b', + 'c' + ]); + + $this->assertSame('a,b,c', $field->toStoredValue()); + $this->assertSame('a,b,c', $field->data()); + } + + /** + * @covers ::validate + * @covers ::validations + */ + public function testValidate() + { + $field = $this->field(); + $this->assertSame([], $field->validate()); + } + + /** + * @covers ::validate + * @covers ::validations + */ + public function testValidateWithFieldValidations() + { + $field = $this->fieldWithValidations(); + + $this->assertSame([], $field->validate()); + + $field->fill('This is way too long'); + + $this->assertSame([ + 'maxlength' => 'Please enter a shorter value. (max. 5 characters)', + 'custom' => 'Please enter an a' + ], $field->validate()); + + $field->fill('Not a'); + + $this->assertSame([ + 'custom' => 'Please enter an a' + ], $field->validate()); + + $field->fill('a'); + + $this->assertSame([], $field->validate()); + } + + /** + * @covers ::validate + * @covers ::setValidate + */ + public function testValidateWithSingleRule() + { + $field = $this->field( + props: [ + 'validate' => 'url' + ] + ); + + $this->assertSame([], $field->validate()); + + $field->fill('invalid url'); + + $this->assertSame(['url' => 'Please enter a valid URL'], $field->validate()); + + $field->fill('https://getkirby.com'); + + $this->assertSame([], $field->validate()); + } + + /** + * @covers ::validate + * @covers ::setValidate + */ + public function testValidateWithMultipleRules() + { + $field = $this->field( + props: [ + 'validate' => [ + 'url', + 'minlength' => 18 + ] + ] + ); + + $this->assertSame([], $field->validate()); + + $field->fill('invalid url'); + + $this->assertSame([ + 'url' => 'Please enter a valid URL', + 'minlength' => 'Please enter a longer value. (min. 18 characters)' + ], $field->validate()); + + $field->fill('https://kirby.com'); + + $this->assertSame([ + 'minlength' => 'Please enter a longer value. (min. 18 characters)' + ], $field->validate()); + + $field->fill('https://getkirby.com'); + + $this->assertSame([], $field->validate()); + } + + /** + * @covers ::width + */ + public function testWidth() + { + $field = $this->field( + props: [ + 'width' => '1/2' + ] + ); + + $this->assertSame('1/2', $field->width()); + } + + /** + * @covers ::width + */ + public function testWidthWhenNotSet() + { + $field = $this->field(); + $this->assertSame('1/1', $field->width()); + } + +} diff --git a/tests/Form/Fields/HeadlineFieldTest.php b/tests/Form/Fields/HeadlineFieldTest.php index 21918d229c..4cae54f3b3 100644 --- a/tests/Form/Fields/HeadlineFieldTest.php +++ b/tests/Form/Fields/HeadlineFieldTest.php @@ -11,7 +11,7 @@ public function testDefaultProps() $this->assertSame('headline', $field->type()); $this->assertSame('headline', $field->name()); $this->assertNull($field->value()); - $this->assertNull($field->label()); + $this->assertSame('Headline', $field->label()); $this->assertFalse($field->save()); } } diff --git a/tests/Form/Fields/InfoFieldTest.php b/tests/Form/Fields/InfoFieldTest.php index a1bda59277..732d6fdbee 100644 --- a/tests/Form/Fields/InfoFieldTest.php +++ b/tests/Form/Fields/InfoFieldTest.php @@ -13,7 +13,7 @@ public function testDefaultProps() $this->assertSame('info', $field->type()); $this->assertSame('info', $field->name()); $this->assertNull($field->value()); - $this->assertNull($field->label()); + $this->assertSame('Info', $field->label()); $this->assertNull($field->text()); $this->assertFalse($field->save()); } diff --git a/tests/Form/Fields/LinkFieldTest.php b/tests/Form/Fields/LinkFieldTest.php index ea99a9abe2..19a89389aa 100644 --- a/tests/Form/Fields/LinkFieldTest.php +++ b/tests/Form/Fields/LinkFieldTest.php @@ -13,7 +13,7 @@ public function testDefaultProps() $this->assertSame('link', $field->type()); $this->assertSame('link', $field->name()); $this->assertSame('', $field->value()); - $this->assertNull($field->label()); + $this->assertSame('Link', $field->label()); $this->assertNull($field->text()); $this->assertTrue($field->save()); $this->assertNull($field->after()); diff --git a/tests/Form/Fields/ListFieldTest.php b/tests/Form/Fields/ListFieldTest.php index 705a611efc..cc511813cd 100644 --- a/tests/Form/Fields/ListFieldTest.php +++ b/tests/Form/Fields/ListFieldTest.php @@ -11,7 +11,7 @@ public function testDefaultProps() $this->assertSame('list', $field->type()); $this->assertSame('list', $field->name()); $this->assertSame('', $field->value()); - $this->assertNull($field->label()); + $this->assertSame('List', $field->label()); $this->assertNull($field->text()); $this->assertTrue($field->save()); } diff --git a/tests/Form/Fields/ObjectFieldTest.php b/tests/Form/Fields/ObjectFieldTest.php index e702269ded..28055aa088 100644 --- a/tests/Form/Fields/ObjectFieldTest.php +++ b/tests/Form/Fields/ObjectFieldTest.php @@ -86,7 +86,7 @@ public function testErrors() $expected = [ 'object' => - 'There’s an error in the "url" field:' . "\n" . + 'There’s an error in the "Url" field:' . "\n" . 'Please enter a longer value. (min. 20 characters)' . "\n" . 'Please enter a valid URL' ]; diff --git a/tests/Form/Fields/StructureFieldTest.php b/tests/Form/Fields/StructureFieldTest.php index 85f5c28168..9013bd2e69 100644 --- a/tests/Form/Fields/StructureFieldTest.php +++ b/tests/Form/Fields/StructureFieldTest.php @@ -69,12 +69,12 @@ public function testColumnsFromFields() $expected = [ 'a' => [ 'type' => 'text', - 'label' => 'a', + 'label' => 'A', 'mobile' => true // the first column should be automatically kept on mobile ], 'b' => [ 'type' => 'text', - 'label' => 'b', + 'label' => 'B', ], ]; @@ -103,7 +103,7 @@ public function testColumnsWithCustomMobileSetup() 'b' => [ 'mobile' => true, 'type' => 'text', - 'label' => 'b', + 'label' => 'B', ], ]; From 3fcdb78168a5f4823485adea9fc7d340c2948bb3 Mon Sep 17 00:00:00 2001 From: Bastian Allgeier Date: Mon, 2 Dec 2024 11:03:39 +0100 Subject: [PATCH 02/15] Fix ::store coverage --- tests/Form/FieldTestCase.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/Form/FieldTestCase.php b/tests/Form/FieldTestCase.php index 5447ae98a2..9300d669e2 100644 --- a/tests/Form/FieldTestCase.php +++ b/tests/Form/FieldTestCase.php @@ -1089,7 +1089,6 @@ public function testToStoredValue() /** * @covers ::toStoredValue - * @covers ::store * @covers ::data */ public function testToStoredValueWhenNotSet() @@ -1101,7 +1100,6 @@ public function testToStoredValueWhenNotSet() /** * @covers ::toStoredValue - * @covers ::store * @covers ::data */ public function testToStoredValueWhenUnsaveable() @@ -1115,7 +1113,6 @@ public function testToStoredValueWhenUnsaveable() /** * @covers ::toStoredValue - * @covers ::store * @covers ::data */ public function testToStoredValueWithDefault() @@ -1134,7 +1131,6 @@ public function testToStoredValueWithDefault() /** * @covers ::toStoredValue - * @covers ::store * @covers ::data */ public function testToStoredValueWithCustomStoreHandler() From 606bfce8708abe26f6617fc7ad90b2097e48c07e Mon Sep 17 00:00:00 2001 From: Bastian Allgeier Date: Mon, 2 Dec 2024 18:01:20 +0100 Subject: [PATCH 03/15] Various improvements --- config/fields/structure.php | 2 +- src/Form/Field.php | 182 +++++------------------ src/Form/Field/BlocksField.php | 130 ++++++++-------- src/Form/Field/LayoutField.php | 136 ++++++++--------- src/Form/FieldClass.php | 206 +------------------------- src/Form/Mixin/Api.php | 19 --- src/Form/Mixin/Common.php | 174 ++++++++++++++++++++++ src/Form/Mixin/Endpoints.php | 38 +++++ src/Form/Mixin/Model.php | 14 ++ src/Form/Mixin/Siblings.php | 47 ++++++ src/Form/Mixin/Value.php | 3 + src/Toolkit/Component.php | 13 +- tests/Form/FieldClassTest.php | 2 +- tests/Form/Fields/BlocksFieldTest.php | 154 +++++++++---------- tests/Form/Fields/LayoutFieldTest.php | 20 +-- 15 files changed, 552 insertions(+), 588 deletions(-) delete mode 100644 src/Form/Mixin/Api.php create mode 100644 src/Form/Mixin/Common.php create mode 100644 src/Form/Mixin/Endpoints.php create mode 100644 src/Form/Mixin/Siblings.php diff --git a/config/fields/structure.php b/config/fields/structure.php index 622507ee39..4e09e9b2e6 100644 --- a/config/fields/structure.php +++ b/config/fields/structure.php @@ -141,7 +141,7 @@ } $column['type'] ??= $field['type']; - $column['label'] ??= $field['label'] ?? $name; + $column['label'] ??= $field['label'] ?? Str::ucfirst($name); $column['label'] = I18n::translate($column['label'], $column['label']); $columns[$name] = $column; diff --git a/src/Form/Field.php b/src/Form/Field.php index 35725ef834..ebaf3150b0 100644 --- a/src/Form/Field.php +++ b/src/Form/Field.php @@ -3,12 +3,9 @@ namespace Kirby\Form; use Closure; -use Kirby\Cms\HasSiblings; use Kirby\Exception\InvalidArgumentException; use Kirby\Toolkit\A; use Kirby\Toolkit\Component; -use Kirby\Toolkit\I18n; -use Kirby\Toolkit\Str; /** * Form Field object that takes a Vue component style @@ -23,22 +20,15 @@ */ class Field extends Component { - /** - * @use \Kirby\Cms\HasSiblings<\Kirby\Form\Fields> - */ - use HasSiblings; - use Mixin\Api; + use Mixin\Common; + use Mixin\Endpoints; use Mixin\Model; + use Mixin\Siblings; use Mixin\Translatable; use Mixin\Validation; use Mixin\When; use Mixin\Value; - /** - * Parent collection with all fields of the current form - */ - public Fields $siblings; - /** * Registry for all component mixins */ @@ -81,8 +71,22 @@ public function __construct( parent::__construct($type, $attrs); - // set the siblings collection - $this->siblings = $siblings ?? new Fields([$this]); + $this->setSiblings($attrs['siblings'] ?? null); + } + + /** + * Returns field api routes + */ + public function api(): array + { + if ( + isset($this->options['api']) === true && + $this->options['api'] instanceof Closure + ) { + return $this->options['api']->call($this); + } + + return []; } /** @@ -95,38 +99,38 @@ public static function defaults(): array /** * Optional text that will be shown after the input */ - 'after' => function ($after = null) { - return I18n::translate($after, $after); + 'after' => function (array|string|null $after = null) { + return $this->i18n($after); }, /** * Sets the focus on this field when the form loads. Only the first field with this label gets */ - 'autofocus' => function (bool|null $autofocus = null): bool { - return $autofocus ?? false; + 'autofocus' => function (bool $autofocus = false): bool { + return $autofocus; }, /** * Optional text that will be shown before the input */ - 'before' => function ($before = null) { - return I18n::translate($before, $before); + 'before' => function (array|string|null $before = null) { + return $this->i18n($before); }, /** * Default value for the field, which will be used when a page/file/user is created */ - 'default' => function ($default = null) { + 'default' => function (mixed $default = null) { return $default; }, /** * If `true`, the field is no longer editable and will not be saved */ - 'disabled' => function (bool|null $disabled = null): bool { - return $disabled ?? false; + 'disabled' => function (bool $disabled = false): bool { + return $disabled; }, /** * Optional help text below the field */ - 'help' => function ($help = null) { - return I18n::translate($help, $help); + 'help' => function (array|string|null $help = null) { + return $this->i18n($help); }, /** * Optional icon that will be shown at the end of the field @@ -137,20 +141,20 @@ public static function defaults(): array /** * The field label can be set as string or associative array with translations */ - 'label' => function ($label = null) { - return I18n::translate($label, $label); + 'label' => function (array|string|null $label = null) { + return $this->i18n($label); }, /** * Optional placeholder value that will be shown when the field is empty */ - 'placeholder' => function ($placeholder = null) { - return I18n::translate($placeholder, $placeholder); + 'placeholder' => function (array|string|null $placeholder = null) { + return $this->i18n($placeholder); }, /** * If `true`, the field has to be filled in correctly to be saved. */ - 'required' => function (bool|null $required = null): bool { - return $required ?? false; + 'required' => function (bool $required = false): bool { + return $required; }, /** * If `false`, the field will be disabled in non-default languages and cannot be translated. This is only relevant in multi-language setups. @@ -161,62 +165,18 @@ public static function defaults(): array /** * Conditions when the field will be shown (since 3.1.0) */ - 'when' => function ($when = null) { + 'when' => function (array|null $when = null) { return $when; }, /** * The width of the field in the field grid. Available widths: `1/1`, `1/2`, `1/3`, `1/4`, `2/3`, `3/4` */ - 'width' => function (string $width = '1/1') { + 'width' => function (string|null $width = null) { return $width; }, 'value' => function ($value = null) { return $value; } - ], - 'computed' => [ - 'after' => function () { - /** @var \Kirby\Form\Field $this */ - if ($this->after !== null) { - return $this->model()->toString($this->after); - } - }, - 'before' => function () { - /** @var \Kirby\Form\Field $this */ - if ($this->before !== null) { - return $this->model()->toString($this->before); - } - }, - 'default' => function () { - /** @var \Kirby\Form\Field $this */ - if ($this->default === null) { - return; - } - - if (is_string($this->default) === false) { - return $this->default; - } - - return $this->model()->toString($this->default); - }, - 'help' => function () { - /** @var \Kirby\Form\Field $this */ - if ($this->help) { - $help = $this->model()->toSafeString($this->help); - $help = $this->kirby()->kirbytext($help); - return $help; - } - }, - 'label' => function () { - /** @var \Kirby\Form\Field $this */ - return $this->model()->toString($this->label ?? Str::ucfirst($this->name)); - }, - 'placeholder' => function () { - /** @var \Kirby\Form\Field $this */ - if ($this->placeholder !== null) { - return $this->model()->toString($this->placeholder); - } - } ] ]; } @@ -281,7 +241,7 @@ public function fill(mixed $value): static $this->applyProp('value', $this->options['props']['value'] ?? $value); // reevaluate the computed props - $this->applyComputed($this->options['computed']); + $this->applyComputed($this->options['computed'] ?? []); // reset the errors cache $this->errors = null; @@ -289,22 +249,6 @@ public function fill(mixed $value): static return $this; } - /** - * @deprecated 5.0.0 Use `::siblings() instead - */ - public function formFields(): Fields - { - return $this->siblings; - } - - /** - * Checks if the field is disabled - */ - public function isDisabled(): bool - { - return $this->disabled === true; - } - /** * Checks if the field is hidden */ @@ -313,14 +257,6 @@ public function isHidden(): bool return ($this->options['hidden'] ?? false) === true; } - /** - * Checks if the field is required - */ - public function isRequired(): bool - { - return $this->required ?? false; - } - /** * Checks if the field is saveable */ @@ -329,46 +265,6 @@ public function isSaveable(): bool return ($this->options['save'] ?? true) !== false; } - /** - * Returns field api routes - */ - public function routes(): array - { - if ( - isset($this->options['api']) === true && - $this->options['api'] instanceof Closure - ) { - return $this->options['api']->call($this); - } - - return []; - } - - /** - * Checks if the field is saveable - * @deprecated 5.0.0 Use `::isSaveable()` instead - */ - public function save(): bool - { - return $this->isSaveable(); - } - - /** - * Parent collection with all fields of the current form - */ - public function siblings(): Fields - { - return $this->siblings; - } - - /** - * Returns all sibling fields for the HasSiblings trait - */ - protected function siblingsCollection(): Fields - { - return $this->siblings; - } - /** * Converts the field to a plain array */ diff --git a/src/Form/Field/BlocksField.php b/src/Form/Field/BlocksField.php index dbadae0042..be597ae368 100644 --- a/src/Form/Field/BlocksField.php +++ b/src/Form/Field/BlocksField.php @@ -46,6 +46,71 @@ public function __construct(array $params = []) $this->setPretty($params['pretty'] ?? false); } + public function api(): array + { + $field = $this; + + return [ + [ + 'pattern' => 'uuid', + 'action' => fn (): array => ['uuid' => Str::uuid()] + ], + [ + 'pattern' => 'paste', + 'method' => 'POST', + 'action' => function () use ($field): array { + $request = App::instance()->request(); + $value = BlocksCollection::parse($request->get('html')); + $blocks = BlocksCollection::factory($value); + + return $field->pasteBlocks($blocks->toArray()); + } + ], + [ + 'pattern' => 'fieldsets/(:any)', + 'method' => 'GET', + 'action' => function ( + string $fieldsetType + ) use ($field): array { + $fields = $field->fields($fieldsetType); + $defaults = $field->form($fields, [])->data(true); + $content = $field->form($fields, $defaults)->values(); + + return Block::factory([ + 'content' => $content, + 'type' => $fieldsetType + ])->toArray(); + } + ], + [ + 'pattern' => 'fieldsets/(:any)/fields/(:any)/(:all?)', + 'method' => 'ALL', + 'action' => function ( + string $fieldsetType, + string $fieldName, + string|null $path = null + ) use ($field) { + $fields = $field->fields($fieldsetType); + $field = $field->form($fields)->field($fieldName); + + $fieldApi = $this->clone([ + 'routes' => $field->api(), + 'data' => [ + ...$this->data(), + 'field' => $field + ] + ]); + + return $fieldApi->call( + $path, + $this->requestMethod(), + $this->requestData() + ); + } + ], + ]; + } + public function blocksToValues( array $blocks, string $to = 'values' @@ -174,71 +239,6 @@ public function props(): array ] + parent::props(); } - public function routes(): array - { - $field = $this; - - return [ - [ - 'pattern' => 'uuid', - 'action' => fn (): array => ['uuid' => Str::uuid()] - ], - [ - 'pattern' => 'paste', - 'method' => 'POST', - 'action' => function () use ($field): array { - $request = App::instance()->request(); - $value = BlocksCollection::parse($request->get('html')); - $blocks = BlocksCollection::factory($value); - - return $field->pasteBlocks($blocks->toArray()); - } - ], - [ - 'pattern' => 'fieldsets/(:any)', - 'method' => 'GET', - 'action' => function ( - string $fieldsetType - ) use ($field): array { - $fields = $field->fields($fieldsetType); - $defaults = $field->form($fields, [])->data(true); - $content = $field->form($fields, $defaults)->values(); - - return Block::factory([ - 'content' => $content, - 'type' => $fieldsetType - ])->toArray(); - } - ], - [ - 'pattern' => 'fieldsets/(:any)/fields/(:any)/(:all?)', - 'method' => 'ALL', - 'action' => function ( - string $fieldsetType, - string $fieldName, - string|null $path = null - ) use ($field) { - $fields = $field->fields($fieldsetType); - $field = $field->form($fields)->field($fieldName); - - $fieldApi = $this->clone([ - 'routes' => $field->api(), - 'data' => [ - ...$this->data(), - 'field' => $field - ] - ]); - - return $fieldApi->call( - $path, - $this->requestMethod(), - $this->requestData() - ); - } - ], - ]; - } - protected function setDefault(mixed $default = null): void { // set id for blocks if not exists diff --git a/src/Form/Field/LayoutField.php b/src/Form/Field/LayoutField.php index 39e89739ad..7f0ee41655 100644 --- a/src/Form/Field/LayoutField.php +++ b/src/Form/Field/LayoutField.php @@ -30,6 +30,74 @@ public function __construct(array $params) parent::__construct($params); } + public function api(): array + { + $field = $this; + $routes = parent::api(); + + $routes[] = [ + 'pattern' => 'layout', + 'method' => 'POST', + 'action' => function () use ($field): array { + $request = App::instance()->request(); + + $input = $request->get('attrs') ?? []; + $defaults = $field->attrsForm($input)->data(true); + $attrs = $field->attrsForm($defaults)->values(); + $columns = $request->get('columns') ?? ['1/1']; + + return Layout::factory([ + 'attrs' => $attrs, + 'columns' => array_map(fn ($width) => [ + 'blocks' => [], + 'id' => Str::uuid(), + 'width' => $width, + ], $columns) + ])->toArray(); + }, + ]; + + $routes[] = [ + 'pattern' => 'layout/paste', + 'method' => 'POST', + 'action' => function () use ($field): array { + $request = App::instance()->request(); + $value = Layouts::parse($request->get('json')); + $layouts = Layouts::factory($value); + + return $field->pasteLayouts($layouts->toArray()); + } + ]; + + $routes[] = [ + 'pattern' => 'fields/(:any)/(:all?)', + 'method' => 'ALL', + 'action' => function ( + string $fieldName, + string|null $path = null + ) use ($field): array { + $form = $field->attrsForm(); + $field = $form->field($fieldName); + + $fieldApi = $this->clone([ + 'routes' => $field->api(), + 'data' => [ + ...$this->data(), + 'field' => $field + ] + ]); + + return $fieldApi->call( + $path, + $this->requestMethod(), + $this->requestData() + ); + } + ]; + + return $routes; + } + public function fill(mixed $value = null): void { $value = Data::decode($value, type: 'json', fail: false); @@ -133,74 +201,6 @@ public function props(): array ]; } - public function routes(): array - { - $field = $this; - $routes = parent::routes(); - - $routes[] = [ - 'pattern' => 'layout', - 'method' => 'POST', - 'action' => function () use ($field): array { - $request = App::instance()->request(); - - $input = $request->get('attrs') ?? []; - $defaults = $field->attrsForm($input)->data(true); - $attrs = $field->attrsForm($defaults)->values(); - $columns = $request->get('columns') ?? ['1/1']; - - return Layout::factory([ - 'attrs' => $attrs, - 'columns' => array_map(fn ($width) => [ - 'blocks' => [], - 'id' => Str::uuid(), - 'width' => $width, - ], $columns) - ])->toArray(); - }, - ]; - - $routes[] = [ - 'pattern' => 'layout/paste', - 'method' => 'POST', - 'action' => function () use ($field): array { - $request = App::instance()->request(); - $value = Layouts::parse($request->get('json')); - $layouts = Layouts::factory($value); - - return $field->pasteLayouts($layouts->toArray()); - } - ]; - - $routes[] = [ - 'pattern' => 'fields/(:any)/(:all?)', - 'method' => 'ALL', - 'action' => function ( - string $fieldName, - string|null $path = null - ) use ($field): array { - $form = $field->attrsForm(); - $field = $form->field($fieldName); - - $fieldApi = $this->clone([ - 'routes' => $field->api(), - 'data' => [ - ...$this->data(), - 'field' => $field - ] - ]); - - return $fieldApi->call( - $path, - $this->requestMethod(), - $this->requestData() - ); - } - ]; - - return $routes; - } - public function selector(): array|null { return $this->selector; diff --git a/src/Form/FieldClass.php b/src/Form/FieldClass.php index 896ebc42e7..abf530a43c 100644 --- a/src/Form/FieldClass.php +++ b/src/Form/FieldClass.php @@ -2,10 +2,6 @@ namespace Kirby\Form; -use Kirby\Cms\HasSiblings; -use Kirby\Toolkit\I18n; -use Kirby\Toolkit\Str; - /** * Abstract field class to be used instead * of functional field components for more @@ -19,32 +15,15 @@ */ abstract class FieldClass { - /** - * @use \Kirby\Cms\HasSiblings<\Kirby\Form\Fields> - */ - use HasSiblings; - use Mixin\Api; + use Mixin\Common; + use Mixin\Endpoints; use Mixin\Model; + use Mixin\Siblings; use Mixin\Translatable; use Mixin\Validation; use Mixin\Value; use Mixin\When; - protected string|null $after; - protected bool $autofocus; - protected string|null $before; - protected mixed $default; - protected bool $disabled; - protected string|null $help; - protected string|null $icon; - protected string|null $label; - protected string|null $name; - protected string|null $placeholder; - protected bool $required; - public Fields $siblings; - protected mixed $value = null; - protected string|null $width; - public function __construct( protected array $params = [] ) { @@ -80,45 +59,6 @@ public function __call(string $param, array $args): mixed return $this->params[$param] ?? null; } - public function after(): string|null - { - return $this->stringTemplate($this->after); - } - - public function autofocus(): bool - { - return $this->autofocus; - } - - public function before(): string|null - { - return $this->stringTemplate($this->before); - } - - /** - * Returns optional dialog routes for the field - */ - public function dialogs(): array - { - return []; - } - - /** - * If `true`, the field is no longer editable and will not be saved - */ - public function disabled(): bool - { - return $this->disabled; - } - - /** - * Returns optional drawer routes for the field - */ - public function drawers(): array - { - return []; - } - /** * Sets a new value for the field */ @@ -128,84 +68,6 @@ public function fill(mixed $value = null): void $this->errors = null; } - /** - * @deprecated 5.0.0 Use `::siblings() instead - */ - public function formFields(): Fields - { - return $this->siblings; - } - - /** - * Optional help text below the field - */ - public function help(): string|null - { - if (empty($this->help) === false) { - $help = $this->stringTemplate($this->help); - $help = $this->kirby()->kirbytext($help); - return $help; - } - - return null; - } - - protected function i18n(string|array|null $param = null): string|null - { - return empty($param) === false ? I18n::translate($param, $param) : null; - } - - /** - * Optional icon that will be shown at the end of the field - */ - public function icon(): string|null - { - return $this->icon; - } - - public function id(): string - { - return $this->name(); - } - - public function isDisabled(): bool - { - return $this->disabled; - } - - public function isHidden(): bool - { - return false; - } - - public function isRequired(): bool - { - return $this->required; - } - - public function isSaveable(): bool - { - return true; - } - - /** - * The field label can be set as string or associative array with translations - */ - public function label(): string - { - return $this->stringTemplate( - $this->label ?? Str::ucfirst($this->name()) - ); - } - - /** - * Returns the field name - */ - public function name(): string - { - return $this->name ?? $this->type(); - } - /** * Returns all original params for the field */ @@ -214,14 +76,6 @@ public function params(): array return $this->params; } - /** - * Optional placeholder value that will be shown when the field is empty - */ - public function placeholder(): string|null - { - return $this->stringTemplate($this->placeholder); - } - /** * Define the props that will be sent to * the Vue component @@ -249,23 +103,6 @@ public function props(): array ]; } - /** - * If `true`, the field has to be filled in correctly to be saved. - */ - public function required(): bool - { - return $this->required; - } - - /** - * Checks if the field is saveable - * @deprecated 5.0.0 Use `::isSaveable()` instead - */ - public function save(): bool - { - return $this->isSaveable(); - } - protected function setAfter(array|string|null $after = null): void { $this->after = $this->i18n($after); @@ -321,39 +158,11 @@ protected function setRequired(bool $required = false): void $this->required = $required; } - protected function setSiblings(Fields|null $siblings = null): void - { - $this->siblings = $siblings ?? new Fields([$this]); - } - - /** - * Setter for the field width - */ protected function setWidth(string|null $width = null): void { $this->width = $width; } - /** - * Returns all sibling fields for the HasSiblings trait - */ - protected function siblingsCollection(): Fields - { - return $this->siblings; - } - - /** - * Parses a string template in the given value - */ - protected function stringTemplate(string|null $string = null): string|null - { - if ($string !== null) { - return $this->model->toString($string); - } - - return null; - } - /** * Converts the field to a plain array */ @@ -373,13 +182,4 @@ public function type(): string { return lcfirst(basename(str_replace(['\\', 'Field'], ['/', ''], static::class))); } - - /** - * Returns the width of the field in - * the Panel grid - */ - public function width(): string - { - return $this->width ?? '1/1'; - } } diff --git a/src/Form/Mixin/Api.php b/src/Form/Mixin/Api.php deleted file mode 100644 index d6482497c2..0000000000 --- a/src/Form/Mixin/Api.php +++ /dev/null @@ -1,19 +0,0 @@ -routes(); - } - - /** - * Routes for the field API - */ - public function routes(): array - { - return []; - } -} diff --git a/src/Form/Mixin/Common.php b/src/Form/Mixin/Common.php new file mode 100644 index 0000000000..cffc53eae4 --- /dev/null +++ b/src/Form/Mixin/Common.php @@ -0,0 +1,174 @@ +stringTemplate($this->after); + } + + /** + * Sets the focus on this field when the form loads. Only the first field with this label gets focused + */ + public function autofocus(): bool + { + return $this->autofocus; + } + + /** + * Optional text that will be shown before the input + */ + public function before(): string|null + { + return $this->stringTemplate($this->before); + } + + /** + * @deprecated 5.0.0 Use `::isDisabled` instead + */ + public function disabled(): bool + { + return $this->isDisabled(); + } + + /** + * Optional help text below the field + */ + public function help(): string|null + { + if (empty($this->help) === false) { + return $this->kirby()->kirbytext( + $this->stringTemplate($this->help, safe: true) + ); + } + + return null; + } + + /** + * Translate field parameters + */ + protected function i18n(string|array|null $param = null): string|null + { + return empty($param) === false ? I18n::translate($param, $param) : null; + } + + /** + * Optional icon that will be shown at the end of the field + */ + public function icon(): string|null + { + return $this->icon; + } + + /** + * The field id is used in fields collections. The name is used as id by default. + */ + public function id(): string + { + return $this->name(); + } + + /** + * If `true`, the field is no longer editable and will not be saved + */ + public function isDisabled(): bool + { + return $this->disabled; + } + + /** + * Checks if the field is hidden + */ + public function isHidden(): bool + { + return false; + } + + /** + * If `true`, the field has to be filled in correctly to be saved. + */ + public function isRequired(): bool + { + return $this->required; + } + + /** + * Checks if the field is saveable + */ + public function isSaveable(): bool + { + return true; + } + + /** + * The field label can be set as string or associative array with translations + */ + public function label(): string|null + { + return $this->stringTemplate( + $this->label ?? Str::ucfirst($this->name()) + ); + } + + /** + * Returns the field name + */ + public function name(): string + { + return $this->name ?? $this->type(); + } + + /** + * Optional placeholder value that will be shown when the field is empty + */ + public function placeholder(): string|null + { + return $this->stringTemplate($this->placeholder); + } + + /** + * @deprecated 5.0.0 Use `::isRequired` instead + */ + public function required(): bool + { + return $this->isRequired(); + } + + /** + * Checks if the field is saveable + * @deprecated 5.0.0 Use `::isSaveable()` instead + */ + public function save(): bool + { + return $this->isSaveable(); + } + + /** + * The width of the field in the field grid. Available widths: `1/1`, `1/2`, `1/3`, `1/4`, `2/3`, `3/4` + */ + public function width(): string + { + return $this->width ?? '1/1'; + } +} diff --git a/src/Form/Mixin/Endpoints.php b/src/Form/Mixin/Endpoints.php new file mode 100644 index 0000000000..15f7c8617f --- /dev/null +++ b/src/Form/Mixin/Endpoints.php @@ -0,0 +1,38 @@ +api(); + } +} diff --git a/src/Form/Mixin/Model.php b/src/Form/Mixin/Model.php index 69b4c1957e..018b1fee66 100644 --- a/src/Form/Mixin/Model.php +++ b/src/Form/Mixin/Model.php @@ -32,4 +32,18 @@ protected function setModel(ModelWithContent|null $model = null): void { $this->model = $model ?? App::instance()->site(); } + + /** + * Parses a string template in the given value + */ + protected function stringTemplate( + string|null $string = null, + bool $safe = false + ): string|null { + if ($string !== null) { + return $safe === true ? $this->model->toSafeString($string) : $this->model->toString($string); + } + + return null; + } } diff --git a/src/Form/Mixin/Siblings.php b/src/Form/Mixin/Siblings.php new file mode 100644 index 0000000000..bc70fb9879 --- /dev/null +++ b/src/Form/Mixin/Siblings.php @@ -0,0 +1,47 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +trait Siblings +{ + /** + * @use \Kirby\Cms\HasSiblings<\Kirby\Form\Fields> + */ + use HasSiblings; + + /** + * Parent collection with all fields of the current form + */ + public Fields $siblings; + + /** + * @deprecated 5.0.0 Use `::siblings() instead + */ + public function formFields(): Fields + { + return $this->siblings; + } + + protected function setSiblings(Fields|null $siblings = null): void + { + $this->siblings = $siblings ?? new Fields([$this]); + } + + /** + * Returns all sibling fields for the HasSiblings trait + */ + protected function siblingsCollection(): Fields + { + return $this->siblings; + } +} diff --git a/src/Form/Mixin/Value.php b/src/Form/Mixin/Value.php index df03fb1e2a..0c8983f281 100644 --- a/src/Form/Mixin/Value.php +++ b/src/Form/Mixin/Value.php @@ -11,6 +11,9 @@ */ trait Value { + protected mixed $default; + protected mixed $value = null; + /** * @deprecated 5.0.0 Use `::toStoredValue()` instead */ diff --git a/src/Toolkit/Component.php b/src/Toolkit/Component.php index 54f9bb6ab0..fa22bf61cc 100644 --- a/src/Toolkit/Component.php +++ b/src/Toolkit/Component.php @@ -286,7 +286,18 @@ public function toArray(): array return $closure->call($this); } - $array = [...$this->attrs, ...$this->props, ...$this->computed]; + $props = []; + + // lazy-load all properties + foreach ($this->props as $key => $value) { + $props[$key] = $this->$key(); + } + + $array = [ + ...$this->attrs, + ...$props, + ...$this->computed + ]; ksort($array); diff --git a/tests/Form/FieldClassTest.php b/tests/Form/FieldClassTest.php index f819850d40..726bf649bf 100644 --- a/tests/Form/FieldClassTest.php +++ b/tests/Form/FieldClassTest.php @@ -7,7 +7,7 @@ class FieldWithApiRoutes extends FieldClass { - public function routes(): array + public function api(): array { return FieldClassTest::apiRoutes(); } diff --git a/tests/Form/Fields/BlocksFieldTest.php b/tests/Form/Fields/BlocksFieldTest.php index 9e7275079b..05db672a2b 100644 --- a/tests/Form/Fields/BlocksFieldTest.php +++ b/tests/Form/Fields/BlocksFieldTest.php @@ -10,6 +10,83 @@ class BlocksFieldTest extends TestCase { + public function testApi() + { + $field = $this->field('blocks'); + + $routes = $field->api(); + + $this->assertIsArray($routes); + $this->assertCount(4, $routes); + } + + public function testApiUUID() + { + $field = $this->field('blocks'); + $route = $field->api()[0]; + + $response = $route['action'](); + + $this->assertIsArray($response); + $this->assertArrayHasKey('uuid', $response); + } + + public function testApiPaste() + { + $this->app = $this->app->clone([ + 'request' => [ + 'query' => [ + 'html' => '

Test

' + ] + ] + ]); + + $field = $this->field('blocks'); + $route = $field->api()[1]; + + $response = $route['action'](); + + $this->assertCount(1, $response); + $this->assertSame(['text' => '

Test

'], $response[0]['content']); + $this->assertFalse($response[0]['isHidden']); + $this->assertSame('text', $response[0]['type']); + } + + public function testApiPasteFieldsets() + { + $this->app = $this->app->clone([ + 'request' => [ + 'query' => [ + 'html' => '

Hello World

Test

Sincerely
' + ] + ] + ]); + + $field = $this->field('blocks', ['fieldsets' => ['heading']]); + $route = $field->api()[1]; + + $response = $route['action'](); + + $this->assertCount(2, $response); + $this->assertSame(['level' => 'h1', 'text' => 'Hello World'], $response[0]['content']); + $this->assertSame('heading', $response[0]['type']); + $this->assertSame(['level' => 'h6', 'text' => 'Sincerely'], $response[1]['content']); + $this->assertSame('heading', $response[1]['type']); + } + + public function testApiFieldset() + { + $field = $this->field('blocks'); + $route = $field->api()[2]; + + $response = $route['action']('text'); + + $this->assertSame(['text' => ''], $response['content']); + $this->assertArrayHasKey('id', $response); + $this->assertFalse($response['isHidden']); + $this->assertSame('text', $response['type']); + } + public function testDefaultProps() { $field = $this->field('blocks', []); @@ -203,83 +280,6 @@ public function testRequiredValid() $this->assertTrue($field->isValid()); } - public function testRoutes() - { - $field = $this->field('blocks'); - - $routes = $field->routes(); - - $this->assertIsArray($routes); - $this->assertCount(4, $routes); - } - - public function testRouteUUID() - { - $field = $this->field('blocks'); - $route = $field->routes()[0]; - - $response = $route['action'](); - - $this->assertIsArray($response); - $this->assertArrayHasKey('uuid', $response); - } - - public function testRoutePaste() - { - $this->app = $this->app->clone([ - 'request' => [ - 'query' => [ - 'html' => '

Test

' - ] - ] - ]); - - $field = $this->field('blocks'); - $route = $field->routes()[1]; - - $response = $route['action'](); - - $this->assertCount(1, $response); - $this->assertSame(['text' => '

Test

'], $response[0]['content']); - $this->assertFalse($response[0]['isHidden']); - $this->assertSame('text', $response[0]['type']); - } - - public function testRoutePasteFieldsets() - { - $this->app = $this->app->clone([ - 'request' => [ - 'query' => [ - 'html' => '

Hello World

Test

Sincerely
' - ] - ] - ]); - - $field = $this->field('blocks', ['fieldsets' => ['heading']]); - $route = $field->routes()[1]; - - $response = $route['action'](); - - $this->assertCount(2, $response); - $this->assertSame(['level' => 'h1', 'text' => 'Hello World'], $response[0]['content']); - $this->assertSame('heading', $response[0]['type']); - $this->assertSame(['level' => 'h6', 'text' => 'Sincerely'], $response[1]['content']); - $this->assertSame('heading', $response[1]['type']); - } - - public function testRouteFieldset() - { - $field = $this->field('blocks'); - $route = $field->routes()[2]; - - $response = $route['action']('text'); - - $this->assertSame(['text' => ''], $response['content']); - $this->assertArrayHasKey('id', $response); - $this->assertFalse($response['isHidden']); - $this->assertSame('text', $response['type']); - } - public function testToStoredValue() { $value = [ diff --git a/tests/Form/Fields/LayoutFieldTest.php b/tests/Form/Fields/LayoutFieldTest.php index 9e0f1199ff..5f9d308cba 100644 --- a/tests/Form/Fields/LayoutFieldTest.php +++ b/tests/Form/Fields/LayoutFieldTest.php @@ -7,6 +7,16 @@ class LayoutFieldTest extends TestCase { + public function testApi() + { + $field = $this->field('layout'); + + $routes = $field->api(); + + $this->assertIsArray($routes); + $this->assertCount(7, $routes); + } + public function testDefaultProps() { $field = $this->field('layout', []); @@ -119,16 +129,6 @@ public function testProps() $this->assertSame([['1/1']], $props['layouts']); } - public function testRoutes() - { - $field = $this->field('layout'); - - $routes = $field->routes(); - - $this->assertIsArray($routes); - $this->assertCount(7, $routes); - } - public function testToStoredValue() { $value = [ From 05f1e4d4aeb05ef8aa1964e4bf942707e2bfc4f9 Mon Sep 17 00:00:00 2001 From: Bastian Allgeier Date: Mon, 2 Dec 2024 18:25:40 +0100 Subject: [PATCH 04/15] Switch back to static methods --- src/Form/Field.php | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/Form/Field.php b/src/Form/Field.php index ebaf3150b0..fd5fa4a23f 100644 --- a/src/Form/Field.php +++ b/src/Form/Field.php @@ -6,6 +6,7 @@ use Kirby\Exception\InvalidArgumentException; use Kirby\Toolkit\A; use Kirby\Toolkit\Component; +use Kirby\Toolkit\I18n; /** * Form Field object that takes a Vue component style @@ -100,7 +101,7 @@ public static function defaults(): array * Optional text that will be shown after the input */ 'after' => function (array|string|null $after = null) { - return $this->i18n($after); + return I18n::translate($after, $after); }, /** * Sets the focus on this field when the form loads. Only the first field with this label gets @@ -112,7 +113,7 @@ public static function defaults(): array * Optional text that will be shown before the input */ 'before' => function (array|string|null $before = null) { - return $this->i18n($before); + return I18n::translate($before, $before); }, /** * Default value for the field, which will be used when a page/file/user is created @@ -130,7 +131,7 @@ public static function defaults(): array * Optional help text below the field */ 'help' => function (array|string|null $help = null) { - return $this->i18n($help); + return I18n::translate($help, $help); }, /** * Optional icon that will be shown at the end of the field @@ -142,13 +143,13 @@ public static function defaults(): array * The field label can be set as string or associative array with translations */ 'label' => function (array|string|null $label = null) { - return $this->i18n($label); + return I18n::translate($label, $label); }, /** * Optional placeholder value that will be shown when the field is empty */ 'placeholder' => function (array|string|null $placeholder = null) { - return $this->i18n($placeholder); + return I18n::translate($placeholder, $placeholder); }, /** * If `true`, the field has to be filled in correctly to be saved. From 3425771641a5eb5089cad9641ce93ffe8669b7ce Mon Sep 17 00:00:00 2001 From: Bastian Allgeier Date: Mon, 2 Dec 2024 18:31:38 +0100 Subject: [PATCH 05/15] Add coverage for the id method --- tests/Form/FieldTestCase.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/Form/FieldTestCase.php b/tests/Form/FieldTestCase.php index 9300d669e2..b407e395f6 100644 --- a/tests/Form/FieldTestCase.php +++ b/tests/Form/FieldTestCase.php @@ -764,6 +764,7 @@ public function testModel() /** * @covers ::name + * @covers ::id */ public function testName() { @@ -774,10 +775,12 @@ public function testName() ); $this->assertSame('the-name', $field->name()); + $this->assertSame('the-name', $field->id()); } /** * @covers ::name + * @covers ::id */ public function testNameWhenNotSet() { @@ -785,6 +788,7 @@ public function testNameWhenNotSet() // the field type should be used as name $this->assertSame('test', $field->name()); + $this->assertSame('test', $field->id()); } /** From 91941b478b19d092e53f211faf42db0a4dc2fda4 Mon Sep 17 00:00:00 2001 From: Bastian Allgeier Date: Mon, 2 Dec 2024 18:36:14 +0100 Subject: [PATCH 06/15] Remove magic param getter and params method --- src/Form/FieldClass.php | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/src/Form/FieldClass.php b/src/Form/FieldClass.php index abf530a43c..a7272de40b 100644 --- a/src/Form/FieldClass.php +++ b/src/Form/FieldClass.php @@ -50,15 +50,6 @@ public function __construct( } } - public function __call(string $param, array $args): mixed - { - if (isset($this->$param) === true) { - return $this->$param; - } - - return $this->params[$param] ?? null; - } - /** * Sets a new value for the field */ @@ -68,14 +59,6 @@ public function fill(mixed $value = null): void $this->errors = null; } - /** - * Returns all original params for the field - */ - public function params(): array - { - return $this->params; - } - /** * Define the props that will be sent to * the Vue component From 229d70948b7709a51550bb0c2cfe723b84b3c178 Mon Sep 17 00:00:00 2001 From: Bastian Allgeier Date: Tue, 3 Dec 2024 11:24:37 +0100 Subject: [PATCH 07/15] More refactoring --- src/Form/Field.php | 23 +++++--- src/Form/FieldClass.php | 70 +--------------------- src/Form/Mixin/Common.php | 105 ++++++++------------------------- src/Form/Mixin/Decorators.php | 108 ++++++++++++++++++++++++++++++++++ src/Form/Mixin/Validation.php | 25 ++++++++ src/Form/Mixin/Value.php | 38 +++++++++++- 6 files changed, 208 insertions(+), 161 deletions(-) create mode 100644 src/Form/Mixin/Decorators.php diff --git a/src/Form/Field.php b/src/Form/Field.php index fd5fa4a23f..d377c685bf 100644 --- a/src/Form/Field.php +++ b/src/Form/Field.php @@ -22,6 +22,7 @@ class Field extends Component { use Mixin\Common; + use Mixin\Decorators; use Mixin\Endpoints; use Mixin\Model; use Mixin\Siblings; @@ -44,8 +45,8 @@ class Field extends Component * @throws \Kirby\Exception\InvalidArgumentException */ public function __construct( - string $type, - array $attrs = [], + protected string $type, + protected array $attrs = [], Fields|null $siblings = null ) { if (isset(static::$types[$type]) === false) { @@ -59,20 +60,22 @@ public function __construct( } $this->setModel($attrs['model'] ?? null); + $this->setName($attrs['name'] ?? null); + $this->setSiblings($attrs['siblings'] ?? null); $this->setValidate($attrs['validate'] ?? []); + // unset the attrs that should no longer be handled + // by the parent constructor, because we already took + // care of them. unset( $attrs['model'], + $attrs['name'], + $attrs['siblings'], + $attrs['type'], $attrs['validate'] ); - // use the type as fallback for the name - $attrs['name'] ??= $type; - $attrs['type'] = $type; - parent::__construct($type, $attrs); - - $this->setSiblings($attrs['siblings'] ?? null); } /** @@ -175,7 +178,7 @@ public static function defaults(): array 'width' => function (string|null $width = null) { return $width; }, - 'value' => function ($value = null) { + 'value' => function (mixed $value = null) { return $value; } ] @@ -274,7 +277,9 @@ public function toArray(): array $array = parent::toArray(); $array['hidden'] = $this->isHidden(); + $array['name'] = $this->name(); $array['saveable'] = $this->isSaveable(); + $array['type'] = $this->type(); ksort($array); diff --git a/src/Form/FieldClass.php b/src/Form/FieldClass.php index a7272de40b..8862547e3c 100644 --- a/src/Form/FieldClass.php +++ b/src/Form/FieldClass.php @@ -16,6 +16,7 @@ abstract class FieldClass { use Mixin\Common; + use Mixin\Decorators; use Mixin\Endpoints; use Mixin\Model; use Mixin\Siblings; @@ -50,15 +51,6 @@ public function __construct( } } - /** - * Sets a new value for the field - */ - public function fill(mixed $value = null): void - { - $this->value = $value; - $this->errors = null; - } - /** * Define the props that will be sent to * the Vue component @@ -86,66 +78,6 @@ public function props(): array ]; } - protected function setAfter(array|string|null $after = null): void - { - $this->after = $this->i18n($after); - } - - protected function setAutofocus(bool $autofocus = false): void - { - $this->autofocus = $autofocus; - } - - protected function setBefore(array|string|null $before = null): void - { - $this->before = $this->i18n($before); - } - - protected function setDefault(mixed $default = null): void - { - $this->default = $default; - } - - protected function setDisabled(bool $disabled = false): void - { - $this->disabled = $disabled; - } - - protected function setHelp(array|string|null $help = null): void - { - $this->help = $this->i18n($help); - } - - protected function setIcon(string|null $icon = null): void - { - $this->icon = $icon; - } - - protected function setLabel(array|string|null $label = null): void - { - $this->label = $this->i18n($label); - } - - protected function setName(string|null $name = null): void - { - $this->name = $name; - } - - protected function setPlaceholder(array|string|null $placeholder = null): void - { - $this->placeholder = $this->i18n($placeholder); - } - - protected function setRequired(bool $required = false): void - { - $this->required = $required; - } - - protected function setWidth(string|null $width = null): void - { - $this->width = $width; - } - /** * Converts the field to a plain array */ diff --git a/src/Form/Mixin/Common.php b/src/Form/Mixin/Common.php index cffc53eae4..eb1e8d846c 100644 --- a/src/Form/Mixin/Common.php +++ b/src/Form/Mixin/Common.php @@ -5,28 +5,20 @@ use Kirby\Toolkit\I18n; use Kirby\Toolkit\Str; +/** + * @package Kirby Form + * @author Bastian Allgeier + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ trait Common { - protected string|array|null $after = null; protected bool $autofocus = false; - protected string|array|null $before = null; protected bool $disabled = false; - protected string|array|null $help = null; - protected string|null $icon = null; - protected string|array|null $label = null; protected string|null $name = null; - protected string|array|null $placeholder = null; - protected bool $required = false; protected string|null $width = null; - /** - * Optional text that will be shown after the input - */ - public function after(): string|null - { - return $this->stringTemplate($this->after); - } - /** * Sets the focus on this field when the form loads. Only the first field with this label gets focused */ @@ -35,14 +27,6 @@ public function autofocus(): bool return $this->autofocus; } - /** - * Optional text that will be shown before the input - */ - public function before(): string|null - { - return $this->stringTemplate($this->before); - } - /** * @deprecated 5.0.0 Use `::isDisabled` instead */ @@ -52,33 +36,11 @@ public function disabled(): bool } /** - * Optional help text below the field - */ - public function help(): string|null - { - if (empty($this->help) === false) { - return $this->kirby()->kirbytext( - $this->stringTemplate($this->help, safe: true) - ); - } - - return null; - } - - /** - * Translate field parameters + * Helper to translate field parameters */ protected function i18n(string|array|null $param = null): string|null { - return empty($param) === false ? I18n::translate($param, $param) : null; - } - - /** - * Optional icon that will be shown at the end of the field - */ - public function icon(): string|null - { - return $this->icon; + return I18n::translate($param, $param); } /** @@ -106,62 +68,43 @@ public function isHidden(): bool } /** - * If `true`, the field has to be filled in correctly to be saved. + * Returns the field name and falls back to the type if no name is given */ - public function isRequired(): bool - { - return $this->required; - } - - /** - * Checks if the field is saveable - */ - public function isSaveable(): bool - { - return true; - } - - /** - * The field label can be set as string or associative array with translations - */ - public function label(): string|null + public function name(): string { - return $this->stringTemplate( - $this->label ?? Str::ucfirst($this->name()) - ); + return $this->name ?? $this->type(); } /** - * Returns the field name + * Setter for the autofocus state */ - public function name(): string + protected function setAutofocus(bool $autofocus = false): void { - return $this->name ?? $this->type(); + $this->autofocus = $autofocus; } /** - * Optional placeholder value that will be shown when the field is empty + * Setter for the disabled state */ - public function placeholder(): string|null + protected function setDisabled(bool $disabled = false): void { - return $this->stringTemplate($this->placeholder); + $this->disabled = $disabled; } /** - * @deprecated 5.0.0 Use `::isRequired` instead + * Setter for the name property */ - public function required(): bool + protected function setName(string|null $name = null): void { - return $this->isRequired(); + $this->name = $name; } /** - * Checks if the field is saveable - * @deprecated 5.0.0 Use `::isSaveable()` instead + * Setter for the field width. See `::width()` for available widths */ - public function save(): bool + protected function setWidth(string|null $width = null): void { - return $this->isSaveable(); + $this->width = $width; } /** diff --git a/src/Form/Mixin/Decorators.php b/src/Form/Mixin/Decorators.php new file mode 100644 index 0000000000..6793769e88 --- /dev/null +++ b/src/Form/Mixin/Decorators.php @@ -0,0 +1,108 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +trait Decorators +{ + protected string|array|null $after = null; + protected string|array|null $before = null; + protected string|array|null $help = null; + protected string|null $icon = null; + protected string|array|null $label = null; + protected string|array|null $placeholder = null; + + /** + * Optional text that will be shown after the input + */ + public function after(): string|null + { + return $this->stringTemplate($this->after); + } + + /** + * Optional text that will be shown before the input + */ + public function before(): string|null + { + return $this->stringTemplate($this->before); + } + + /** + * Optional help text below the field + */ + public function help(): string|null + { + if (empty($this->help) === false) { + return $this->kirby()->kirbytext( + $this->stringTemplate($this->help, safe: true) + ); + } + + return null; + } + + /** + * Optional icon that will be shown at the end of the field + */ + public function icon(): string|null + { + return $this->icon; + } + + /** + * The field label can be set as string or associative array with translations + */ + public function label(): string|null + { + return $this->stringTemplate( + $this->label ?? Str::ucfirst($this->name()) + ); + } + + /** + * Optional placeholder value that will be shown when the field is empty + */ + public function placeholder(): string|null + { + return $this->stringTemplate($this->placeholder); + } + + protected function setAfter(array|string|null $after = null): void + { + $this->after = $this->i18n($after); + } + + protected function setBefore(array|string|null $before = null): void + { + $this->before = $this->i18n($before); + } + + protected function setHelp(array|string|null $help = null): void + { + $this->help = $this->i18n($help); + } + + protected function setIcon(string|null $icon = null): void + { + $this->icon = $icon; + } + + protected function setLabel(array|string|null $label = null): void + { + $this->label = $this->i18n($label); + } + + protected function setPlaceholder(array|string|null $placeholder = null): void + { + $this->placeholder = $this->i18n($placeholder); + } +} diff --git a/src/Form/Mixin/Validation.php b/src/Form/Mixin/Validation.php index 5da7852fc3..1cfdf96d5b 100644 --- a/src/Form/Mixin/Validation.php +++ b/src/Form/Mixin/Validation.php @@ -22,6 +22,7 @@ trait Validation * An array of all found errors */ protected array|null $errors = null; + protected bool $required = false; protected array $validate = []; /** @@ -41,6 +42,14 @@ public function isInvalid(): bool return $this->isValid() === false; } + /** + * If `true`, the field has to be filled in correctly to be saved. + */ + public function isRequired(): bool + { + return $this->required; + } + /** * Checks if the field is valid */ @@ -49,6 +58,22 @@ public function isValid(): bool return $this->errors() === []; } + /** + * @deprecated 5.0.0 Use `::isRequired` instead + */ + public function required(): bool + { + return $this->isRequired(); + } + + /** + * Set the required state + */ + protected function setRequired(bool $required = false): void + { + $this->required = $required; + } + /** * Set custom validation rules for the field */ diff --git a/src/Form/Mixin/Value.php b/src/Form/Mixin/Value.php index 0c8983f281..88a7fce4df 100644 --- a/src/Form/Mixin/Value.php +++ b/src/Form/Mixin/Value.php @@ -11,7 +11,7 @@ */ trait Value { - protected mixed $default; + protected mixed $default = null; protected mixed $value = null; /** @@ -31,7 +31,16 @@ public function default(): mixed return $this->default; } - return $this->model->toString($this->default); + return $this->stringTemplate($this->default); + } + + /** + * Sets a new value for the field + */ + public function fill(mixed $value = null): void + { + $this->value = $value; + $this->errors = null; } /** @@ -50,6 +59,14 @@ public function isEmptyValue(mixed $value = null): bool return in_array($value, [null, '', []], true); } + /** + * Checks if the field is saveable + */ + public function isSaveable(): bool + { + return true; + } + /** * Checks if the field needs a value before being saved; * this is the case if all of the following requirements are met: @@ -72,6 +89,23 @@ public function needsValue(): bool return true; } + /** + * Checks if the field is saveable + * @deprecated 5.0.0 Use `::isSaveable()` instead + */ + public function save(): bool + { + return $this->isSaveable(); + } + + /** + * Setter for the default value property + */ + protected function setDefault(mixed $default = null): void + { + $this->default = $default; + } + /** * Returns the value of the field in a format to be used in forms * @alias for `::value()` From a7fedbd5c7f0c3baa6434b79ffaee01bf3bf1682 Mon Sep 17 00:00:00 2001 From: Bastian Allgeier Date: Tue, 3 Dec 2024 11:56:21 +0100 Subject: [PATCH 08/15] Translation state for languages --- src/Form/Mixin/Translatable.php | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/Form/Mixin/Translatable.php b/src/Form/Mixin/Translatable.php index 05d8cdf987..05cd2407bd 100644 --- a/src/Form/Mixin/Translatable.php +++ b/src/Form/Mixin/Translatable.php @@ -2,6 +2,8 @@ namespace Kirby\Form\Mixin; +use Kirby\Cms\Language; + /** * @package Kirby Form * @author Bastian Allgeier @@ -13,6 +15,32 @@ trait Translatable { protected bool $translate = true; + /** + * Checks if the field can be translated into the + * given language + */ + public function isTranslatableInto(Language $language): bool + { + // fields are always active in the default language + if ($language->isDefault() === true) { + return true; + } + + // for other languages, it depends on the `translate` option + // so far, this is only a boolean, but could be an array + // of language codes later + return $this->translate() === true; + } + + /** + * Checks if the field can be translated into the + * currently active language + */ + public function isTranslatableIntoCurrentLanguage(): bool + { + return $this->isTranslatableInto(Language::ensure('current')); + } + /** * Set the translatable status */ From 4c4b93a136a31b3bbfa815e16f50c7279e42e8e4 Mon Sep 17 00:00:00 2001 From: Bastian Allgeier Date: Tue, 3 Dec 2024 14:16:23 +0100 Subject: [PATCH 09/15] New disabled mixin --- src/Form/Field.php | 37 +++++++++---------- src/Form/FieldClass.php | 1 + src/Form/Mixin/Common.php | 27 +------------- src/Form/Mixin/Disabled.php | 47 ++++++++++++++++++++++++ src/Form/Mixin/Translatable.php | 6 ++- tests/Form/Fields/StructureFieldTest.php | 8 ++-- 6 files changed, 75 insertions(+), 51 deletions(-) create mode 100644 src/Form/Mixin/Disabled.php diff --git a/src/Form/Field.php b/src/Form/Field.php index d377c685bf..0895d69e94 100644 --- a/src/Form/Field.php +++ b/src/Form/Field.php @@ -23,6 +23,7 @@ class Field extends Component { use Mixin\Common; use Mixin\Decorators; + use Mixin\Disabled; use Mixin\Endpoints; use Mixin\Model; use Mixin\Siblings; @@ -83,14 +84,7 @@ public function __construct( */ public function api(): array { - if ( - isset($this->options['api']) === true && - $this->options['api'] instanceof Closure - ) { - return $this->options['api']->call($this); - } - - return []; + return $this->endpoints('api'); } /** @@ -190,26 +184,27 @@ public static function defaults(): array */ public function dialogs(): array { - if ( - isset($this->options['dialogs']) === true && - $this->options['dialogs'] instanceof Closure - ) { - return $this->options['dialogs']->call($this); - } - - return []; + return $this->endpoints('dialogs'); } /** * Returns optional drawer routes for the field */ public function drawers(): array + { + return $this->endpoints('drawers'); + } + + /** + * Helper to get endpoint definitions from the field component options + */ + protected function endpoints(string $type): array { if ( - isset($this->options['drawers']) === true && - $this->options['drawers'] instanceof Closure + isset($this->options[$type]) === true && + $this->options[$type] instanceof Closure ) { - return $this->options['drawers']->call($this); + return $this->options[$type]->call($this); } return []; @@ -276,8 +271,12 @@ public function toArray(): array { $array = parent::toArray(); + // set properties manually that should not be + // auto-generated by the weird component class logic + $array['disabled'] = $this->isDisabled(); $array['hidden'] = $this->isHidden(); $array['name'] = $this->name(); + $array['required'] = $this->isRequired(); $array['saveable'] = $this->isSaveable(); $array['type'] = $this->type(); diff --git a/src/Form/FieldClass.php b/src/Form/FieldClass.php index 8862547e3c..8079b4a695 100644 --- a/src/Form/FieldClass.php +++ b/src/Form/FieldClass.php @@ -17,6 +17,7 @@ abstract class FieldClass { use Mixin\Common; use Mixin\Decorators; + use Mixin\Disabled; use Mixin\Endpoints; use Mixin\Model; use Mixin\Siblings; diff --git a/src/Form/Mixin/Common.php b/src/Form/Mixin/Common.php index eb1e8d846c..c88aa24fd4 100644 --- a/src/Form/Mixin/Common.php +++ b/src/Form/Mixin/Common.php @@ -15,7 +15,6 @@ trait Common { protected bool $autofocus = false; - protected bool $disabled = false; protected string|null $name = null; protected string|null $width = null; @@ -27,14 +26,6 @@ public function autofocus(): bool return $this->autofocus; } - /** - * @deprecated 5.0.0 Use `::isDisabled` instead - */ - public function disabled(): bool - { - return $this->isDisabled(); - } - /** * Helper to translate field parameters */ @@ -51,14 +42,6 @@ public function id(): string return $this->name(); } - /** - * If `true`, the field is no longer editable and will not be saved - */ - public function isDisabled(): bool - { - return $this->disabled; - } - /** * Checks if the field is hidden */ @@ -82,15 +65,7 @@ protected function setAutofocus(bool $autofocus = false): void { $this->autofocus = $autofocus; } - - /** - * Setter for the disabled state - */ - protected function setDisabled(bool $disabled = false): void - { - $this->disabled = $disabled; - } - + /** * Setter for the name property */ diff --git a/src/Form/Mixin/Disabled.php b/src/Form/Mixin/Disabled.php new file mode 100644 index 0000000000..3c85c4ba1f --- /dev/null +++ b/src/Form/Mixin/Disabled.php @@ -0,0 +1,47 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +trait Disabled +{ + protected bool $disabled = false; + + /** + * Returns the custom disabled state + */ + public function disabled(): bool + { + return $this->disabled ?? false; + } + + /** + * If `true`, the field is no longer editable and will not be saved + */ + public function isDisabled(Language|string $language = 'current'): bool + { + if ($this->isTranslatableInto($language) === false) { + return true; + } + + return $this->disabled(); + } + + /** + * Setter for the disabled state + */ + protected function setDisabled(bool $disabled = false): void + { + $this->disabled = $disabled; + } +} diff --git a/src/Form/Mixin/Translatable.php b/src/Form/Mixin/Translatable.php index 05cd2407bd..c57e0b9fd0 100644 --- a/src/Form/Mixin/Translatable.php +++ b/src/Form/Mixin/Translatable.php @@ -19,8 +19,10 @@ trait Translatable * Checks if the field can be translated into the * given language */ - public function isTranslatableInto(Language $language): bool + public function isTranslatableInto(Language|string $language = 'current'): bool { + $language = Language::ensure($language); + // fields are always active in the default language if ($language->isDefault() === true) { return true; @@ -38,7 +40,7 @@ public function isTranslatableInto(Language $language): bool */ public function isTranslatableIntoCurrentLanguage(): bool { - return $this->isTranslatableInto(Language::ensure('current')); + return $this->isTranslatableInto('current'); } /** diff --git a/tests/Form/Fields/StructureFieldTest.php b/tests/Form/Fields/StructureFieldTest.php index 9013bd2e69..052b3500d8 100644 --- a/tests/Form/Fields/StructureFieldTest.php +++ b/tests/Form/Fields/StructureFieldTest.php @@ -366,13 +366,13 @@ public function testTranslate() $app->setCurrentLanguage('en'); - $this->assertFalse($field->form()->fields()->a()->disabled()); - $this->assertFalse($field->form()->fields()->b()->disabled()); + $this->assertFalse($field->form()->fields()->a()->isDisabled()); + $this->assertFalse($field->form()->fields()->b()->isDisabled()); $app->setCurrentLanguage('de'); - $this->assertFalse($field->form()->fields()->a()->disabled()); - $this->assertTrue($field->form()->fields()->b()->disabled()); + $this->assertFalse($field->form()->fields()->a()->isDisabled()); + $this->assertTrue($field->form()->fields()->b()->isDisabled()); } public function testDefault() From 5c43d14da11f22aef3e43dbea3f70f456d7e18cd Mon Sep 17 00:00:00 2001 From: Bastian Allgeier Date: Tue, 3 Dec 2024 14:16:48 +0100 Subject: [PATCH 10/15] Stop unsetting existing components --- src/Toolkit/Component.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Toolkit/Component.php b/src/Toolkit/Component.php index fa22bf61cc..c084b09bf4 100644 --- a/src/Toolkit/Component.php +++ b/src/Toolkit/Component.php @@ -151,7 +151,7 @@ protected function applyProp(string $name, mixed $value): void { // unset prop if ($value === null) { - unset($this->props[$name], $this->$name); + unset($this->props[$name]); return; } From a89d896a55e6ab8483210bbfa93be223724b37fa Mon Sep 17 00:00:00 2001 From: Bastian Allgeier Date: Tue, 3 Dec 2024 16:31:27 +0100 Subject: [PATCH 11/15] Different experiments with new reform class --- src/Form/Field.php | 65 ++++++++++-------- src/Form/FieldClass.php | 12 ---- src/Form/Fields.php | 50 ++++++++++++++ src/Form/Form.php | 1 - src/Form/Mixin/Common.php | 33 ++++++++- src/Form/Mixin/Value.php | 22 ++++++ src/Form/Reform.php | 103 ++++++++++++++++++++++++++++ tests/Form/Fields/DateFieldTest.php | 2 +- 8 files changed, 243 insertions(+), 45 deletions(-) create mode 100644 src/Form/Reform.php diff --git a/src/Form/Field.php b/src/Form/Field.php index 0895d69e94..c57c8658c3 100644 --- a/src/Form/Field.php +++ b/src/Form/Field.php @@ -4,7 +4,6 @@ use Closure; use Kirby\Exception\InvalidArgumentException; -use Kirby\Toolkit\A; use Kirby\Toolkit\Component; use Kirby\Toolkit\I18n; @@ -87,6 +86,14 @@ public function api(): array return $this->endpoints('api'); } + /** + * Get a component option from the original field component definition + */ + protected function componentOption(string $option, mixed $fallback = null): mixed + { + return static::$types[$this->type][$option] ?? $fallback; + } + /** * Default props and computed of the field */ @@ -200,14 +207,13 @@ public function drawers(): array */ protected function endpoints(string $type): array { - if ( - isset($this->options[$type]) === true && - $this->options[$type] instanceof Closure - ) { - return $this->options[$type]->call($this); + $endpoints = $this->componentOption($type, []); + + if ($endpoints instanceof Closure) { + return $endpoints->call($this); } - return []; + return $endpoints; } /** @@ -236,11 +242,14 @@ public function fill(mixed $value): static // overwrite the attribute value $this->value = $this->attrs['value'] = $value; + $props = $this->componentOption('props', []); + $computed = $this->componentOption('computed', []); + // reevaluate the value prop - $this->applyProp('value', $this->options['props']['value'] ?? $value); + $this->applyProp('value', $props['value'] ?? $value); // reevaluate the computed props - $this->applyComputed($this->options['computed'] ?? []); + $this->applyComputed($computed); // reset the errors cache $this->errors = null; @@ -253,7 +262,7 @@ public function fill(mixed $value): static */ public function isHidden(): bool { - return ($this->options['hidden'] ?? false) === true; + return $this->componentOption('hidden', false) === true; } /** @@ -261,31 +270,27 @@ public function isHidden(): bool */ public function isSaveable(): bool { - return ($this->options['save'] ?? true) !== false; + return $this->componentOption('save', true) !== false; } /** - * Converts the field to a plain array + * Return field props, which can be used in our + * frontend components */ - public function toArray(): array + public function props(): array { - $array = parent::toArray(); + $props = parent::toArray(); // set properties manually that should not be // auto-generated by the weird component class logic - $array['disabled'] = $this->isDisabled(); - $array['hidden'] = $this->isHidden(); - $array['name'] = $this->name(); - $array['required'] = $this->isRequired(); - $array['saveable'] = $this->isSaveable(); - $array['type'] = $this->type(); - - ksort($array); - - return array_filter( - $array, - fn ($item) => $item !== null && is_object($item) === false - ); + $props['disabled'] = $this->isDisabled(); + $props['hidden'] = $this->isHidden(); + $props['name'] = $this->name(); + $props['required'] = $this->isRequired(); + $props['saveable'] = $this->isSaveable(); + $props['type'] = $this->type(); + + return $props; } /** @@ -293,8 +298,8 @@ public function toArray(): array */ public function toStoredValue(bool $default = false): mixed { - $value = $this->value($default); - $store = $this->options['save'] ?? true; + $value = $this->toFormValue($default); + $store = $this->componentOption('save', true); if ($store === false) { return null; @@ -312,6 +317,6 @@ public function toStoredValue(bool $default = false): mixed */ protected function validations(): array { - return $this->options['validations'] ?? []; + return $this->componentOption('validations', []); } } diff --git a/src/Form/FieldClass.php b/src/Form/FieldClass.php index 8079b4a695..0da6e54818 100644 --- a/src/Form/FieldClass.php +++ b/src/Form/FieldClass.php @@ -79,18 +79,6 @@ public function props(): array ]; } - /** - * Converts the field to a plain array - */ - public function toArray(): array - { - $props = $this->props(); - - ksort($props); - - return array_filter($props, fn ($item) => $item !== null); - } - /** * Returns the field type */ diff --git a/src/Form/Fields.php b/src/Form/Fields.php index 5cfea91be0..e30d9e245b 100644 --- a/src/Form/Fields.php +++ b/src/Form/Fields.php @@ -3,6 +3,7 @@ namespace Kirby\Form; use Closure; +use Kirby\Cms\Language; use Kirby\Cms\ModelWithContent; use Kirby\Toolkit\A; use Kirby\Toolkit\Collection; @@ -162,6 +163,38 @@ public function findByKeyRecursive(string $key): Field|FieldClass|null return $field; } + /** + * Sets the value for each field with a matching key in the input array + */ + public function submit( + array $input, + Language|string $language = 'default' + ): static { + foreach ($input as $name => $value) { + $this->get($name)?->submit( + value: $value, + language: $language + ); + } + + // reset the errors cache + $this->errors = null; + + return $this; + } + + /** + * Filter fields by whether they are translatable into the given language + */ + public function translatable(Language|string $language = 'default'): static + { + $language = Language::ensure($language); + + return $this->filter(function ($field) use ($language) { + return $field->isTranslatableInto($language); + }); + } + /** * Converts the fields collection to an * array and also does that for every @@ -172,6 +205,14 @@ public function toArray(Closure|null $map = null): array return A::map($this->data, $map ?? fn ($field) => $field->toArray()); } + /** + * Returns an array with the default value of each field + */ + public function toDefaultValues(): array + { + return $this->toArray(fn ($field) => $field->default()); + } + /** * Returns an array with the form value of each field */ @@ -180,6 +221,15 @@ public function toFormValues(bool $defaults = false): array return $this->toArray(fn ($field) => $field->toFormValue($defaults)); } + /** + * Return field props for each field, which can be used in our + * frontend components + */ + public function toProps(): array + { + return $this->toArray(fn ($field) => $field->toProps()); + } + /** * Returns an array with the stored value of each field */ diff --git a/src/Form/Form.php b/src/Form/Form.php index 044b65dd38..b3d31c2aaf 100644 --- a/src/Form/Form.php +++ b/src/Form/Form.php @@ -4,7 +4,6 @@ use Closure; use Kirby\Cms\App; -use Kirby\Cms\File; use Kirby\Cms\ModelWithContent; use Kirby\Data\Data; use Kirby\Exception\NotFoundException; diff --git a/src/Form/Mixin/Common.php b/src/Form/Mixin/Common.php index c88aa24fd4..663b2f52e2 100644 --- a/src/Form/Mixin/Common.php +++ b/src/Form/Mixin/Common.php @@ -65,7 +65,7 @@ protected function setAutofocus(bool $autofocus = false): void { $this->autofocus = $autofocus; } - + /** * Setter for the name property */ @@ -82,6 +82,37 @@ protected function setWidth(string|null $width = null): void $this->width = $width; } + /** + * Converts the field to a plain array + */ + public function toArray(): array + { + return $this->toProps(); + } + + /** + * Return field props, which can be used in our + * frontend components + */ + public function toProps(): array + { + $props = $this->props(); + + // don't include the value + // values must be extracted with the toFormValue and toStoreValue methods. + unset($props['value']); + + // sort all props by key alphabetically + // for better debuggability + ksort($props); + + // remove null values and unintentional objects + return array_filter( + $props, + fn ($item) => $item !== null && is_object($item) === false + ); + } + /** * The width of the field in the field grid. Available widths: `1/1`, `1/2`, `1/3`, `1/4`, `2/3`, `3/4` */ diff --git a/src/Form/Mixin/Value.php b/src/Form/Mixin/Value.php index 88a7fce4df..ed8a40613b 100644 --- a/src/Form/Mixin/Value.php +++ b/src/Form/Mixin/Value.php @@ -2,6 +2,9 @@ namespace Kirby\Form\Mixin; +use Kirby\Cms\Language; +use Kirby\Exception\PermissionException; + /** * @package Kirby Form * @author Bastian Allgeier @@ -106,6 +109,25 @@ protected function setDefault(mixed $default = null): void $this->default = $default; } + /** + * Tries to set a new field value, but will throw an exception + * if the field is not submittable. + */ + public function submit( + mixed $value = null, + Language|string $language = 'default', + ): void { + if ($this->isSaveable() === false) { + throw new PermissionException('The "' . $this->name() . '" field cannot be saved'); + } + + if ($this->isDisabled($language) === true) { + throw new PermissionException('The "' . $this->name() . '" field is disabled and cannot be submitted'); + } + + $this->fill($value); + } + /** * Returns the value of the field in a format to be used in forms * @alias for `::value()` diff --git a/src/Form/Reform.php b/src/Form/Reform.php new file mode 100644 index 0000000000..e5d13d796d --- /dev/null +++ b/src/Form/Reform.php @@ -0,0 +1,103 @@ +model = $model; + $this->fields = new Fields($fields ?? $model->blueprint()->fields(), $model); + $this->language = Language::ensure($language); + } + + /** + * An array of all found errors + */ + public function errors(): array + { + return $this->fields->errors(); + } + + /** + * Returns a collection with all form fields + */ + public function fields(): Fields + { + return $this->fields; + } + + /** + * Sets the value for each field with a matching key in the input array + */ + public function fill(array|Version|Content|ModelWithContent $input): static + { + $values = match(true) { + $input instanceof ModelWithContent + => $input->content()->toArray(), + $input instanceof Version + => $input->content()->toArray(), + $input instanceof Content + => $input->toArray(), + default => $input, + }; + + $this->fields->fill($values); + return $this; + } + + /** + * Checks if the form is invalid + */ + public function isInvalid(): bool + { + return $this->isValid() === false; + } + + /** + * Checks if the form is valid + */ + public function isValid(): bool + { + return $this->fields->errors() === []; + } + + /** + * Sets the value for each field with a matching key in the input array + */ + public function submit(array $input): static { + // only submit values for translatable fields + $this + ->fields + ->filter(fn($field) => $field->isDisabled($this->language) === false) + ->submit($input, $this->language); + return $this; + } + + /** + * Returns an array with the form value of each field + */ + public function toFormValues(bool $defaults = false): array + { + return $this->fields->toFormValues($defaults); + } + + /** + * Returns an array with the stored value of each field + */ + public function toStoredValues(bool $defaults = false): array + { + return $this->fields->translatable($this->language)->toStoredValues($defaults); + } +} diff --git a/tests/Form/Fields/DateFieldTest.php b/tests/Form/Fields/DateFieldTest.php index fb5715d28e..2b9330aa58 100644 --- a/tests/Form/Fields/DateFieldTest.php +++ b/tests/Form/Fields/DateFieldTest.php @@ -143,7 +143,7 @@ public function testSave() 'value' => '12.12.2012', ]); - $this->assertSame('2012-12-12', $field->data()); + $this->assertSame('2012-12-12 00:00:00', $field->data()); // empty value $field = $this->field('date', [ From 828ced7b7016dafb8034dc66088267c2ea39bd49 Mon Sep 17 00:00:00 2001 From: Bastian Allgeier Date: Tue, 3 Dec 2024 17:45:49 +0100 Subject: [PATCH 12/15] Try to apply the new reform class in all places --- src/Api/Controller/Changes.php | 21 ++++++------- src/Cms/AppPlugins.php | 5 +-- src/Content/Version.php | 21 ++++--------- src/Form/Field.php | 36 +++++++++++++--------- src/Form/Field/BlocksField.php | 26 ++++++++-------- src/Form/Field/LayoutField.php | 20 ++++++------ src/Form/FieldClass.php | 6 ++++ src/Form/Reform.php | 24 +++++++++++++-- src/Panel/Model.php | 29 +++++++++++------ src/Toolkit/Component.php | 13 ++++++-- tests/Cms/Api/routes/AccountRoutesTest.php | 3 +- tests/Cms/Api/routes/UsersRoutesTest.php | 1 + tests/Form/FieldTest.php | 1 + tests/Form/Fields/DateFieldTest.php | 2 +- tests/Form/Fields/TestCase.php | 1 + tests/Form/FormTest.php | 2 ++ tests/Form/ValidationsTest.php | 1 + 17 files changed, 129 insertions(+), 83 deletions(-) diff --git a/src/Api/Controller/Changes.php b/src/Api/Controller/Changes.php index 99e207ca88..f28dcecd82 100644 --- a/src/Api/Controller/Changes.php +++ b/src/Api/Controller/Changes.php @@ -6,7 +6,7 @@ use Kirby\Content\Lock; use Kirby\Content\VersionId; use Kirby\Filesystem\F; -use Kirby\Form\Form; +use Kirby\Form\Reform; /** * The Changes controller takes care of the request logic @@ -90,13 +90,13 @@ public static function publish(ModelWithContent $model, array $input): array */ public static function save(ModelWithContent $model, array $input): array { - // we need to run the input through the form - // class to get a set of storable field values - // that we can send to the content storage handler - $form = Form::for($model, [ - 'ignoreDisabled' => true, - 'input' => $input, - ]); + $form = new Reform( + model: $model, + language: 'current' + ); + + $form->fill($model); + $form->submit($input); $changes = $model->version(VersionId::changes()); $latest = $model->version(VersionId::latest()); @@ -108,10 +108,7 @@ public static function save(ModelWithContent $model, array $input): array // combine the new field changes with the // last published state $changes->save( - fields: [ - ...$latest->read(), - ...$form->strings(), - ], + fields: $form->toStoredValues(), language: 'current' ); diff --git a/src/Cms/AppPlugins.php b/src/Cms/AppPlugins.php index d5b5f016dc..8414a2f73f 100644 --- a/src/Cms/AppPlugins.php +++ b/src/Cms/AppPlugins.php @@ -813,8 +813,9 @@ protected function extensionsFromSystem(): void { // Always start with fresh fields and sections // from the core and add plugins on top of that - FormField::$types = []; - Section::$types = []; + FormField::$types = []; + FormField::$setups = []; + Section::$types = []; // mixins FormField::$mixins = $this->core->fieldMixins(); diff --git a/src/Content/Version.php b/src/Content/Version.php index 79fcc24c55..34f69e3212 100644 --- a/src/Content/Version.php +++ b/src/Content/Version.php @@ -2,7 +2,6 @@ namespace Kirby\Content; -use Kirby\Cms\File; use Kirby\Cms\Language; use Kirby\Cms\Languages; use Kirby\Cms\ModelWithContent; @@ -10,7 +9,7 @@ use Kirby\Cms\Site; use Kirby\Exception\LogicException; use Kirby\Exception\NotFoundException; -use Kirby\Form\Form; +use Kirby\Form\Reform; use Kirby\Http\Uri; /** @@ -216,21 +215,13 @@ public function isIdentical( $b['uuid'] ); - $a = Form::for( + $form = new Reform( model: $this->model, - props: [ - 'language' => $language->code(), - 'values' => $a, - ] - )->values(); + language: $language, + ); - $b = Form::for( - model: $this->model, - props: [ - 'language' => $language->code(), - 'values' => $b - ] - )->values(); + $a = $form->fill($a)->toFormValues(); + $b = $form->fill($b)->toFormValues(); ksort($a); ksort($b); diff --git a/src/Form/Field.php b/src/Form/Field.php index c57c8658c3..1ad4bc3ff2 100644 --- a/src/Form/Field.php +++ b/src/Form/Field.php @@ -91,7 +91,7 @@ public function api(): array */ protected function componentOption(string $option, mixed $fallback = null): mixed { - return static::$types[$this->type][$option] ?? $fallback; + return static::setup($this->type)[$option] ?? $fallback; } /** @@ -162,7 +162,7 @@ public static function defaults(): array return $required; }, /** - * If `false`, the field will be disabled in non-default languages and cannot be translated. This is only relevant in multi-language setups. + * If `false`, the ield will be disabled in non-default languages and cannot be translated. This is only relevant in multi-language setups. */ 'translate' => function (bool $translate = true): bool { return $translate; @@ -279,18 +279,26 @@ public function isSaveable(): bool */ public function props(): array { - $props = parent::toArray(); - - // set properties manually that should not be - // auto-generated by the weird component class logic - $props['disabled'] = $this->isDisabled(); - $props['hidden'] = $this->isHidden(); - $props['name'] = $this->name(); - $props['required'] = $this->isRequired(); - $props['saveable'] = $this->isSaveable(); - $props['type'] = $this->type(); - - return $props; + return [ + ...parent::toArray(), + 'after' => $this->after(), + 'autofocus' => $this->autofocus(), + 'before' => $this->before(), + 'default' => $this->default(), + 'disabled' => $this->isDisabled(), + 'help' => $this->help(), + 'hidden' => $this->isHidden(), + 'icon' => $this->icon(), + 'label' => $this->label(), + 'name' => $this->name(), + 'placeholder' => $this->placeholder(), + 'required' => $this->isRequired(), + 'saveable' => $this->isSaveable(), + 'translate' => $this->translate(), + 'type' => $this->type(), + 'when' => $this->when(), + 'width' => $this->width(), + ]; } /** diff --git a/src/Form/Field/BlocksField.php b/src/Form/Field/BlocksField.php index be597ae368..960ce269e1 100644 --- a/src/Form/Field/BlocksField.php +++ b/src/Form/Field/BlocksField.php @@ -12,10 +12,10 @@ use Kirby\Exception\InvalidArgumentException; use Kirby\Exception\NotFoundException; use Kirby\Form\FieldClass; -use Kirby\Form\Form; use Kirby\Form\Mixin\EmptyState; use Kirby\Form\Mixin\Max; use Kirby\Form\Mixin\Min; +use Kirby\Form\Reform; use Kirby\Toolkit\Str; use Throwable; @@ -72,9 +72,8 @@ public function api(): array 'action' => function ( string $fieldsetType ) use ($field): array { - $fields = $field->fields($fieldsetType); - $defaults = $field->form($fields, [])->data(true); - $content = $field->form($fields, $defaults)->values(); + $fields = $field->fields($fieldsetType); + $content = $field->form($fields)->toFormValues(true); return Block::factory([ 'content' => $content, @@ -113,7 +112,7 @@ public function api(): array public function blocksToValues( array $blocks, - string $to = 'values' + string $to = 'toFormValues' ): array { $result = []; $fields = []; @@ -178,14 +177,15 @@ public function fill(mixed $value = null): void $this->errors = null; } - public function form(array $fields, array $input = []): Form + public function form(array $fields, array $input = []): Reform { - return new Form([ - 'fields' => $fields, - 'model' => $this->model, - 'strict' => true, - 'values' => $input, - ]); + $form = new Reform( + model: $this->model, + fields: $fields, + language: 'current' + ); + + return $form->fill($input); } public function isEmpty(): bool @@ -278,7 +278,7 @@ protected function setPretty(bool $pretty = false): void public function toStoredValue(bool $default = false): mixed { $value = $this->toFormValue($default); - $blocks = $this->blocksToValues((array)$value, 'content'); + $blocks = $this->blocksToValues((array)$value, 'toStoredValues'); // returns empty string to avoid storing empty array as string `[]` // and to consistency work with `$field->isEmpty()` diff --git a/src/Form/Field/LayoutField.php b/src/Form/Field/LayoutField.php index 7f0ee41655..93de9a1872 100644 --- a/src/Form/Field/LayoutField.php +++ b/src/Form/Field/LayoutField.php @@ -10,7 +10,7 @@ use Kirby\Data\Data; use Kirby\Data\Json; use Kirby\Exception\InvalidArgumentException; -use Kirby\Form\Form; +use Kirby\Form\Reform; use Kirby\Toolkit\Str; use Throwable; @@ -117,16 +117,16 @@ public function fill(mixed $value = null): void $this->errors = null; } - public function attrsForm(array $input = []): Form + public function attrsForm(array $input = []): Reform { $settings = $this->settings(); - return new Form([ - 'fields' => $settings?->fields() ?? [], - 'model' => $this->model, - 'strict' => true, - 'values' => $input, - ]); + $form = new Reform( + model: $this->model, + fields: $settings?->fields() ?? [] + ); + + return $form->fill($input); } public function layouts(): array|null @@ -282,11 +282,11 @@ public function toStoredValue(bool $default = false): mixed foreach ($value as $layoutIndex => $layout) { if ($this->settings !== null) { - $value[$layoutIndex]['attrs'] = $this->attrsForm($layout['attrs'])->content(); + $value[$layoutIndex]['attrs'] = $this->attrsForm($layout['attrs'])->toStoredValues(); } foreach ($layout['columns'] as $columnIndex => $column) { - $value[$layoutIndex]['columns'][$columnIndex]['blocks'] = $this->blocksToValues($column['blocks'] ?? [], 'content'); + $value[$layoutIndex]['columns'][$columnIndex]['blocks'] = $this->blocksToValues($column['blocks'] ?? [], 'toStoredValues'); } } diff --git a/src/Form/FieldClass.php b/src/Form/FieldClass.php index 0da6e54818..f6b9c5ddd8 100644 --- a/src/Form/FieldClass.php +++ b/src/Form/FieldClass.php @@ -86,4 +86,10 @@ public function type(): string { return lcfirst(basename(str_replace(['\\', 'Field'], ['/', ''], static::class))); } + + public function unset(): bool + { + return false; + } + } diff --git a/src/Form/Reform.php b/src/Form/Reform.php index e5d13d796d..80f6b01d14 100644 --- a/src/Form/Reform.php +++ b/src/Form/Reform.php @@ -6,6 +6,7 @@ use Kirby\Cms\ModelWithContent; use Kirby\Content\Content; use Kirby\Content\Version; +use Kirby\Exception\NotFoundException; class Reform { @@ -30,6 +31,23 @@ public function errors(): array return $this->fields->errors(); } + /** + * Get the field object by name + * and handle nested fields correctly + * + * @throws \Kirby\Exception\NotFoundException + */ + public function field(string $name): Field|FieldClass + { + if ($field = $this->fields->find($name)) { + return $field; + } + + throw new NotFoundException( + message: 'The field could not be found' + ); + } + /** * Returns a collection with all form fields */ @@ -79,7 +97,7 @@ public function isValid(): bool public function submit(array $input): static { // only submit values for translatable fields $this - ->fields + ->fields() ->filter(fn($field) => $field->isDisabled($this->language) === false) ->submit($input, $this->language); return $this; @@ -90,7 +108,7 @@ public function submit(array $input): static { */ public function toFormValues(bool $defaults = false): array { - return $this->fields->toFormValues($defaults); + return $this->fields()->toFormValues($defaults); } /** @@ -98,6 +116,6 @@ public function toFormValues(bool $defaults = false): array */ public function toStoredValues(bool $defaults = false): array { - return $this->fields->translatable($this->language)->toStoredValues($defaults); + return $this->fields()->translatable($this->language)->toStoredValues($defaults); } } diff --git a/src/Panel/Model.php b/src/Panel/Model.php index 6206d4cf76..8ed6732793 100644 --- a/src/Panel/Model.php +++ b/src/Panel/Model.php @@ -6,7 +6,7 @@ use Kirby\Cms\File as CmsFile; use Kirby\Cms\ModelWithContent; use Kirby\Filesystem\Asset; -use Kirby\Form\Form; +use Kirby\Form\Reform; use Kirby\Http\Uri; use Kirby\Toolkit\A; @@ -22,6 +22,8 @@ */ abstract class Model { + protected Reform $form; + public function __construct( protected ModelWithContent $model ) { @@ -44,14 +46,7 @@ public function content(): array $changes = $version->content('current')->toArray(); } - // create a form which will collect the latest values for the model, - // but also pass along unpublished changes as overwrites - return Form::for( - model: $this->model, - props: [ - 'values' => $changes - ] - )->values(); + return $this->form()->fill($changes)->toFormValues(); } /** @@ -109,6 +104,20 @@ public function dropdownOption(): array ]; } + public function form(): Reform + { + if (isset($this->form) === true) { + return $this->form; + } + + $this->form = new Reform( + model: $this->model, + language: 'current' + ); + + return $this->form->fill($this->model); + } + /** * Returns the Panel image definition * @internal @@ -336,7 +345,7 @@ public function options(array $unlock = []): array */ public function originals(): array { - return Form::for(model: $this->model)->values(); + return $this->form()->toFormValues(); } /** diff --git a/src/Toolkit/Component.php b/src/Toolkit/Component.php index c084b09bf4..82fa221470 100644 --- a/src/Toolkit/Component.php +++ b/src/Toolkit/Component.php @@ -30,6 +30,11 @@ class Component */ public static array $mixins = []; + /** + * Cache for all component setups + */ + public static array $setups = []; + /** * Registry for all component types */ @@ -238,6 +243,10 @@ public static function load(string $type): array */ public static function setup(string $type): array { + if (isset(static::$setups[$type]) === true) { + return static::$setups[$type]; + } + // load component definition $definition = static::load($type); @@ -272,7 +281,7 @@ public static function setup(string $type): array } } - return $options; + return static::$setups[$type] = $options; } /** @@ -295,7 +304,7 @@ public function toArray(): array $array = [ ...$this->attrs, - ...$props, + ...$this->props, ...$this->computed ]; diff --git a/tests/Cms/Api/routes/AccountRoutesTest.php b/tests/Cms/Api/routes/AccountRoutesTest.php index a5ed46f71f..d923f12352 100644 --- a/tests/Cms/Api/routes/AccountRoutesTest.php +++ b/tests/Cms/Api/routes/AccountRoutesTest.php @@ -49,7 +49,8 @@ public function setUp(): void public function tearDown(): void { $this->app->session()->destroy(); - Field::$types = []; + Field::$types = []; + Field::$setups = []; Section::$types = []; Dir::remove(static::TMP); App::destroy(); diff --git a/tests/Cms/Api/routes/UsersRoutesTest.php b/tests/Cms/Api/routes/UsersRoutesTest.php index b6433e005a..be0fbc6bee 100644 --- a/tests/Cms/Api/routes/UsersRoutesTest.php +++ b/tests/Cms/Api/routes/UsersRoutesTest.php @@ -51,6 +51,7 @@ public function tearDown(): void { App::destroy(); Field::$types = []; + Field::$setups = []; Section::$types = []; Dir::remove(static::TMP); } diff --git a/tests/Form/FieldTest.php b/tests/Form/FieldTest.php index 2f969723d4..5a8619b630 100644 --- a/tests/Form/FieldTest.php +++ b/tests/Form/FieldTest.php @@ -20,6 +20,7 @@ public function setUp(): void parent::setUp(); Field::$types = []; + Field::$setups = []; // make a backup of the system mixins $this->originalMixins = Field::$mixins; diff --git a/tests/Form/Fields/DateFieldTest.php b/tests/Form/Fields/DateFieldTest.php index 2b9330aa58..fb5715d28e 100644 --- a/tests/Form/Fields/DateFieldTest.php +++ b/tests/Form/Fields/DateFieldTest.php @@ -143,7 +143,7 @@ public function testSave() 'value' => '12.12.2012', ]); - $this->assertSame('2012-12-12 00:00:00', $field->data()); + $this->assertSame('2012-12-12', $field->data()); // empty value $field = $this->field('date', [ diff --git a/tests/Form/Fields/TestCase.php b/tests/Form/Fields/TestCase.php index 21a237e5d3..70a223faaf 100644 --- a/tests/Form/Fields/TestCase.php +++ b/tests/Form/Fields/TestCase.php @@ -14,6 +14,7 @@ public function setUp(): void { // start with a fresh set of fields Field::$types = []; + Field::$setups = []; $this->setUpTmp(); diff --git a/tests/Form/FormTest.php b/tests/Form/FormTest.php index fbe03ae956..fdf40a1d30 100644 --- a/tests/Form/FormTest.php +++ b/tests/Form/FormTest.php @@ -30,6 +30,8 @@ public function setUp(): void $this->model = $this->app->page('test'); $this->setUpTmp(); + + Field::$setups = []; } public function tearDown(): void diff --git a/tests/Form/ValidationsTest.php b/tests/Form/ValidationsTest.php index 3d62ede0e9..e5fc6cc109 100644 --- a/tests/Form/ValidationsTest.php +++ b/tests/Form/ValidationsTest.php @@ -29,6 +29,7 @@ public function setUp(): void public function tearDown(): void { Field::$types = []; + Field::$setups = []; } public function testBooleanValid() From cb74a5e15aeee8e52aa75aace0c55084d5ecb6aa Mon Sep 17 00:00:00 2001 From: Bastian Allgeier Date: Tue, 3 Dec 2024 18:36:51 +0100 Subject: [PATCH 13/15] Fix saving changes --- src/Api/Controller/Changes.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Api/Controller/Changes.php b/src/Api/Controller/Changes.php index f28dcecd82..893d26c32a 100644 --- a/src/Api/Controller/Changes.php +++ b/src/Api/Controller/Changes.php @@ -108,7 +108,10 @@ public static function save(ModelWithContent $model, array $input): array // combine the new field changes with the // last published state $changes->save( - fields: $form->toStoredValues(), + fields: [ + ...$latest->read(), + ...$form->toStoredValues(), + ], language: 'current' ); From bd343f45ca0f0069c425e7a0340b6953bd6ec7af Mon Sep 17 00:00:00 2001 From: Bastian Allgeier Date: Tue, 3 Dec 2024 18:38:19 +0100 Subject: [PATCH 14/15] Improve content and originals props --- src/Panel/Model.php | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/src/Panel/Model.php b/src/Panel/Model.php index 8ed6732793..c22b489582 100644 --- a/src/Panel/Model.php +++ b/src/Panel/Model.php @@ -22,8 +22,6 @@ */ abstract class Model { - protected Reform $form; - public function __construct( protected ModelWithContent $model ) { @@ -43,10 +41,13 @@ public function content(): array $changes = []; if ($version->exists('current') === true) { - $changes = $version->content('current')->toArray(); + $changes = $version->read('current'); } - return $this->form()->fill($changes)->toFormValues(); + return [ + ...$this->originals(), + ...$changes + ]; } /** @@ -106,16 +107,10 @@ public function dropdownOption(): array public function form(): Reform { - if (isset($this->form) === true) { - return $this->form; - } - - $this->form = new Reform( + return new Reform( model: $this->model, - language: 'current' + language: 'current', ); - - return $this->form->fill($this->model); } /** @@ -345,7 +340,7 @@ public function options(array $unlock = []): array */ public function originals(): array { - return $this->form()->toFormValues(); + return $this->model->version('latest')->read('current'); } /** @@ -385,15 +380,22 @@ public function props(): array $request = $this->model->kirby()->request(); $tabs = $blueprint->tabs(); $tab = $blueprint->tab($request->get('tab')) ?? $tabs[0] ?? null; + $form = $this->form(); + + $originals = $this->originals(); + $content = [ + ...$originals, + ...$this->content() + ]; $props = [ 'api' => $link, 'buttons' => fn () => $this->buttons(), - 'content' => (object)$this->content(), + 'content' => (object)$form->fill($content)->toFormValues(), 'id' => $this->model->id(), 'link' => $link, 'lock' => $this->model->lock()->toArray(), - 'originals' => (object)$this->originals(), + 'originals' => (object)$form->fill($originals)->toFormValues(), 'permissions' => $this->model->permissions()->toArray(), 'tabs' => $tabs, 'uuid' => fn () => $this->model->uuid()?->toString() From 5bad17d15ef1b2c2e448cbff07565e547736a6cb Mon Sep 17 00:00:00 2001 From: Bastian Allgeier Date: Tue, 3 Dec 2024 18:46:15 +0100 Subject: [PATCH 15/15] Fix broken values method --- src/Form/Field/LayoutField.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Form/Field/LayoutField.php b/src/Form/Field/LayoutField.php index 93de9a1872..74d4be19a9 100644 --- a/src/Form/Field/LayoutField.php +++ b/src/Form/Field/LayoutField.php @@ -105,7 +105,7 @@ public function fill(mixed $value = null): void foreach ($layouts as $layoutIndex => $layout) { if ($this->settings !== null) { - $layouts[$layoutIndex]['attrs'] = $this->attrsForm($layout['attrs'])->values(); + $layouts[$layoutIndex]['attrs'] = $this->attrsForm($layout['attrs'])->toFormValues(); } foreach ($layout['columns'] as $columnIndex => $column) {