diff --git a/src/Fields/Fieldtype.php b/src/Fields/Fieldtype.php index 55c3855576f..b0eed7b1631 100644 --- a/src/Fields/Fieldtype.php +++ b/src/Fields/Fieldtype.php @@ -354,15 +354,29 @@ public function preProcess($data) } /** - * Generate a fake value for form submission testing. + * A deterministic sample value, used wherever a value is needed for previewing but no real one exists. + * For example, form email previews. * - * Return null to fall back to FakeSubmissionGenerator's built-in handling. + * Return the value in its processed (stored) shape. This may run in production, so it must not use Faker. + * The field's config is available via `$this->field()`. Return null if the fieldtype doesn't collect a value. */ - public function fakeValue(): mixed + public function previewValue(): mixed { return null; } + /** + * A realistic random sample value, used where Faker is available. For example, fake form submissions. + * + * Return the value in its processed (stored) shape. This only runs in local environments. + * Defaults to `previewValue()`, so override it only when you want more realistic data. + * The field's config is available via `$this->field()`. + */ + public function fakeValue(\Faker\Generator $faker): mixed + { + return $this->previewValue(); + } + public function preProcessConfig($data) { return $this->preProcess($data); diff --git a/src/Fields/SampleValue.php b/src/Fields/SampleValue.php new file mode 100644 index 00000000000..ff643c74df3 --- /dev/null +++ b/src/Fields/SampleValue.php @@ -0,0 +1,51 @@ + 0 ? (int) $max : 3, count($keys)); + + return $faker->randomElements($keys, $faker->numberBetween(1, $limit)); + } + + public static function optionKeys(Field $field): array + { + $options = Arr::wrap($field->get('options', [])); + + if ($options === []) { + return []; + } + + if (Arr::isAssoc($options)) { + return array_keys($options); + } + + return array_values(array_filter(array_map(function ($option) { + if (! is_array($option)) { + return $option; + } + + return Arr::get($option, 'key') ?? Arr::get($option, 'value'); + }, $options), fn ($value) => $value !== null && $value !== '')); + } +} diff --git a/src/Fieldtypes/Checkboxes.php b/src/Fieldtypes/Checkboxes.php index 9a48a58fb1a..43ad2bec1eb 100644 --- a/src/Fieldtypes/Checkboxes.php +++ b/src/Fieldtypes/Checkboxes.php @@ -2,7 +2,9 @@ namespace Statamic\Fieldtypes; +use Faker\Generator; use Statamic\Fields\Fieldtype; +use Statamic\Fields\SampleValue; use Statamic\Fieldtypes\Concerns\MigratesLegacyInlineConfig; use function Statamic\trans as __; @@ -78,4 +80,14 @@ public function process($data) ->values() ->all(); } + + public function previewValue(): mixed + { + return array_slice(SampleValue::optionKeys($this->field()), 0, 1); + } + + public function fakeValue(Generator $faker): mixed + { + return SampleValue::randomKeys(SampleValue::optionKeys($this->field()), $faker); + } } diff --git a/src/Fieldtypes/Date.php b/src/Fieldtypes/Date.php index 3d27b639196..20006abb334 100644 --- a/src/Fieldtypes/Date.php +++ b/src/Fieldtypes/Date.php @@ -3,6 +3,7 @@ namespace Statamic\Fieldtypes; use Carbon\Exceptions\InvalidFormatException; +use Faker\Generator; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Validator; use Illuminate\Validation\ValidationException; @@ -481,4 +482,14 @@ private function preProcessRangeValidatable($value) 'end' => Carbon::parse($value['end']), ]; } + + public function previewValue(): mixed + { + return now()->toDateString(); + } + + public function fakeValue(Generator $faker): mixed + { + return $faker->date('Y-m-d'); + } } diff --git a/src/Fieldtypes/Dictionary.php b/src/Fieldtypes/Dictionary.php index 128e33fc090..648859d0cbf 100644 --- a/src/Fieldtypes/Dictionary.php +++ b/src/Fieldtypes/Dictionary.php @@ -2,6 +2,7 @@ namespace Statamic\Fieldtypes; +use Faker\Generator; use Statamic\Dictionaries\Dictionary as DictionaryInstance; use Statamic\Dictionaries\Item; use Statamic\Exceptions\DictionaryNotFoundException; @@ -9,6 +10,7 @@ use Statamic\Facades\Dictionary as Dictionaries; use Statamic\Facades\GraphQL; use Statamic\Fields\Fieldtype; +use Statamic\Fields\SampleValue; use Statamic\Support\Arr; use function Statamic\trans as __; @@ -195,4 +197,39 @@ public function keywords(): array ->merge(['select', 'option', 'choice', 'dropdown', 'list']) ->unique()->values()->all(); } + + public function previewValue(): mixed + { + if (($key = array_key_first($this->sampleOptions())) === null) { + return null; + } + + return $this->field()->get('max_items') === 1 ? $key : [$key]; + } + + public function fakeValue(Generator $faker): mixed + { + $keys = array_keys($this->sampleOptions()); + + if ($keys === []) { + return null; + } + + $maxItems = $this->field()->get('max_items'); + + if ($maxItems === 1) { + return $faker->randomElement($keys); + } + + return SampleValue::randomKeys($keys, $faker, $maxItems); + } + + private function sampleOptions(): array + { + try { + return $this->dictionary()->options(); + } catch (\Throwable) { + return []; + } + } } diff --git a/src/Fieldtypes/Floatval.php b/src/Fieldtypes/Floatval.php index ef1ada985f0..0ebdf86379e 100644 --- a/src/Fieldtypes/Floatval.php +++ b/src/Fieldtypes/Floatval.php @@ -2,6 +2,7 @@ namespace Statamic\Fieldtypes; +use Faker\Generator; use Statamic\Facades\GraphQL; use Statamic\Fields\Fieldtype; use Statamic\Query\Scopes\Filters\Fields\Floatval as FloatFilter; @@ -100,4 +101,16 @@ public function rules(): array return $rules; } + + public function previewValue(): mixed + { + $min = $this->field()->get('min'); + + return is_numeric($min) ? $min + 0 : 1; + } + + public function fakeValue(Generator $faker): mixed + { + return $faker->randomFloat(2, 1, 1000); + } } diff --git a/src/Fieldtypes/Group.php b/src/Fieldtypes/Group.php index 6cbb493637a..9a89f0de8e5 100644 --- a/src/Fieldtypes/Group.php +++ b/src/Fieldtypes/Group.php @@ -2,11 +2,15 @@ namespace Statamic\Fieldtypes; +use Faker\Generator; +use Illuminate\Support\Collection; use Statamic\Data\NestedFieldUpdater; use Statamic\Facades\GraphQL; +use Statamic\Fields\Field; use Statamic\Fields\Fields; use Statamic\Fields\Fieldtype; use Statamic\Fields\Values; +use Statamic\Forms\Fields\FormField; use Statamic\Forms\Fields\FormFields; use Statamic\GraphQL\Types\GroupType; use Statamic\Support\Arr; @@ -224,4 +228,28 @@ public function iterateReferenceFields($data, NestedFieldUpdater $updater): void $updater->update(new Fields($fieldsConfig)); } + + public function previewValue(): mixed + { + return $this->sampleFields() + ->map(fn (Field $field) => $field->fieldtype()->previewValue()) + ->all(); + } + + public function fakeValue(Generator $faker): mixed + { + return $this->sampleFields() + ->map(fn (Field $field) => $field->fieldtype()->fakeValue($faker)) + ->all(); + } + + private function sampleFields(): Collection + { + // TODO: Use $this->fields() once the form Group fieldtype converts its nested fields to core fieldtypes. + return collect($this->field()->get('fields', [])) + ->filter(fn ($config) => is_array($config) && isset($config['handle'], $config['field'])) + ->mapWithKeys(fn (array $config) => [ + $config['handle'] => new Field($config['handle'], (new FormField($config['handle'], $config['field']))->toFieldArray()), + ]); + } } diff --git a/src/Fieldtypes/HasSelectOptions.php b/src/Fieldtypes/HasSelectOptions.php index 0f04e6a38d7..330a9defabd 100644 --- a/src/Fieldtypes/HasSelectOptions.php +++ b/src/Fieldtypes/HasSelectOptions.php @@ -2,9 +2,11 @@ namespace Statamic\Fieldtypes; +use Faker\Generator; use Illuminate\Support\Collection; use Statamic\Facades\GraphQL; use Statamic\Fields\LabeledValue; +use Statamic\Fields\SampleValue; use Statamic\GraphQL\Types\LabeledValueType; use Statamic\Support\Arr; @@ -217,4 +219,24 @@ public function extraRenderableFieldData(): array ->all(), ]; } + + public function previewValue(): mixed + { + $keys = SampleValue::optionKeys($this->field()); + + return $this->multiple() + ? array_slice($keys, 0, 1) + : ($keys[0] ?? null); + } + + public function fakeValue(Generator $faker): mixed + { + $keys = SampleValue::optionKeys($this->field()); + + if ($this->multiple()) { + return SampleValue::randomKeys($keys, $faker, $this->field()->get('max_items')); + } + + return $keys === [] ? null : $faker->randomElement($keys); + } } diff --git a/src/Fieldtypes/Hidden.php b/src/Fieldtypes/Hidden.php index 4026dd556f3..e2ddefdd43b 100644 --- a/src/Fieldtypes/Hidden.php +++ b/src/Fieldtypes/Hidden.php @@ -25,4 +25,9 @@ protected function configFieldItems(): array ], ]; } + + public function previewValue(): mixed + { + return $this->field()->defaultValue(); + } } diff --git a/src/Fieldtypes/ImageChoice.php b/src/Fieldtypes/ImageChoice.php index 1f14c2f4429..f9dc4e014b9 100644 --- a/src/Fieldtypes/ImageChoice.php +++ b/src/Fieldtypes/ImageChoice.php @@ -2,9 +2,11 @@ namespace Statamic\Fieldtypes; +use Faker\Generator; use Statamic\Facades\Asset; use Statamic\Facades\AssetContainer; use Statamic\Fields\Fieldtype; +use Statamic\Fields\SampleValue; use Statamic\Support\Arr; use Statamic\Support\Str; @@ -91,4 +93,24 @@ private function resolveImageUrl(mixed $image): ?string ->first() ?->url(); } + + public function previewValue(): mixed + { + $keys = SampleValue::optionKeys($this->field()); + + return $this->field()->get('multiple') + ? array_slice($keys, 0, 1) + : ($keys[0] ?? null); + } + + public function fakeValue(Generator $faker): mixed + { + if ($this->field()->get('multiple')) { + return SampleValue::randomKeys(SampleValue::optionKeys($this->field()), $faker); + } + + $keys = SampleValue::optionKeys($this->field()); + + return $keys === [] ? null : $faker->randomElement($keys); + } } diff --git a/src/Fieldtypes/Integer.php b/src/Fieldtypes/Integer.php index f65fdf080b6..f2a091890c6 100644 --- a/src/Fieldtypes/Integer.php +++ b/src/Fieldtypes/Integer.php @@ -2,6 +2,7 @@ namespace Statamic\Fieldtypes; +use Faker\Generator; use Statamic\Facades\GraphQL; use Statamic\Fields\Fieldtype; use Statamic\Query\Scopes\Filters\Fields\Integer as IntegerFilter; @@ -109,4 +110,19 @@ public function filter() { return new IntegerFilter($this); } + + public function previewValue(): mixed + { + $min = $this->field()->get('min'); + + return is_numeric($min) ? $min + 0 : 1; + } + + public function fakeValue(Generator $faker): mixed + { + $min = (int) ($this->field()->get('min') ?? 1); + $max = (int) ($this->field()->get('max') ?? 5000); + + return $faker->numberBetween(min($min, $max), max($min, $max)); + } } diff --git a/src/Fieldtypes/Link.php b/src/Fieldtypes/Link.php index 2c59f2c35a1..2610b8751f8 100644 --- a/src/Fieldtypes/Link.php +++ b/src/Fieldtypes/Link.php @@ -3,6 +3,7 @@ namespace Statamic\Fieldtypes; use Facades\Statamic\Routing\ResolveRedirect; +use Faker\Generator; use Statamic\Contracts\Data\Localization; use Statamic\Contracts\Entries\Collection; use Statamic\Contracts\Entries\Entry; @@ -298,4 +299,14 @@ public function replaceAssetReferences($data, ?string $newValue, string $oldValu return $newValue !== null ? "asset::{$container}::{$newValue}" : null; } + + public function previewValue(): mixed + { + return 'https://example.com'; + } + + public function fakeValue(Generator $faker): mixed + { + return $faker->url(); + } } diff --git a/src/Fieldtypes/Lists.php b/src/Fieldtypes/Lists.php index 8c42cb374ad..d2fdf38821e 100644 --- a/src/Fieldtypes/Lists.php +++ b/src/Fieldtypes/Lists.php @@ -2,6 +2,7 @@ namespace Statamic\Fieldtypes; +use Faker\Generator; use Statamic\Facades\GraphQL; use Statamic\Fields\Fieldtype; @@ -57,4 +58,14 @@ public function toGqlType() { return GraphQL::listOf(GraphQL::string()); } + + public function previewValue(): mixed + { + return ['Lorem', 'Ipsum', 'Dolor']; + } + + public function fakeValue(Generator $faker): mixed + { + return [$faker->word(), $faker->word(), $faker->word()]; + } } diff --git a/src/Fieldtypes/Markdown.php b/src/Fieldtypes/Markdown.php index 02360c06ae7..39331d21600 100644 --- a/src/Fieldtypes/Markdown.php +++ b/src/Fieldtypes/Markdown.php @@ -2,9 +2,11 @@ namespace Statamic\Fieldtypes; +use Faker\Generator; use Statamic\Facades\GraphQL; use Statamic\Fields\Field; use Statamic\Fields\Fieldtype; +use Statamic\Fields\SampleValue; use Statamic\Query\Scopes\Filters\Fields\Markdown as MarkdownFilter; use Statamic\Support\Html; @@ -266,4 +268,14 @@ public function replaceAssetReferences($data, ?string $newValue, string $oldValu return $this->replaceStatamicUrls($data, $newValue, $oldValue); } + + public function previewValue(): mixed + { + return SampleValue::paragraph(); + } + + public function fakeValue(Generator $faker): mixed + { + return $faker->paragraph(2); + } } diff --git a/src/Fieldtypes/OpinionScale.php b/src/Fieldtypes/OpinionScale.php index 85b3bd4cd94..9ce1a682ced 100644 --- a/src/Fieldtypes/OpinionScale.php +++ b/src/Fieldtypes/OpinionScale.php @@ -2,6 +2,7 @@ namespace Statamic\Fieldtypes; +use Faker\Generator; use Statamic\Fields\Fieldtype; class OpinionScale extends Fieldtype @@ -26,4 +27,19 @@ private function scaleValues(): array { return range($this->config('min'), $this->config('max')); } + + public function previewValue(): mixed + { + $min = $this->field()->get('min'); + + return is_numeric($min) ? $min + 0 : 1; + } + + public function fakeValue(Generator $faker): mixed + { + $min = (int) ($this->field()->get('min') ?? 0); + $max = (int) ($this->field()->get('max') ?? 10); + + return $faker->numberBetween(min($min, $max), max($min, $max)); + } } diff --git a/src/Fieldtypes/Range.php b/src/Fieldtypes/Range.php index a7bbd249988..9bf7559e621 100644 --- a/src/Fieldtypes/Range.php +++ b/src/Fieldtypes/Range.php @@ -2,6 +2,7 @@ namespace Statamic\Fieldtypes; +use Faker\Generator; use Statamic\Facades\GraphQL; use Statamic\Fields\Fieldtype; @@ -109,4 +110,19 @@ public function toGqlType() { return $this->usesDecimals() ? GraphQL::float() : GraphQL::int(); } + + public function previewValue(): mixed + { + $min = $this->field()->get('min'); + + return is_numeric($min) ? $min + 0 : 1; + } + + public function fakeValue(Generator $faker): mixed + { + $min = (int) ($this->field()->get('min') ?? 1); + $max = (int) ($this->field()->get('max') ?? 5000); + + return $faker->numberBetween(min($min, $max), max($min, $max)); + } } diff --git a/src/Fieldtypes/Ranking.php b/src/Fieldtypes/Ranking.php index bc9699bdc3a..795ffdecbe7 100644 --- a/src/Fieldtypes/Ranking.php +++ b/src/Fieldtypes/Ranking.php @@ -2,9 +2,21 @@ namespace Statamic\Fieldtypes; +use Faker\Generator; use Statamic\Fields\Fieldtype; +use Statamic\Fields\SampleValue; class Ranking extends Fieldtype { protected $selectable = false; + + public function previewValue(): mixed + { + return SampleValue::optionKeys($this->field()); + } + + public function fakeValue(Generator $faker): mixed + { + return $faker->shuffleArray(SampleValue::optionKeys($this->field())); + } } diff --git a/src/Fieldtypes/Slug.php b/src/Fieldtypes/Slug.php index 7b2780c7cec..68cd6a273a2 100644 --- a/src/Fieldtypes/Slug.php +++ b/src/Fieldtypes/Slug.php @@ -2,6 +2,9 @@ namespace Statamic\Fieldtypes; +use Faker\Generator; +use Illuminate\Support\Str as IlluminateStr; + use function Statamic\trans as __; class Slug extends Text @@ -41,4 +44,14 @@ protected function configFieldItems(): array ], ]; } + + public function previewValue(): mixed + { + return IlluminateStr::slug(parent::previewValue()); + } + + public function fakeValue(Generator $faker): mixed + { + return IlluminateStr::slug(parent::fakeValue($faker)); + } } diff --git a/src/Fieldtypes/StarRating.php b/src/Fieldtypes/StarRating.php index f6b62ea4860..31d9d83785a 100644 --- a/src/Fieldtypes/StarRating.php +++ b/src/Fieldtypes/StarRating.php @@ -2,9 +2,30 @@ namespace Statamic\Fieldtypes; +use Faker\Generator; use Statamic\Fields\Fieldtype; class StarRating extends Fieldtype { protected $selectable = false; + + public function previewValue(): mixed + { + $min = $this->field()->get('min'); + + return is_numeric($min) ? $min + 0 : 1; + } + + public function fakeValue(Generator $faker): mixed + { + $max = max(1, (int) ($this->field()->get('max_stars') ?? 5)); + + if (! $this->field()->get('allow_half_stars')) { + return $faker->numberBetween(1, $max); + } + + $value = $faker->numberBetween(1, $max * 2) * 0.5; + + return fmod($value, 1.0) === 0.0 ? (int) $value : $value; + } } diff --git a/src/Fieldtypes/Text.php b/src/Fieldtypes/Text.php index 20783ce8110..07f01643fbb 100644 --- a/src/Fieldtypes/Text.php +++ b/src/Fieldtypes/Text.php @@ -2,7 +2,9 @@ namespace Statamic\Fieldtypes; +use Faker\Generator; use Statamic\Fields\Fieldtype; +use Statamic\Fields\SampleValue; use Statamic\Support\Str; use function Statamic\trans as __; @@ -174,4 +176,147 @@ public function preProcessIndex($value) return $this->config('prepend').$value.$this->config('append'); } + + public function previewValue(): mixed + { + return match ($this->sampleKind()) { + 'email' => 'name@example.com', + 'url' => 'https://example.com', + 'phone' => '555-0100', + 'first_name' => 'Jane', + 'last_name' => 'Doe', + 'name' => 'Jane Doe', + 'company' => 'Acme Inc.', + 'address' => '123 Main Street', + 'city' => 'Springfield', + 'state' => 'Oregon', + 'country' => 'Canada', + 'postcode' => '97477', + 'job' => 'Product Manager', + 'subject' => 'A question about your services', + 'paragraph' => SampleValue::paragraph(), + default => SampleValue::sentence(), + }; + } + + public function fakeValue(Generator $faker): mixed + { + return (string) match ($this->sampleKind()) { + 'email' => $faker->safeEmail(), + 'url' => $faker->url(), + 'phone' => $faker->phoneNumber(), + 'first_name' => $faker->firstName(), + 'last_name' => $faker->lastName(), + 'name' => $faker->name(), + 'company' => $faker->company(), + 'address' => $faker->streetAddress(), + 'city' => $faker->city(), + 'state' => $faker->state(), + 'country' => $faker->country(), + 'postcode' => $faker->postcode(), + 'job' => $faker->jobTitle(), + 'subject' => $faker->sentence(6), + 'paragraph' => $faker->paragraph(2), + default => $faker->sentence(4), + }; + } + + private function sampleKind(): ?string + { + return $this->sampleKindFromInputType() + ?? $this->sampleKindFromAutocomplete() + ?? $this->sampleKindFromWords(); + } + + private function sampleKindFromInputType(): ?string + { + return match ($this->field()->get('input_type')) { + 'email' => 'email', + 'url' => 'url', + 'tel' => 'phone', + default => null, + }; + } + + private function sampleKindFromAutocomplete(): ?string + { + $autocomplete = $this->field()->get('autocomplete'); + + if (! is_string($autocomplete) || $autocomplete === '') { + return null; + } + + return match (Str::afterLast(Str::lower(trim($autocomplete)), ' ')) { + 'email' => 'email', + 'url' => 'url', + 'tel', 'tel-national', 'tel-local' => 'phone', + 'given-name' => 'first_name', + 'family-name' => 'last_name', + 'name' => 'name', + 'organization' => 'company', + 'organization-title' => 'job', + 'street-address', 'address-line1', 'address-line2', 'address-line3' => 'address', + 'address-level2' => 'city', + 'address-level1' => 'state', + 'country', 'country-name' => 'country', + 'postal-code' => 'postcode', + default => null, + }; + } + + private function sampleKindFromWords(): ?string + { + // Checked in order, so "company_email" is an email, "company_name" is a company and "city_name" is a city. + $kinds = [ + 'first_name' => [['first', 'name'], ['given', 'name'], ['firstname'], ['forename']], + 'last_name' => [['last', 'name'], ['family', 'name'], ['lastname'], ['surname']], + 'email' => [['email'], ['e', 'mail']], + 'phone' => [['phone'], ['telephone'], ['tel'], ['mobile'], ['cell'], ['fax']], + 'url' => [['url'], ['website'], ['homepage']], + 'postcode' => [['postcode'], ['postal', 'code'], ['post', 'code'], ['zip'], ['zipcode']], + 'company' => [['company'], ['organization'], ['organisation'], ['business'], ['employer']], + 'address' => [['address'], ['street']], + 'city' => [['city'], ['town']], + 'state' => [['state'], ['province'], ['region'], ['county']], + 'country' => [['country']], + 'job' => [['job'], ['occupation'], ['position']], + 'subject' => [['subject']], + 'paragraph' => [['message'], ['comment'], ['comments'], ['notes'], ['description'], ['bio']], + 'name' => [['name'], ['fullname']], + ]; + + $wordLists = collect([$this->field()->handle(), $this->field()->display()]) + ->filter(fn ($part) => is_string($part) && $part !== '') + ->map(fn (string $part) => $this->sampleWords($part)); + + foreach ($kinds as $kind => $sequences) { + foreach ($sequences as $sequence) { + if ($wordLists->contains(fn (array $words) => $this->containsSequence($words, $sequence))) { + return $kind; + } + } + } + + return null; + } + + private function sampleWords(string $text): array + { + $text = preg_replace('/(?<=\p{Ll})(?=\p{Lu})/u', ' ', $text); + + return preg_split('/[^\p{L}\p{N}]+/u', Str::lower($text), -1, PREG_SPLIT_NO_EMPTY); + } + + private function containsSequence(array $words, array $sequence): bool + { + $length = count($sequence); + + for ($i = 0; $i <= count($words) - $length; $i++) { + if (array_slice($words, $i, $length) === $sequence) { + return true; + } + } + + return false; + } } diff --git a/src/Fieldtypes/Textarea.php b/src/Fieldtypes/Textarea.php index b90b71fdfcd..040bce49732 100644 --- a/src/Fieldtypes/Textarea.php +++ b/src/Fieldtypes/Textarea.php @@ -2,7 +2,9 @@ namespace Statamic\Fieldtypes; +use Faker\Generator; use Statamic\Fields\Fieldtype; +use Statamic\Fields\SampleValue; use Statamic\Query\Scopes\Filters\Fields\Textarea as TextareaFilter; use function Statamic\trans as __; @@ -66,4 +68,14 @@ public function filter() { return new TextareaFilter($this); } + + public function previewValue(): mixed + { + return SampleValue::paragraph(); + } + + public function fakeValue(Generator $faker): mixed + { + return $faker->paragraph(2); + } } diff --git a/src/Fieldtypes/Time.php b/src/Fieldtypes/Time.php index 1ccaab78be7..0dfd8bbf960 100644 --- a/src/Fieldtypes/Time.php +++ b/src/Fieldtypes/Time.php @@ -2,6 +2,7 @@ namespace Statamic\Fieldtypes; +use Faker\Generator; use Illuminate\Support\Facades\Date; use Statamic\Fields\Fieldtype; use Statamic\Rules\TimeFieldtype as ValidationRule; @@ -57,4 +58,14 @@ public function rules(): array { return [new ValidationRule($this)]; } + + public function previewValue(): mixed + { + return now()->format('H:i'); + } + + public function fakeValue(Generator $faker): mixed + { + return $faker->time('H:i'); + } } diff --git a/src/Fieldtypes/Toggle.php b/src/Fieldtypes/Toggle.php index 010e634b686..f7cec693293 100644 --- a/src/Fieldtypes/Toggle.php +++ b/src/Fieldtypes/Toggle.php @@ -2,6 +2,7 @@ namespace Statamic\Fieldtypes; +use Faker\Generator; use Statamic\Facades\GraphQL; use Statamic\Fields\Fieldtype; use Statamic\Query\Scopes\Filters\Fields\Toggle as ToggleFilter; @@ -84,4 +85,14 @@ public function toQueryableValue($value) { return (bool) $value; } + + public function previewValue(): mixed + { + return true; + } + + public function fakeValue(Generator $faker): mixed + { + return $faker->boolean(); + } } diff --git a/src/Forms/FakeSubmissionGenerator.php b/src/Forms/FakeSubmissionGenerator.php index 4d764f16141..e9903f6d06b 100644 --- a/src/Forms/FakeSubmissionGenerator.php +++ b/src/Forms/FakeSubmissionGenerator.php @@ -2,15 +2,13 @@ namespace Statamic\Forms; -use Illuminate\Support\Str as IlluminateStr; +use Faker\Generator; use Statamic\Contracts\Forms\Form as FormContract; use Statamic\Fields\Field; -use Statamic\Forms\Fields\FormField; -use Statamic\Support\Str; class FakeSubmissionGenerator { - protected $faker; + protected Generator $faker; public static function enabled(): bool { @@ -25,310 +23,7 @@ public function __construct() public function generate(FormContract $form): array { return $form->blueprint()->fields()->all() - ->mapWithKeys(fn (Field $field) => [$field->handle() => $this->valueForField($field)]) + ->mapWithKeys(fn (Field $field) => [$field->handle() => $field->fieldtype()->fakeValue($this->faker)]) ->all(); } - - private function valueForField(Field $field): mixed - { - $type = $field->type(); - $default = $field->defaultValue(); - - if ($default !== null && ! in_array($type, [ - 'toggle', 'checkboxes', 'select', 'radio', 'button_group', 'image_choice', 'yes_no', 'ranking', 'dictionary', - ])) { - return $default; - } - - return match ($type) { - 'text', 'revealer', 'hidden' => $this->textValueFor($field), - 'slug' => IlluminateStr::slug($this->textValueFor($field)), - 'textarea', 'markdown' => $this->paragraph(), - 'integer', 'range' => $this->integerValueFor($field), - 'float' => $this->decimalNumber(), - 'toggle' => $this->boolean(), - 'date' => $this->date(), - 'time' => $this->time(), - 'date_and_time' => $this->dateTime(), - 'email' => $this->email(), - 'link', 'url' => $this->url(), - 'select', 'radio', 'button_group', 'yes_no' => $this->randomOptionKey($field), - 'image_choice' => $field->get('multiple') - ? $this->randomOptionKeys($field) - : $this->randomOptionKey($field), - 'checkboxes' => $this->randomOptionKeys($field), - 'opinion_scale' => $this->opinionScaleValueFor($field), - 'star_rating' => $this->starRatingValueFor($field), - 'ranking' => $this->rankingValueFor($field), - 'dictionary' => $this->dictionaryValueFor($field), - 'group' => $this->groupValueFor($field), - 'list' => [$this->word(), $this->word(), $this->word()], - default => $field->fieldtype()->fakeValue() ?? $default, - }; - } - - private function integerValueFor(Field $field): int - { - $min = (int) ($field->get('min') ?? 1); - $max = (int) ($field->get('max') ?? 5000); - - return $this->number(min($min, $max), max($min, $max)); - } - - private function randomOptionKey(Field $field): mixed - { - $options = FieldOptions::keys($field); - - if (empty($options)) { - return null; - } - - return $options[array_rand($options)]; - } - - private function randomOptionKeys(Field $field): array - { - $options = FieldOptions::keys($field); - - if (empty($options)) { - return []; - } - - shuffle($options); - - return array_slice($options, 0, random_int(1, min(2, count($options)))); - } - - private function opinionScaleValueFor(Field $field): int - { - $min = (int) ($field->get('min') ?? 0); - $max = (int) ($field->get('max') ?? 10); - - if ($max < $min) { - [$min, $max] = [$max, $min]; - } - - return $this->number($min, $max); - } - - private function starRatingValueFor(Field $field): int|float - { - $max = max(1, (int) ($field->get('max_stars') ?? 5)); - $allowHalf = (bool) $field->get('allow_half_stars'); - - if (! $allowHalf) { - return $this->number(1, $max); - } - - $steps = (int) ($max / 0.5); - $value = $this->number(1, $steps) * 0.5; - - return fmod($value, 1.0) === 0.0 ? (int) $value : $value; - } - - private function rankingValueFor(Field $field): array - { - $options = FieldOptions::keys($field); - - shuffle($options); - - return array_values($options); - } - - private function dictionaryValueFor(Field $field): mixed - { - try { - $options = array_keys($field->fieldtype()->dictionary()->options()); - } catch (\Throwable) { - return null; - } - - if ($options === []) { - return null; - } - - $maxItems = $field->get('max_items'); - - if ($maxItems === 1) { - return $options[array_rand($options)]; - } - - shuffle($options); - - $count = $maxItems - ? random_int(1, min((int) $maxItems, count($options))) - : random_int(1, min(2, count($options))); - - return array_slice($options, 0, $count); - } - - private function groupValueFor(Field $field): array - { - return collect($field->get('fields', [])) - ->filter(fn ($config) => is_array($config) && isset($config['handle'], $config['field'])) - ->mapWithKeys(function (array $config) { - $nested = $this->resolveField($config['handle'], $config['field']); - - return [$nested->handle() => $this->valueForField($nested)]; - }) - ->all(); - } - - private function resolveField(string $handle, array $config): Field - { - return new Field($handle, (new FormField($handle, $config))->toFieldArray()); - } - - private function word(): string - { - return (string) $this->faker->word(); - } - - private function textValueFor(Field $field): string - { - return match ($field->get('input_type')) { - 'email' => $this->email(), - 'url' => $this->url(), - 'tel' => $this->phoneNumber(), - default => $this->textValueFromContext($field), - }; - } - - private function textValueFromContext(Field $field): string - { - if ($this->looksLike($field, ['email', 'e-mail'])) { - return (string) $this->faker->safeEmail(); - } - - if ($this->looksLike($field, ['phone', 'tel', 'mobile', 'fax'])) { - return (string) $this->faker->phoneNumber(); - } - - if ($this->looksLike($field, ['first_name', 'firstname', 'given_name'])) { - return (string) $this->faker->firstName(); - } - - if ($this->looksLike($field, ['last_name', 'lastname', 'surname', 'family_name'])) { - return (string) $this->faker->lastName(); - } - - if ($this->looksLike($field, ['full_name', 'name']) || $field->get('autocomplete') === 'name') { - return (string) $this->faker->name(); - } - - if ($this->looksLike($field, ['company', 'organization', 'organisation', 'business'])) { - return (string) $this->faker->company(); - } - - if ($this->looksLike($field, ['address', 'street'])) { - return (string) $this->faker->streetAddress(); - } - - if ($this->looksLike($field, ['city', 'town'])) { - return (string) $this->faker->city(); - } - - if ($this->looksLike($field, ['state', 'province', 'region'])) { - return (string) $this->faker->state(); - } - - if ($this->looksLike($field, ['country'])) { - return (string) $this->faker->country(); - } - - if ($this->looksLike($field, ['zip', 'postal', 'postcode'])) { - return (string) $this->faker->postcode(); - } - - if ($this->looksLike($field, ['url', 'website', 'site'])) { - return (string) $this->faker->url(); - } - - if ($this->looksLike($field, ['job', 'title', 'position', 'role'])) { - return (string) $this->faker->jobTitle(); - } - - if ($this->looksLike($field, ['subject'])) { - return (string) $this->faker->sentence(6); - } - - if ($this->looksLike($field, ['message', 'comment', 'notes', 'description', 'bio'])) { - return (string) $this->faker->paragraph(2); - } - - return (string) $this->faker->sentence(4); - } - - private function looksLike(Field $field, array $tokens): bool - { - $haystack = $this->normalizedFieldContext($field); - - foreach ($tokens as $token) { - if (str_contains($haystack, Str::lower($token))) { - return true; - } - } - - return false; - } - - private function normalizedFieldContext(Field $field): string - { - $parts = [ - $field->handle(), - $field->display(), - ]; - - return Str::lower(implode(' ', array_filter($parts, fn ($part) => is_string($part) && $part !== ''))); - } - - private function paragraph(): string - { - return (string) $this->faker->paragraph(2); - } - - private function number(int $min, int $max): int - { - return (int) $this->faker->numberBetween($min, $max); - } - - private function decimalNumber(): float - { - return (float) $this->faker->randomFloat(2, 1, 1000); - } - - private function boolean(): bool - { - return (bool) $this->faker->boolean(); - } - - private function date(): string - { - return (string) $this->faker->date('Y-m-d'); - } - - private function time(): string - { - return (string) $this->faker->time('H:i'); - } - - private function dateTime(): string - { - return $this->faker->dateTime()->format(DATE_ATOM); - } - - private function email(): string - { - return (string) $this->faker->safeEmail(); - } - - private function phoneNumber(): string - { - return (string) $this->faker->phoneNumber(); - } - - private function url(): string - { - return (string) $this->faker->url(); - } } diff --git a/src/Forms/FieldOptions.php b/src/Forms/FieldOptions.php deleted file mode 100644 index b0b6eeced41..00000000000 --- a/src/Forms/FieldOptions.php +++ /dev/null @@ -1,30 +0,0 @@ -get('options', [])); - - if ($options === []) { - return []; - } - - if (Arr::isAssoc($options)) { - return array_keys($options); - } - - return array_values(array_filter(array_map(function ($option) { - if (! is_array($option)) { - return $option; - } - - return Arr::get($option, 'key') ?? Arr::get($option, 'value'); - }, $options), fn ($value) => $value !== null && $value !== '')); - } -} diff --git a/src/Forms/PreviewSubmission.php b/src/Forms/PreviewSubmission.php index af9efb97c84..980b0a97a7a 100644 --- a/src/Forms/PreviewSubmission.php +++ b/src/Forms/PreviewSubmission.php @@ -2,97 +2,15 @@ namespace Statamic\Forms; -use Illuminate\Support\Str; use Statamic\Contracts\Forms\Form as FormContract; use Statamic\Fields\Field; -use Statamic\Forms\Fields\FormField; - -use function Statamic\trans as __; class PreviewSubmission { public function values(FormContract $form): array { return $form->blueprint()->fields()->all() - ->mapWithKeys(fn (Field $field) => [$field->handle() => $this->valueForField($field)]) - ->all(); - } - - private function valueForField(Field $field): mixed - { - return match ($field->type()) { - 'text' => $this->textValueFor($field), - 'textarea', 'markdown', 'hidden', 'revealer' => $this->placeholder($field), - 'slug' => Str::slug(__($field->display())), - 'list' => [$this->placeholder($field)], - 'email' => 'name@example.com', - 'link', 'url' => 'https://example.com', - 'integer', 'range', 'float', 'opinion_scale', 'star_rating' => $this->numberValueFor($field), - 'toggle' => true, - 'date' => now()->toDateString(), - 'time' => now()->format('H:i'), - 'date_and_time' => now()->toIso8601String(), - 'select', 'radio', 'button_group', 'yes_no', 'image_choice' => $field->get('multiple') - ? array_slice(FieldOptions::keys($field), 0, 1) - : $this->firstOptionKey($field), - 'checkboxes' => array_slice(FieldOptions::keys($field), 0, 1), - 'ranking' => FieldOptions::keys($field), - 'dictionary' => $this->dictionaryValueFor($field), - 'group' => $this->groupValueFor($field), - default => null, - }; - } - - private function placeholder(Field $field): string - { - return '['.__($field->display()).']'; - } - - private function textValueFor(Field $field): string - { - return match ($field->get('input_type')) { - 'email' => 'name@example.com', - 'url' => 'https://example.com', - default => $this->placeholder($field), - }; - } - - private function numberValueFor(Field $field): int|float - { - $min = $field->get('min'); - - return is_numeric($min) ? $min + 0 : 1; - } - - private function firstOptionKey(Field $field): mixed - { - return FieldOptions::keys($field)[0] ?? null; - } - - private function dictionaryValueFor(Field $field): mixed - { - try { - $key = array_key_first($field->fieldtype()->dictionary()->options()); - } catch (\Throwable) { - return null; - } - - if ($key === null) { - return null; - } - - return $field->get('max_items') === 1 ? $key : [$key]; - } - - private function groupValueFor(Field $field): array - { - return collect($field->get('fields', [])) - ->filter(fn ($config) => is_array($config) && isset($config['handle'], $config['field'])) - ->mapWithKeys(function (array $config) { - $nested = new Field($config['handle'], (new FormField($config['handle'], $config['field']))->toFieldArray()); - - return [$nested->handle() => $this->valueForField($nested)]; - }) + ->mapWithKeys(fn (Field $field) => [$field->handle() => $field->fieldtype()->previewValue()]) ->all(); } } diff --git a/tests/Fields/FieldtypeSampleValuesTest.php b/tests/Fields/FieldtypeSampleValuesTest.php new file mode 100644 index 00000000000..794b7a04075 --- /dev/null +++ b/tests/Fields/FieldtypeSampleValuesTest.php @@ -0,0 +1,216 @@ +faker = Factory::create(); + + Carbon::setTestNow('2026-11-09 14:30:00'); + } + + #[Test] + public function the_base_fieldtype_has_no_preview_value() + { + $fieldtype = new class extends Fieldtype + { + }; + + $this->assertNull($fieldtype->previewValue()); + $this->assertNull($fieldtype->fakeValue($this->faker)); + } + + #[Test] + public function the_base_fake_value_falls_back_to_the_preview_value() + { + $fieldtype = new class extends Fieldtype + { + public function previewValue(): mixed + { + return 'preview'; + } + }; + + $this->assertSame('preview', $fieldtype->fakeValue($this->faker)); + } + + #[Test] + #[DataProvider('previewValueProvider')] + public function it_gets_preview_values(array $config, $expected) + { + $this->assertSame($expected, $this->field($config)->fieldtype()->previewValue()); + } + + public static function previewValueProvider() + { + $options = ['options' => ['red' => 'Red', 'blue' => 'Blue']]; + + return [ + 'text' => [['type' => 'text', 'display' => 'Favourite Colour'], 'Lorem ipsum dolor sit amet'], + 'text first name' => [['type' => 'text', 'display' => 'First Name'], 'Jane'], + 'text name' => [['type' => 'text', 'display' => 'Who are you?', 'autocomplete' => 'name'], 'Jane Doe'], + 'text city' => [['type' => 'text', 'handle' => 'city'], 'Springfield'], + 'text email' => [['type' => 'text', 'input_type' => 'email'], 'name@example.com'], + 'text url' => [['type' => 'text', 'input_type' => 'url'], 'https://example.com'], + 'text tel' => [['type' => 'text', 'display' => 'Call me', 'input_type' => 'tel'], '555-0100'], + 'slug' => [['type' => 'slug', 'display' => 'Slug'], 'lorem-ipsum-dolor-sit-amet'], + 'textarea' => [['type' => 'textarea'], SampleValue::paragraph()], + 'markdown' => [['type' => 'markdown'], SampleValue::paragraph()], + 'hidden' => [['type' => 'hidden'], null], + 'hidden with default' => [['type' => 'hidden', 'display' => 'Source', 'default' => 'newsletter'], 'newsletter'], + 'revealer' => [['type' => 'revealer', 'display' => 'More'], null], + 'list' => [['type' => 'list'], ['Lorem', 'Ipsum', 'Dolor']], + 'integer' => [['type' => 'integer'], 1], + 'integer with min' => [['type' => 'integer', 'min' => 16], 16], + 'range' => [['type' => 'range', 'min' => 0], 0], + 'float' => [['type' => 'float', 'min' => 1.5], 1.5], + 'opinion scale' => [['type' => 'opinion_scale', 'min' => 0, 'max' => 10], 0], + 'star rating' => [['type' => 'star_rating', 'min' => 1], 1], + 'toggle' => [['type' => 'toggle'], true], + 'date' => [['type' => 'date'], '2026-11-09'], + 'time' => [['type' => 'time'], '14:30'], + 'link' => [['type' => 'link'], 'https://example.com'], + 'select' => [['type' => 'select', ...$options], 'red'], + 'select multiple' => [['type' => 'select', 'multiple' => true, ...$options], ['red']], + 'select without options' => [['type' => 'select'], null], + 'radio' => [['type' => 'radio', ...$options], 'red'], + 'button group' => [['type' => 'button_group', ...$options], 'red'], + 'yes no' => [['type' => 'yes_no', 'options' => ['yes' => 'Yes', 'no' => 'No']], 'yes'], + 'checkboxes' => [['type' => 'checkboxes', ...$options], ['red']], + 'image choice' => [['type' => 'image_choice', 'options' => [['key' => 'a', 'image' => 'a.png']]], 'a'], + 'image choice multiple' => [['type' => 'image_choice', 'multiple' => true, 'options' => [['key' => 'a', 'image' => 'a.png']]], ['a']], + 'ranking' => [['type' => 'ranking', ...$options], ['red', 'blue']], + 'form upload' => [['type' => 'form_upload'], null], + 'group' => [['type' => 'group', 'fields' => [ + ['handle' => 'city', 'field' => ['type' => 'text', 'display' => 'City']], + ['handle' => 'agree', 'field' => ['type' => 'toggle']], + ]], ['city' => 'Springfield', 'agree' => true]], + ]; + } + + #[Test] + #[DataProvider('textMatchingProvider')] + public function it_picks_text_preview_values_from_the_field(array $config, $expected) + { + $this->assertSame($expected, $this->field(['type' => 'text', ...$config])->fieldtype()->previewValue()); + } + + public static function textMatchingProvider() + { + $sentence = SampleValue::sentence(); + + return [ + 'input type wins over handle' => [['handle' => 'name', 'input_type' => 'email'], 'name@example.com'], + 'autocomplete wins over handle' => [['handle' => 'name', 'autocomplete' => 'postal-code'], '97477'], + 'autocomplete with section' => [['handle' => 'field', 'autocomplete' => 'shipping address-level2'], 'Springfield'], + 'autocomplete given name' => [['handle' => 'field', 'autocomplete' => 'given-name'], 'Jane'], + 'autocomplete organization' => [['handle' => 'field', 'autocomplete' => 'organization'], 'Acme Inc.'], + 'unknown autocomplete falls through' => [['handle' => 'city', 'autocomplete' => 'off'], 'Springfield'], + 'snake case handle' => [['handle' => 'first_name'], 'Jane'], + 'camel case handle' => [['handle' => 'lastName'], 'Doe'], + 'label with spaces' => [['handle' => 'field', 'display' => 'Given Name'], 'Jane'], + 'hyphenated label' => [['handle' => 'field', 'display' => 'E-mail'], 'name@example.com'], + 'email address' => [['handle' => 'email_address'], 'name@example.com'], + 'company email' => [['handle' => 'company_email'], 'name@example.com'], + 'company website' => [['handle' => 'company_website'], 'https://example.com'], + 'company name' => [['handle' => 'company_name'], 'Acme Inc.'], + 'city name' => [['handle' => 'city_name'], 'Springfield'], + 'full name' => [['handle' => 'full_name'], 'Jane Doe'], + 'phone number' => [['handle' => 'phone_number'], '555-0100'], + 'zip code' => [['handle' => 'zip_code'], '97477'], + 'job title' => [['handle' => 'job_title'], 'Product Manager'], + 'message' => [['handle' => 'your_message'], SampleValue::paragraph()], + 'hotel is not a phone' => [['handle' => 'hotel'], $sentence], + 'statement is not a state' => [['handle' => 'statement'], $sentence], + 'website title is a url' => [['handle' => 'website_title'], 'https://example.com'], + 'title is not a job' => [['handle' => 'title'], $sentence], + 'page title is not a job' => [['handle' => 'page_title'], $sentence], + 'subtitle is not a job' => [['handle' => 'subtitle'], $sentence], + 'username is not a name' => [['handle' => 'username'], $sentence], + 'site is not a url' => [['handle' => 'site'], $sentence], + ]; + } + + #[Test] + #[DataProvider('fakeValueProvider')] + public function it_gets_fake_values(array $config, \Closure $assertion) + { + foreach (range(1, 10) as $_) { + $assertion->call($this, $this->field($config)->fieldtype()->fakeValue($this->faker)); + } + } + + public static function fakeValueProvider() + { + $options = ['options' => ['red' => 'Red', 'blue' => 'Blue']]; + + return [ + 'text' => [['type' => 'text'], fn ($value) => $this->assertIsString($value)], + 'text email' => [['type' => 'text', 'input_type' => 'email'], fn ($value) => $this->assertMatchesRegularExpression('/^.+@.+\..+$/', $value)], + 'text url' => [['type' => 'text', 'input_type' => 'url'], fn ($value) => $this->assertMatchesRegularExpression('/^https?:\/\//', $value)], + 'text email by handle' => [['type' => 'text', 'handle' => 'email_address'], fn ($value) => $this->assertMatchesRegularExpression('/^.+@.+\..+$/', $value)], + 'slug' => [['type' => 'slug'], fn ($value) => $this->assertMatchesRegularExpression('/^[a-z0-9-]+$/', $value)], + 'textarea' => [['type' => 'textarea'], fn ($value) => $this->assertIsString($value)], + 'markdown' => [['type' => 'markdown'], fn ($value) => $this->assertIsString($value)], + 'hidden' => [['type' => 'hidden'], fn ($value) => $this->assertNull($value)], + 'hidden with default' => [['type' => 'hidden', 'default' => 'newsletter'], fn ($value) => $this->assertSame('newsletter', $value)], + 'revealer' => [['type' => 'revealer'], fn ($value) => $this->assertNull($value)], + 'text ignores default' => [['type' => 'text', 'default' => 'Hello'], fn ($value) => $this->assertNotSame('Hello', $value)], + 'list' => [['type' => 'list'], fn ($value) => $this->assertCount(3, $value)], + 'integer' => [['type' => 'integer', 'min' => 16, 'max' => 18], fn ($value) => $this->assertContains($value, [16, 17, 18])], + 'range' => [['type' => 'range', 'min' => 1, 'max' => 2], fn ($value) => $this->assertContains($value, [1, 2])], + 'float' => [['type' => 'float'], fn ($value) => $this->assertIsFloat($value)], + 'opinion scale' => [['type' => 'opinion_scale', 'min' => 0, 'max' => 2], fn ($value) => $this->assertContains($value, [0, 1, 2])], + 'star rating' => [['type' => 'star_rating', 'max_stars' => 2, 'allow_half_stars' => true], fn ($value) => $this->assertContains($value, [0.5, 1, 1.5, 2])], + 'toggle' => [['type' => 'toggle'], fn ($value) => $this->assertIsBool($value)], + 'date' => [['type' => 'date'], fn ($value) => $this->assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2}$/', $value)], + 'time' => [['type' => 'time'], fn ($value) => $this->assertMatchesRegularExpression('/^\d{2}:\d{2}$/', $value)], + 'link' => [['type' => 'link'], fn ($value) => $this->assertMatchesRegularExpression('/^https?:\/\//', $value)], + 'select' => [['type' => 'select', ...$options], fn ($value) => $this->assertContains($value, ['red', 'blue'])], + 'select multiple' => [['type' => 'select', 'multiple' => true, ...$options], function ($value) { + $this->assertIsArray($value); + $this->assertNotEmpty($value); + $this->assertEmpty(array_diff($value, ['red', 'blue'])); + }], + 'select multiple with max items' => [['type' => 'select', 'multiple' => true, 'max_items' => 1, 'options' => ['a' => 'A', 'b' => 'B', 'c' => 'C']], function ($value) { + $this->assertIsArray($value); + $this->assertCount(1, $value); + }], + 'radio' => [['type' => 'radio', ...$options], fn ($value) => $this->assertContains($value, ['red', 'blue'])], + 'button group' => [['type' => 'button_group', ...$options], fn ($value) => $this->assertContains($value, ['red', 'blue'])], + 'checkboxes' => [['type' => 'checkboxes', ...$options], fn ($value) => $this->assertEmpty(array_diff($value, ['red', 'blue']))], + 'image choice multiple' => [['type' => 'image_choice', 'multiple' => true, 'options' => [['key' => 'a', 'image' => 'a.png']]], fn ($value) => $this->assertSame(['a'], $value)], + 'ranking' => [['type' => 'ranking', ...$options], fn ($value) => $this->assertEqualsCanonicalizing(['red', 'blue'], $value)], + 'dictionary' => [['type' => 'dictionary', 'dictionary' => 'countries', 'max_items' => 1], fn ($value) => $this->assertIsString($value)], + 'form upload' => [['type' => 'form_upload'], fn ($value) => $this->assertNull($value)], + 'group' => [['type' => 'group', 'fields' => [ + ['handle' => 'city', 'field' => ['type' => 'hidden', 'default' => 'Toronto']], + ['handle' => 'agree', 'field' => ['type' => 'toggle']], + ]], function ($value) { + $this->assertSame('Toronto', $value['city']); + $this->assertIsBool($value['agree']); + }], + ]; + } + + private function field(array $config): Field + { + return new Field($config['handle'] ?? 'test', $config); + } +} diff --git a/tests/Forms/Connectors/EmailConnectorTest.php b/tests/Forms/Connectors/EmailConnectorTest.php index 8b14fc2c458..2dd52cb9a39 100644 --- a/tests/Forms/Connectors/EmailConnectorTest.php +++ b/tests/Forms/Connectors/EmailConnectorTest.php @@ -151,7 +151,7 @@ public function it_previews_an_email_using_a_sample_submission() 'sample' => true, ]); - $this->assertEquals('Hello [Name]', $response->json('subject')); + $this->assertEquals('Hello Jane Doe', $response->json('subject')); $this->assertEquals(['name@example.com', 'Team '], $response->json('to')); $this->assertEquals(['name@example.com'], $response->json('reply_to')); $this->assertStringContainsString('Name:', $response->json('body')); @@ -175,7 +175,7 @@ public function it_previews_a_sample_submission_without_the_fake_submission_gene ]) ->assertOk() ->assertJson([ - 'subject' => 'Hello [Name]', + 'subject' => 'Hello Jane Doe', 'to' => ['name@example.com'], 'sample' => true, ]); @@ -222,7 +222,7 @@ public function it_previews_using_sample_data_when_the_user_cannot_view_submissi ]) ->assertOk() ->assertJson([ - 'subject' => 'Hello [Name]', + 'subject' => 'Hello Jane Doe', 'to' => ['name@example.com'], 'sample' => true, ]); diff --git a/tests/Forms/FakeSubmissionGeneratorTest.php b/tests/Forms/FakeSubmissionGeneratorTest.php index 09b44eba549..5240e24df45 100644 --- a/tests/Forms/FakeSubmissionGeneratorTest.php +++ b/tests/Forms/FakeSubmissionGeneratorTest.php @@ -2,6 +2,7 @@ namespace Tests\Forms; +use Faker\Generator; use PHPUnit\Framework\Attributes\Test; use Statamic\Facades\Form; use Statamic\Fields\Fieldtype; @@ -13,12 +14,27 @@ class FakeValueTestFieldtype extends Fieldtype { protected static $handle = 'fake_value_test'; - public function fakeValue(): mixed + public function previewValue(): mixed + { + return 'from preview value'; + } + + public function fakeValue(Generator $faker): mixed { return ['ok' => true]; } } +class PreviewValueOnlyTestFieldtype extends Fieldtype +{ + protected static $handle = 'preview_value_only_test'; + + public function previewValue(): mixed + { + return 'from preview value'; + } +} + class FakeSubmissionGeneratorTest extends TestCase { use PreventSavingStacheItemsToDisk; @@ -232,7 +248,7 @@ public function it_respects_star_rating_half_steps() } #[Test] - public function it_uses_fieldtype_fake_value_for_unknown_types() + public function it_uses_a_custom_fieldtypes_fake_value_over_its_preview_value() { FakeValueTestFieldtype::register(); @@ -245,6 +261,65 @@ public function it_uses_fieldtype_fake_value_for_unknown_types() $this->assertEquals(['ok' => true], $values['widget']); } + #[Test] + public function it_falls_back_to_a_custom_fieldtypes_preview_value() + { + PreviewValueOnlyTestFieldtype::register(); + + $form = $this->makeForm('custom_preview_type', [ + ['handle' => 'widget', 'field' => ['type' => 'preview_value_only_test']], + ]); + + $values = (new FakeSubmissionGenerator)->generate($form); + + $this->assertSame('from preview value', $values['widget']); + } + + #[Test] + public function it_uses_hidden_field_defaults_but_ignores_other_defaults() + { + $form = $this->makeForm('defaults', [ + ['handle' => 'source', 'field' => ['type' => 'hidden', 'default' => 'newsletter']], + ['handle' => 'subject', 'field' => ['type' => 'short_answer', 'default' => 'Hello']], + ['handle' => 'colour', 'field' => [ + 'type' => 'dropdown', + 'default' => 'red', + 'options' => [ + ['key' => 'red', 'value' => 'Red'], + ['key' => 'blue', 'value' => 'Blue'], + ], + ]], + ]); + + $values = collect(range(1, 30))->map(fn () => (new FakeSubmissionGenerator)->generate($form)); + + $this->assertSame(['newsletter'], $values->pluck('source')->unique()->values()->all()); + $this->assertNotSame(['Hello'], $values->pluck('subject')->unique()->values()->all()); + $this->assertEqualsCanonicalizing(['red', 'blue'], $values->pluck('colour')->unique()->values()->all()); + } + + #[Test] + public function it_generates_values_for_groups_with_core_fieldtypes() + { + $form = $this->makeForm('core_group', [ + ['handle' => 'address', 'field' => [ + 'type' => 'group', + 'fields' => [ + ['handle' => 'city', 'field' => ['type' => 'text', 'display' => 'City']], + ['handle' => 'country', 'field' => ['type' => 'hidden', 'default' => 'Canada']], + ['handle' => 'floor', 'field' => ['type' => 'integer', 'min' => 1, 'max' => 3]], + ], + ]], + ]); + + $values = (new FakeSubmissionGenerator)->generate($form)['address']; + + $this->assertSame(['city', 'country', 'floor'], array_keys($values)); + $this->assertIsString($values['city']); + $this->assertSame('Canada', $values['country']); + $this->assertContains($values['floor'], [1, 2, 3]); + } + #[Test] public function it_returns_multiple_dictionary_values_when_max_items_allows() { diff --git a/tests/Forms/PreviewSubmissionTest.php b/tests/Forms/PreviewSubmissionTest.php index 9fc0c16426f..c44c29f37b9 100644 --- a/tests/Forms/PreviewSubmissionTest.php +++ b/tests/Forms/PreviewSubmissionTest.php @@ -2,10 +2,12 @@ namespace Tests\Forms; +use Faker\Generator; use Illuminate\Support\Carbon; use PHPUnit\Framework\Attributes\Test; use Statamic\Facades\Form; use Statamic\Fields\Fieldtype; +use Statamic\Fields\SampleValue; use Statamic\Forms\PreviewSubmission; use Tests\PreventSavingStacheItemsToDisk; use Tests\TestCase; @@ -14,9 +16,24 @@ class PreviewSubmissionTestFieldtype extends Fieldtype { protected static $handle = 'preview_submission_test'; - public function fakeValue(): mixed + public function previewValue(): mixed { - return 'from fake value'; + return 'from preview value'; + } + + public function fakeValue(Generator $faker): mixed + { + throw new \Exception('The preview should not call fakeValue().'); + } +} + +class PreviewSubmissionFakeOnlyTestFieldtype extends Fieldtype +{ + protected static $handle = 'preview_submission_fake_only_test'; + + public function fakeValue(Generator $faker): mixed + { + throw new \Exception('The preview should not call fakeValue().'); } } @@ -32,7 +49,7 @@ protected function resolveApplicationConfiguration($app) } #[Test] - public function it_uses_placeholder_values_for_each_field_type() + public function it_uses_example_values_for_each_field_type() { Carbon::setTestNow('2026-11-09 14:30:00'); @@ -122,12 +139,12 @@ public function it_uses_placeholder_values_for_each_field_type() $values = (new PreviewSubmission)->values($form); - $this->assertSame('[Who are you?]', $values['name']); - $this->assertSame('[First Name]', $values['first_name']); + $this->assertSame('Jane Doe', $values['name']); + $this->assertSame('Jane', $values['first_name']); $this->assertSame('name@example.com', $values['email']); - $this->assertSame('[Call me]', $values['phone']); + $this->assertSame('555-0100', $values['phone']); $this->assertSame('https://example.com', $values['website']); - $this->assertSame('[Message]', $values['long']); + $this->assertSame(SampleValue::paragraph(), $values['long']); $this->assertSame(1, $values['number']); $this->assertSame(16, $values['age']); $this->assertSame(1, $values['currency']); @@ -147,7 +164,7 @@ public function it_uses_placeholder_values_for_each_field_type() $this->assertIsString($values['dict']); $this->assertIsArray($values['dicts']); $this->assertCount(1, $values['dicts']); - $this->assertSame(['city' => '[City]', 'nested_email' => 'name@example.com'], $values['group']); + $this->assertSame(['city' => 'Springfield', 'nested_email' => 'name@example.com'], $values['group']); $this->assertNull($values['upload']); $this->assertNull($values['banner']); $this->assertNull($values['heading']); @@ -156,7 +173,7 @@ public function it_uses_placeholder_values_for_each_field_type() } #[Test] - public function it_uses_null_for_unknown_fieldtypes() + public function it_uses_a_custom_fieldtypes_preview_value() { PreviewSubmissionTestFieldtype::register(); @@ -164,9 +181,37 @@ public function it_uses_null_for_unknown_fieldtypes() ['handle' => 'widget', 'field' => ['type' => 'preview_submission_test']], ]); + $this->assertSame('from preview value', (new PreviewSubmission)->values($form)['widget']); + } + + #[Test] + public function it_uses_null_for_fieldtypes_without_a_preview_value() + { + PreviewSubmissionFakeOnlyTestFieldtype::register(); + + $form = $this->makeForm('fake_only_type', [ + ['handle' => 'widget', 'field' => ['type' => 'preview_submission_fake_only_test']], + ]); + $this->assertNull((new PreviewSubmission)->values($form)['widget']); } + #[Test] + public function it_uses_example_values_for_groups_with_core_fieldtypes() + { + $form = $this->makeForm('core_group', [ + ['handle' => 'address', 'field' => [ + 'type' => 'group', + 'fields' => [ + ['handle' => 'city', 'field' => ['type' => 'text', 'display' => 'City']], + ['handle' => 'floor', 'field' => ['type' => 'integer', 'min' => 2]], + ], + ]], + ]); + + $this->assertSame(['city' => 'Springfield', 'floor' => 2], (new PreviewSubmission)->values($form)['address']); + } + #[Test] public function it_returns_the_same_values_every_time() {