* @link https://getkirby.com * @copyright Bastian Allgeier * @license https://opensource.org/licenses/MIT */ class Field extends Component { /** * An array of all found errors */ protected array|null $errors = null; /** * Parent collection with all fields of the current form */ protected Fields|null $formFields; /** * Registry for all component mixins */ public static array $mixins = []; /** * Registry for all component types */ public static array $types = []; /** * @throws \Kirby\Exception\InvalidArgumentException */ public function __construct( string $type, array $attrs = [], Fields|null $formFields = null ) { if (isset(static::$types[$type]) === false) { throw new InvalidArgumentException([ 'key' => 'field.type.missing', 'data' => ['name' => $attrs['name'] ?? '-', 'type' => $type] ]); } if (isset($attrs['model']) === false) { throw new InvalidArgumentException('Field requires a model'); } $this->formFields = $formFields; // use the type as fallback for the name $attrs['name'] ??= $type; $attrs['type'] = $type; parent::__construct($type, $attrs); } /** * Returns field api call */ public function api(): mixed { if ( isset($this->options['api']) === true && $this->options['api'] instanceof Closure ) { return $this->options['api']->call($this); } return null; } /** * Returns field data */ public function data(bool $default = false): mixed { $save = $this->options['save'] ?? true; if ($default === true && $this->isEmpty($this->value)) { $value = $this->default(); } else { $value = $this->value; } if ($save === false) { return null; } if ($save instanceof Closure) { return $save->call($this, $value); } return $value; } /** * Default props and computed of the field */ public static function defaults(): array { return [ 'props' => [ /** * Optional text that will be shown after the input */ 'after' => function ($after = null) { return I18n::translate($after, $after); }, /** * Sets the focus on this field when the form loads. Only the first field with this label gets */ 'autofocus' => function (bool $autofocus = null): bool { return $autofocus ?? false; }, /** * Optional text that will be shown before the input */ 'before' => function ($before = null) { return I18n::translate($before, $before); }, /** * Default value for the field, which will be used when a page/file/user is created */ 'default' => function ($default = null) { return $default; }, /** * If `true`, the field is no longer editable and will not be saved */ 'disabled' => function (bool $disabled = null): bool { return $disabled ?? false; }, /** * Optional help text below the field */ 'help' => function ($help = null) { return I18n::translate($help, $help); }, /** * Optional icon that will be shown at the end of the field */ 'icon' => function (string $icon = null) { return $icon; }, /** * The field label can be set as string or associative array with translations */ 'label' => function ($label = null) { return I18n::translate($label, $label); }, /** * Optional placeholder value that will be shown when the field is empty */ 'placeholder' => function ($placeholder = null) { return I18n::translate($placeholder, $placeholder); }, /** * If `true`, the field has to be filled in correctly to be saved. */ 'required' => function (bool $required = null): bool { return $required ?? false; }, /** * If `false`, the field 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; }, /** * Conditions when the field will be shown (since 3.1.0) */ 'when' => function ($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') { 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 */ if ($this->label !== null) { return $this->model()->toString($this->label); } }, 'placeholder' => function () { /** @var \Kirby\Form\Field $this */ if ($this->placeholder !== null) { return $this->model()->toString($this->placeholder); } } ] ]; } /** * Returns optional dialog routes for the field */ public function dialogs(): array { if ( isset($this->options['dialogs']) === true && $this->options['dialogs'] instanceof Closure ) { return $this->options['dialogs']->call($this); } return []; } /** * Returns optional drawer routes for the field */ public function drawers(): array { if ( isset($this->options['drawers']) === true && $this->options['drawers'] instanceof Closure ) { return $this->options['drawers']->call($this); } return []; } /** * Creates a new field instance */ public static function factory( string $type, array $attrs = [], Fields|null $formFields = null ): static|FieldClass { $field = static::$types[$type] ?? null; if (is_string($field) && class_exists($field) === true) { $attrs['siblings'] = $formFields; return new $field($attrs); } return new static($type, $attrs, $formFields); } /** * Parent collection with all fields of the current form */ public function formFields(): Fields|null { return $this->formFields; } /** * Validates when run for the first time and returns any errors */ public function errors(): array { if ($this->errors === null) { $this->validate(); } return $this->errors; } /** * Checks if the field is empty */ public function isEmpty(mixed ...$args): bool { $value = match (count($args)) { 0 => $this->value(), default => $args[0] }; if ($empty = $this->options['isEmpty'] ?? null) { return $empty->call($this, $value); } return in_array($value, [null, '', []], true); } /** * Checks if the field is hidden */ public function isHidden(): bool { return ($this->options['hidden'] ?? false) === true; } /** * Checks if the field is invalid */ public function isInvalid(): bool { return empty($this->errors()) === false; } /** * Checks if the field is required */ public function isRequired(): bool { return $this->required ?? false; } /** * Checks if the field is valid */ public function isValid(): bool { return empty($this->errors()) === true; } /** * Returns the Kirby instance */ public function kirby(): App { return $this->model()->kirby(); } /** * Returns the parent model */ public function model(): mixed { return $this->model; } /** * Checks if the field needs a value before being saved; * this is the case if all of the following requirements are met: * - The field is saveable * - The field is required * - The field is currently empty * - The field is not currently inactive because of a `when` rule */ protected function needsValue(): bool { // check simple conditions first if ( $this->save() === false || $this->isRequired() === false || $this->isEmpty() === false ) { return false; } // check the data of the relevant fields if there is a `when` option if ( empty($this->when) === false && is_array($this->when) === true && $formFields = $this->formFields() ) { foreach ($this->when as $field => $value) { $field = $formFields->get($field); $inputValue = $field?->value() ?? ''; // if the input data doesn't match the requested `when` value, // that means that this field is not required and can be saved // (*all* `when` conditions must be met for this field to be required) if ($inputValue !== $value) { return false; } } } // either there was no `when` condition or all conditions matched return true; } /** * Checks if the field is saveable */ public function save(): bool { return ($this->options['save'] ?? true) !== false; } /** * Converts the field to a plain array */ public function toArray(): array { $array = parent::toArray(); unset($array['model']); $array['hidden'] = $this->isHidden(); $array['saveable'] = $this->save(); $array['signature'] = md5(json_encode($array)); ksort($array); return array_filter( $array, fn ($item) => $item !== null && is_object($item) === false ); } /** * Runs the validations defined for the field */ protected function validate(): void { $validations = $this->options['validations'] ?? []; $this->errors = []; // validate required values if ($this->needsValue() === true) { $this->errors['required'] = I18n::translate('error.validation.required'); } foreach ($validations as $key => $validation) { if (is_int($key) === true) { // predefined validation try { Validations::$validation($this, $this->value()); } catch (Exception $e) { $this->errors[$validation] = $e->getMessage(); } continue; } if ($validation instanceof Closure) { try { $validation->call($this, $this->value()); } catch (Exception $e) { $this->errors[$key] = $e->getMessage(); } } } if ( empty($this->validate) === false && ($this->isEmpty() === false || $this->isRequired() === true) ) { $rules = A::wrap($this->validate); $errors = V::errors($this->value(), $rules); if (empty($errors) === false) { $this->errors = array_merge($this->errors, $errors); } } } /** * Returns the value of the field if saveable * otherwise it returns null */ public function value(): mixed { return $this->save() ? $this->value : null; } }