From 46d9f34d8ec8c916e8c4163bdcac156851ef8a43 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Tue, 10 Nov 2026 11:53:47 -0500 Subject: [PATCH 1/7] Move fake and preview value generation into fieldtypes Adds previewValue() and fakeValue(\Faker\Generator $faker) hooks to the base Fieldtype. previewValue() is a deterministic, Faker-free placeholder for the email preview. fakeValue() defaults to previewValue(), so addon fieldtypes get both by implementing one. FakeSubmissionGenerator and PreviewSubmission now just loop over fields and call the hooks; the generator keeps the default-value handling. Co-Authored-By: Claude Opus 5.5 --- src/Fields/Fieldtype.php | 18 +- src/Fieldtypes/Checkboxes.php | 23 ++ src/Fieldtypes/Date.php | 11 + src/Fieldtypes/Dictionary.php | 36 +++ src/Fieldtypes/Floatval.php | 13 + src/Fieldtypes/Group.php | 28 ++ src/Fieldtypes/HasSelectOptions.php | 18 ++ src/Fieldtypes/Hidden.php | 12 + src/Fieldtypes/ImageChoice.php | 33 +++ src/Fieldtypes/Integer.php | 16 + src/Fieldtypes/Link.php | 11 + src/Fieldtypes/Lists.php | 12 + src/Fieldtypes/Markdown.php | 12 + src/Fieldtypes/OpinionScale.php | 16 + src/Fieldtypes/Range.php | 16 + src/Fieldtypes/Ranking.php | 12 + src/Fieldtypes/Revealer.php | 12 + src/Fieldtypes/Slug.php | 13 + src/Fieldtypes/StarRating.php | 21 ++ src/Fieldtypes/Text.php | 50 ++++ src/Fieldtypes/Textarea.php | 12 + src/Fieldtypes/Time.php | 11 + src/Fieldtypes/Toggle.php | 11 + src/Forms/FakeSubmissionGenerator.php | 310 +------------------- src/Forms/PreviewSubmission.php | 79 +---- tests/Fields/FieldtypeSampleValuesTest.php | 156 ++++++++++ tests/Forms/FakeSubmissionGeneratorTest.php | 77 ++++- tests/Forms/PreviewSubmissionTest.php | 50 +++- 28 files changed, 704 insertions(+), 385 deletions(-) create mode 100644 tests/Fields/FieldtypeSampleValuesTest.php diff --git a/src/Fields/Fieldtype.php b/src/Fields/Fieldtype.php index 55c3855576f..4080cdfe7de 100644 --- a/src/Fields/Fieldtype.php +++ b/src/Fields/Fieldtype.php @@ -354,15 +354,27 @@ public function preProcess($data) } /** - * Generate a fake value for form submission testing. + * A deterministic placeholder value, used when previewing form emails without a real submission. * - * Return null to fall back to FakeSubmissionGenerator's built-in handling. + * This runs 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 value, used when generating fake form submissions. + * + * This only runs locally. 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/Fieldtypes/Checkboxes.php b/src/Fieldtypes/Checkboxes.php index 9a48a58fb1a..431e8e87f88 100644 --- a/src/Fieldtypes/Checkboxes.php +++ b/src/Fieldtypes/Checkboxes.php @@ -2,8 +2,10 @@ namespace Statamic\Fieldtypes; +use Faker\Generator; use Statamic\Fields\Fieldtype; use Statamic\Fieldtypes\Concerns\MigratesLegacyInlineConfig; +use Statamic\Forms\FieldOptions; use function Statamic\trans as __; @@ -78,4 +80,25 @@ public function process($data) ->values() ->all(); } + + public function previewValue(): mixed + { + return array_slice(FieldOptions::keys($this->field()), 0, 1); + } + + public function fakeValue(Generator $faker): mixed + { + return $this->randomOptionKeys($faker); + } + + private function randomOptionKeys(Generator $faker): array + { + $keys = FieldOptions::keys($this->field()); + + if ($keys === []) { + return []; + } + + return $faker->randomElements($keys, $faker->numberBetween(1, min(2, count($keys)))); + } } 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..6b65a8f7e4b 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; @@ -195,4 +196,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 $faker->randomElements($keys, $faker->numberBetween(1, min($maxItems ? (int) $maxItems : 2, count($keys)))); + } + + 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..1bf8f4f0c73 100644 --- a/src/Fieldtypes/Group.php +++ b/src/Fieldtypes/Group.php @@ -2,11 +2,16 @@ 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\FakeSubmissionGenerator; +use Statamic\Forms\Fields\FormField; use Statamic\Forms\Fields\FormFields; use Statamic\GraphQL\Types\GroupType; use Statamic\Support\Arr; @@ -224,4 +229,27 @@ 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) => FakeSubmissionGenerator::valueFor($field, $faker)) + ->all(); + } + + private function sampleFields(): Collection + { + 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..48200ebb6c7 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\Forms\FieldOptions; use Statamic\GraphQL\Types\LabeledValueType; use Statamic\Support\Arr; @@ -217,4 +219,20 @@ public function extraRenderableFieldData(): array ->all(), ]; } + + public function previewValue(): mixed + { + $keys = FieldOptions::keys($this->field()); + + return $this->field()->get('multiple') + ? array_slice($keys, 0, 1) + : ($keys[0] ?? null); + } + + public function fakeValue(Generator $faker): mixed + { + $keys = FieldOptions::keys($this->field()); + + return $keys === [] ? null : $faker->randomElement($keys); + } } diff --git a/src/Fieldtypes/Hidden.php b/src/Fieldtypes/Hidden.php index 4026dd556f3..255eabbbe8d 100644 --- a/src/Fieldtypes/Hidden.php +++ b/src/Fieldtypes/Hidden.php @@ -2,7 +2,9 @@ namespace Statamic\Fieldtypes; +use Faker\Generator; use Statamic\Fields\Fieldtype; +use Statamic\Forms\PreviewSubmission; use function Statamic\trans as __; @@ -25,4 +27,14 @@ protected function configFieldItems(): array ], ]; } + + public function previewValue(): mixed + { + return PreviewSubmission::placeholder($this->field()); + } + + public function fakeValue(Generator $faker): mixed + { + return app(Text::class)->setField($this->field())->fakeValue($faker); + } } diff --git a/src/Fieldtypes/ImageChoice.php b/src/Fieldtypes/ImageChoice.php index 1f14c2f4429..7b4a4627a59 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\Forms\FieldOptions; use Statamic\Support\Arr; use Statamic\Support\Str; @@ -91,4 +93,35 @@ private function resolveImageUrl(mixed $image): ?string ->first() ?->url(); } + + public function previewValue(): mixed + { + $keys = FieldOptions::keys($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 $this->randomOptionKeys($faker); + } + + $keys = FieldOptions::keys($this->field()); + + return $keys === [] ? null : $faker->randomElement($keys); + } + + private function randomOptionKeys(Generator $faker): array + { + $keys = FieldOptions::keys($this->field()); + + if ($keys === []) { + return []; + } + + return $faker->randomElements($keys, $faker->numberBetween(1, min(2, count($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..5505ef16e57 100644 --- a/src/Fieldtypes/Lists.php +++ b/src/Fieldtypes/Lists.php @@ -2,8 +2,10 @@ namespace Statamic\Fieldtypes; +use Faker\Generator; use Statamic\Facades\GraphQL; use Statamic\Fields\Fieldtype; +use Statamic\Forms\PreviewSubmission; use function Statamic\trans as __; @@ -57,4 +59,14 @@ public function toGqlType() { return GraphQL::listOf(GraphQL::string()); } + + public function previewValue(): mixed + { + return [PreviewSubmission::placeholder($this->field())]; + } + + 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..e764069f87a 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\Forms\PreviewSubmission; 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 PreviewSubmission::placeholder($this->field()); + } + + 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..bdf1e97e2ae 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\Forms\FieldOptions; class Ranking extends Fieldtype { protected $selectable = false; + + public function previewValue(): mixed + { + return FieldOptions::keys($this->field()); + } + + public function fakeValue(Generator $faker): mixed + { + return $faker->shuffleArray(FieldOptions::keys($this->field())); + } } diff --git a/src/Fieldtypes/Revealer.php b/src/Fieldtypes/Revealer.php index 41a69387439..1d62a74db1e 100644 --- a/src/Fieldtypes/Revealer.php +++ b/src/Fieldtypes/Revealer.php @@ -2,7 +2,9 @@ namespace Statamic\Fieldtypes; +use Faker\Generator; use Statamic\Fields\Fieldtype; +use Statamic\Forms\PreviewSubmission; use function Statamic\trans as __; @@ -54,4 +56,14 @@ public function process($data) // return null; } + + public function previewValue(): mixed + { + return PreviewSubmission::placeholder($this->field()); + } + + public function fakeValue(Generator $faker): mixed + { + return app(Text::class)->setField($this->field())->fakeValue($faker); + } } diff --git a/src/Fieldtypes/Slug.php b/src/Fieldtypes/Slug.php index 7b2780c7cec..ade9900f85f 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(__($this->field()->display())); + } + + 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..c2574050ff6 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\Forms\PreviewSubmission; use Statamic\Support\Str; use function Statamic\trans as __; @@ -174,4 +176,52 @@ public function preProcessIndex($value) return $this->config('prepend').$value.$this->config('append'); } + + public function previewValue(): mixed + { + return match ($this->field()->get('input_type')) { + 'email' => 'name@example.com', + 'url' => 'https://example.com', + default => PreviewSubmission::placeholder($this->field()), + }; + } + + public function fakeValue(Generator $faker): mixed + { + return match ($this->field()->get('input_type')) { + 'email' => $faker->safeEmail(), + 'url' => $faker->url(), + 'tel' => $faker->phoneNumber(), + default => $this->fakeValueFromContext($faker), + }; + } + + private function fakeValueFromContext(Generator $faker): string + { + $haystack = Str::lower(implode(' ', array_filter( + [$this->field()->handle(), $this->field()->display()], + fn ($part) => is_string($part) && $part !== '' + ))); + + $looksLike = fn (array $tokens) => collect($tokens)->contains(fn ($token) => str_contains($haystack, $token)); + + return (string) match (true) { + $looksLike(['email', 'e-mail']) => $faker->safeEmail(), + $looksLike(['phone', 'tel', 'mobile', 'fax']) => $faker->phoneNumber(), + $looksLike(['first_name', 'firstname', 'given_name']) => $faker->firstName(), + $looksLike(['last_name', 'lastname', 'surname', 'family_name']) => $faker->lastName(), + $looksLike(['full_name', 'name']) || $this->field()->get('autocomplete') === 'name' => $faker->name(), + $looksLike(['company', 'organization', 'organisation', 'business']) => $faker->company(), + $looksLike(['address', 'street']) => $faker->streetAddress(), + $looksLike(['city', 'town']) => $faker->city(), + $looksLike(['state', 'province', 'region']) => $faker->state(), + $looksLike(['country']) => $faker->country(), + $looksLike(['zip', 'postal', 'postcode']) => $faker->postcode(), + $looksLike(['url', 'website', 'site']) => $faker->url(), + $looksLike(['job', 'title', 'position', 'role']) => $faker->jobTitle(), + $looksLike(['subject']) => $faker->sentence(6), + $looksLike(['message', 'comment', 'notes', 'description', 'bio']) => $faker->paragraph(2), + default => $faker->sentence(4), + }; + } } diff --git a/src/Fieldtypes/Textarea.php b/src/Fieldtypes/Textarea.php index b90b71fdfcd..dd21ae52fcf 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\Forms\PreviewSubmission; 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 PreviewSubmission::placeholder($this->field()); + } + + 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..826550831b6 100644 --- a/src/Forms/FakeSubmissionGenerator.php +++ b/src/Forms/FakeSubmissionGenerator.php @@ -2,15 +2,17 @@ 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; + private const RANDOMIZED_DEFAULTS = [ + 'toggle', 'checkboxes', 'select', 'radio', 'button_group', 'image_choice', 'yes_no', 'ranking', 'dictionary', + ]; + + protected Generator $faker; public static function enabled(): bool { @@ -25,310 +27,18 @@ 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() => static::valueFor($field, $this->faker)]) ->all(); } - private function valueForField(Field $field): mixed + public static function valueFor(Field $field, Generator $faker): 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', - ])) { + if ($default !== null && ! in_array($field->type(), self::RANDOMIZED_DEFAULTS)) { 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(); + return $field->fieldtype()->fakeValue($faker) ?? $default; } } diff --git a/src/Forms/PreviewSubmission.php b/src/Forms/PreviewSubmission.php index af9efb97c84..43854487d83 100644 --- a/src/Forms/PreviewSubmission.php +++ b/src/Forms/PreviewSubmission.php @@ -2,10 +2,8 @@ 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 __; @@ -14,85 +12,12 @@ class PreviewSubmission public function values(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()->previewValue()]) ->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 + public static 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)]; - }) - ->all(); - } } diff --git a/tests/Fields/FieldtypeSampleValuesTest.php b/tests/Fields/FieldtypeSampleValuesTest.php new file mode 100644 index 00000000000..db648568b56 --- /dev/null +++ b/tests/Fields/FieldtypeSampleValuesTest.php @@ -0,0 +1,156 @@ +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' => 'First Name'], '[First Name]'], + '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' => 'Phone', 'input_type' => 'tel'], '[Phone]'], + 'slug' => [['type' => 'slug', 'display' => 'Page Title'], 'page-title'], + 'textarea' => [['type' => 'textarea', 'display' => 'Message'], '[Message]'], + 'markdown' => [['type' => 'markdown', 'display' => 'Body'], '[Body]'], + 'hidden' => [['type' => 'hidden', 'display' => 'Source'], '[Source]'], + 'revealer' => [['type' => 'revealer', 'display' => 'More'], '[More]'], + 'list' => [['type' => 'list', 'display' => 'Items'], ['[Items]']], + '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' => '[City]', 'agree' => true]], + ]; + } + + #[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->assertIsString($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'])], + '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' => 'text', '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/FakeSubmissionGeneratorTest.php b/tests/Forms/FakeSubmissionGeneratorTest.php index 09b44eba549..26a23a06f56 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,63 @@ 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_defaults_for_text_but_randomizes_options() + { + $form = $this->makeForm('defaults', [ + ['handle' => 'source', 'field' => ['type' => 'short_answer', 'default' => 'newsletter']], + ['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->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' => 'text', '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..a86c57589df 100644 --- a/tests/Forms/PreviewSubmissionTest.php +++ b/tests/Forms/PreviewSubmissionTest.php @@ -2,6 +2,7 @@ namespace Tests\Forms; +use Faker\Generator; use Illuminate\Support\Carbon; use PHPUnit\Framework\Attributes\Test; use Statamic\Facades\Form; @@ -14,9 +15,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().'); } } @@ -156,7 +172,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 +180,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_placeholders_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' => '[City]', 'floor' => 2], (new PreviewSubmission)->values($form)['address']); + } + #[Test] public function it_returns_the_same_values_every_time() { From 17e5567bbcafcf5b614301ea500fc29d3484ad47 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Tue, 10 Nov 2026 14:17:27 -0500 Subject: [PATCH 2/7] Generalise previewValue and fakeValue docblocks Co-Authored-By: Claude Opus 5.5 --- src/Fields/Fieldtype.php | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/Fields/Fieldtype.php b/src/Fields/Fieldtype.php index 4080cdfe7de..b0eed7b1631 100644 --- a/src/Fields/Fieldtype.php +++ b/src/Fields/Fieldtype.php @@ -354,10 +354,11 @@ public function preProcess($data) } /** - * A deterministic placeholder value, used when previewing form emails without a real submission. + * A deterministic sample value, used wherever a value is needed for previewing but no real one exists. + * For example, form email previews. * - * This runs 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. + * 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 previewValue(): mixed { @@ -365,9 +366,10 @@ public function previewValue(): mixed } /** - * A realistic random value, used when generating fake form submissions. + * A realistic random sample value, used where Faker is available. For example, fake form submissions. * - * This only runs locally. Defaults to `previewValue()`, so override it only when you want more realistic data. + * 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 From 9810924766574e732f9a8114a304455596e5c76e Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Tue, 10 Nov 2026 14:37:31 -0500 Subject: [PATCH 3/7] Decouple fieldtype sample values from Forms Moves the placeholder, option key and default-value helpers into Statamic\Fields\SampleValue so fieldtypes no longer depend on PreviewSubmission, FieldOptions or FakeSubmissionGenerator. Group still uses FormField until the form Group fieldtype converts its nested fields to core fieldtypes. Co-Authored-By: Claude Opus 5.5 --- src/Fields/SampleValue.php | 52 +++++++++++++++++++++++++++ src/Fieldtypes/Checkboxes.php | 6 ++-- src/Fieldtypes/Group.php | 5 +-- src/Fieldtypes/HasSelectOptions.php | 6 ++-- src/Fieldtypes/Hidden.php | 4 +-- src/Fieldtypes/ImageChoice.php | 8 ++--- src/Fieldtypes/Lists.php | 4 +-- src/Fieldtypes/Markdown.php | 4 +-- src/Fieldtypes/Ranking.php | 6 ++-- src/Fieldtypes/Revealer.php | 4 +-- src/Fieldtypes/Text.php | 4 +-- src/Fieldtypes/Textarea.php | 4 +-- src/Forms/FakeSubmissionGenerator.php | 18 ++-------- src/Forms/FieldOptions.php | 30 ---------------- src/Forms/PreviewSubmission.php | 7 ---- 15 files changed, 82 insertions(+), 80 deletions(-) create mode 100644 src/Fields/SampleValue.php delete mode 100644 src/Forms/FieldOptions.php diff --git a/src/Fields/SampleValue.php b/src/Fields/SampleValue.php new file mode 100644 index 00000000000..6fdb1e4e567 --- /dev/null +++ b/src/Fields/SampleValue.php @@ -0,0 +1,52 @@ +defaultValue(); + + if ($default !== null && ! in_array($field->type(), self::RANDOMIZED_DEFAULTS)) { + return $default; + } + + return $field->fieldtype()->fakeValue($faker) ?? $default; + } + + public static function placeholder(Field $field): string + { + return '['.__($field->display()).']'; + } + + 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 431e8e87f88..94fbf2177c9 100644 --- a/src/Fieldtypes/Checkboxes.php +++ b/src/Fieldtypes/Checkboxes.php @@ -4,8 +4,8 @@ use Faker\Generator; use Statamic\Fields\Fieldtype; +use Statamic\Fields\SampleValue; use Statamic\Fieldtypes\Concerns\MigratesLegacyInlineConfig; -use Statamic\Forms\FieldOptions; use function Statamic\trans as __; @@ -83,7 +83,7 @@ public function process($data) public function previewValue(): mixed { - return array_slice(FieldOptions::keys($this->field()), 0, 1); + return array_slice(SampleValue::optionKeys($this->field()), 0, 1); } public function fakeValue(Generator $faker): mixed @@ -93,7 +93,7 @@ public function fakeValue(Generator $faker): mixed private function randomOptionKeys(Generator $faker): array { - $keys = FieldOptions::keys($this->field()); + $keys = SampleValue::optionKeys($this->field()); if ($keys === []) { return []; diff --git a/src/Fieldtypes/Group.php b/src/Fieldtypes/Group.php index 1bf8f4f0c73..a9d4cb84cde 100644 --- a/src/Fieldtypes/Group.php +++ b/src/Fieldtypes/Group.php @@ -9,8 +9,8 @@ use Statamic\Fields\Field; use Statamic\Fields\Fields; use Statamic\Fields\Fieldtype; +use Statamic\Fields\SampleValue; use Statamic\Fields\Values; -use Statamic\Forms\FakeSubmissionGenerator; use Statamic\Forms\Fields\FormField; use Statamic\Forms\Fields\FormFields; use Statamic\GraphQL\Types\GroupType; @@ -240,12 +240,13 @@ public function previewValue(): mixed public function fakeValue(Generator $faker): mixed { return $this->sampleFields() - ->map(fn (Field $field) => FakeSubmissionGenerator::valueFor($field, $faker)) + ->map(fn (Field $field) => SampleValue::fake($field, $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) => [ diff --git a/src/Fieldtypes/HasSelectOptions.php b/src/Fieldtypes/HasSelectOptions.php index 48200ebb6c7..323d0b954b7 100644 --- a/src/Fieldtypes/HasSelectOptions.php +++ b/src/Fieldtypes/HasSelectOptions.php @@ -6,7 +6,7 @@ use Illuminate\Support\Collection; use Statamic\Facades\GraphQL; use Statamic\Fields\LabeledValue; -use Statamic\Forms\FieldOptions; +use Statamic\Fields\SampleValue; use Statamic\GraphQL\Types\LabeledValueType; use Statamic\Support\Arr; @@ -222,7 +222,7 @@ public function extraRenderableFieldData(): array public function previewValue(): mixed { - $keys = FieldOptions::keys($this->field()); + $keys = SampleValue::optionKeys($this->field()); return $this->field()->get('multiple') ? array_slice($keys, 0, 1) @@ -231,7 +231,7 @@ public function previewValue(): mixed public function fakeValue(Generator $faker): mixed { - $keys = FieldOptions::keys($this->field()); + $keys = SampleValue::optionKeys($this->field()); return $keys === [] ? null : $faker->randomElement($keys); } diff --git a/src/Fieldtypes/Hidden.php b/src/Fieldtypes/Hidden.php index 255eabbbe8d..d25e36e7d93 100644 --- a/src/Fieldtypes/Hidden.php +++ b/src/Fieldtypes/Hidden.php @@ -4,7 +4,7 @@ use Faker\Generator; use Statamic\Fields\Fieldtype; -use Statamic\Forms\PreviewSubmission; +use Statamic\Fields\SampleValue; use function Statamic\trans as __; @@ -30,7 +30,7 @@ protected function configFieldItems(): array public function previewValue(): mixed { - return PreviewSubmission::placeholder($this->field()); + return SampleValue::placeholder($this->field()); } public function fakeValue(Generator $faker): mixed diff --git a/src/Fieldtypes/ImageChoice.php b/src/Fieldtypes/ImageChoice.php index 7b4a4627a59..1506faaaedb 100644 --- a/src/Fieldtypes/ImageChoice.php +++ b/src/Fieldtypes/ImageChoice.php @@ -6,7 +6,7 @@ use Statamic\Facades\Asset; use Statamic\Facades\AssetContainer; use Statamic\Fields\Fieldtype; -use Statamic\Forms\FieldOptions; +use Statamic\Fields\SampleValue; use Statamic\Support\Arr; use Statamic\Support\Str; @@ -96,7 +96,7 @@ private function resolveImageUrl(mixed $image): ?string public function previewValue(): mixed { - $keys = FieldOptions::keys($this->field()); + $keys = SampleValue::optionKeys($this->field()); return $this->field()->get('multiple') ? array_slice($keys, 0, 1) @@ -109,14 +109,14 @@ public function fakeValue(Generator $faker): mixed return $this->randomOptionKeys($faker); } - $keys = FieldOptions::keys($this->field()); + $keys = SampleValue::optionKeys($this->field()); return $keys === [] ? null : $faker->randomElement($keys); } private function randomOptionKeys(Generator $faker): array { - $keys = FieldOptions::keys($this->field()); + $keys = SampleValue::optionKeys($this->field()); if ($keys === []) { return []; diff --git a/src/Fieldtypes/Lists.php b/src/Fieldtypes/Lists.php index 5505ef16e57..63e2817a647 100644 --- a/src/Fieldtypes/Lists.php +++ b/src/Fieldtypes/Lists.php @@ -5,7 +5,7 @@ use Faker\Generator; use Statamic\Facades\GraphQL; use Statamic\Fields\Fieldtype; -use Statamic\Forms\PreviewSubmission; +use Statamic\Fields\SampleValue; use function Statamic\trans as __; @@ -62,7 +62,7 @@ public function toGqlType() public function previewValue(): mixed { - return [PreviewSubmission::placeholder($this->field())]; + return [SampleValue::placeholder($this->field())]; } public function fakeValue(Generator $faker): mixed diff --git a/src/Fieldtypes/Markdown.php b/src/Fieldtypes/Markdown.php index e764069f87a..d5233d52a99 100644 --- a/src/Fieldtypes/Markdown.php +++ b/src/Fieldtypes/Markdown.php @@ -6,7 +6,7 @@ use Statamic\Facades\GraphQL; use Statamic\Fields\Field; use Statamic\Fields\Fieldtype; -use Statamic\Forms\PreviewSubmission; +use Statamic\Fields\SampleValue; use Statamic\Query\Scopes\Filters\Fields\Markdown as MarkdownFilter; use Statamic\Support\Html; @@ -271,7 +271,7 @@ public function replaceAssetReferences($data, ?string $newValue, string $oldValu public function previewValue(): mixed { - return PreviewSubmission::placeholder($this->field()); + return SampleValue::placeholder($this->field()); } public function fakeValue(Generator $faker): mixed diff --git a/src/Fieldtypes/Ranking.php b/src/Fieldtypes/Ranking.php index bdf1e97e2ae..795ffdecbe7 100644 --- a/src/Fieldtypes/Ranking.php +++ b/src/Fieldtypes/Ranking.php @@ -4,7 +4,7 @@ use Faker\Generator; use Statamic\Fields\Fieldtype; -use Statamic\Forms\FieldOptions; +use Statamic\Fields\SampleValue; class Ranking extends Fieldtype { @@ -12,11 +12,11 @@ class Ranking extends Fieldtype public function previewValue(): mixed { - return FieldOptions::keys($this->field()); + return SampleValue::optionKeys($this->field()); } public function fakeValue(Generator $faker): mixed { - return $faker->shuffleArray(FieldOptions::keys($this->field())); + return $faker->shuffleArray(SampleValue::optionKeys($this->field())); } } diff --git a/src/Fieldtypes/Revealer.php b/src/Fieldtypes/Revealer.php index 1d62a74db1e..2eca98f00c3 100644 --- a/src/Fieldtypes/Revealer.php +++ b/src/Fieldtypes/Revealer.php @@ -4,7 +4,7 @@ use Faker\Generator; use Statamic\Fields\Fieldtype; -use Statamic\Forms\PreviewSubmission; +use Statamic\Fields\SampleValue; use function Statamic\trans as __; @@ -59,7 +59,7 @@ public function process($data) public function previewValue(): mixed { - return PreviewSubmission::placeholder($this->field()); + return SampleValue::placeholder($this->field()); } public function fakeValue(Generator $faker): mixed diff --git a/src/Fieldtypes/Text.php b/src/Fieldtypes/Text.php index c2574050ff6..37d0a909fdf 100644 --- a/src/Fieldtypes/Text.php +++ b/src/Fieldtypes/Text.php @@ -4,7 +4,7 @@ use Faker\Generator; use Statamic\Fields\Fieldtype; -use Statamic\Forms\PreviewSubmission; +use Statamic\Fields\SampleValue; use Statamic\Support\Str; use function Statamic\trans as __; @@ -182,7 +182,7 @@ public function previewValue(): mixed return match ($this->field()->get('input_type')) { 'email' => 'name@example.com', 'url' => 'https://example.com', - default => PreviewSubmission::placeholder($this->field()), + default => SampleValue::placeholder($this->field()), }; } diff --git a/src/Fieldtypes/Textarea.php b/src/Fieldtypes/Textarea.php index dd21ae52fcf..adc3e446e86 100644 --- a/src/Fieldtypes/Textarea.php +++ b/src/Fieldtypes/Textarea.php @@ -4,7 +4,7 @@ use Faker\Generator; use Statamic\Fields\Fieldtype; -use Statamic\Forms\PreviewSubmission; +use Statamic\Fields\SampleValue; use Statamic\Query\Scopes\Filters\Fields\Textarea as TextareaFilter; use function Statamic\trans as __; @@ -71,7 +71,7 @@ public function filter() public function previewValue(): mixed { - return PreviewSubmission::placeholder($this->field()); + return SampleValue::placeholder($this->field()); } public function fakeValue(Generator $faker): mixed diff --git a/src/Forms/FakeSubmissionGenerator.php b/src/Forms/FakeSubmissionGenerator.php index 826550831b6..fffa2156fb8 100644 --- a/src/Forms/FakeSubmissionGenerator.php +++ b/src/Forms/FakeSubmissionGenerator.php @@ -5,13 +5,10 @@ use Faker\Generator; use Statamic\Contracts\Forms\Form as FormContract; use Statamic\Fields\Field; +use Statamic\Fields\SampleValue; class FakeSubmissionGenerator { - private const RANDOMIZED_DEFAULTS = [ - 'toggle', 'checkboxes', 'select', 'radio', 'button_group', 'image_choice', 'yes_no', 'ranking', 'dictionary', - ]; - protected Generator $faker; public static function enabled(): bool @@ -27,18 +24,7 @@ public function __construct() public function generate(FormContract $form): array { return $form->blueprint()->fields()->all() - ->mapWithKeys(fn (Field $field) => [$field->handle() => static::valueFor($field, $this->faker)]) + ->mapWithKeys(fn (Field $field) => [$field->handle() => SampleValue::fake($field, $this->faker)]) ->all(); } - - public static function valueFor(Field $field, Generator $faker): mixed - { - $default = $field->defaultValue(); - - if ($default !== null && ! in_array($field->type(), self::RANDOMIZED_DEFAULTS)) { - return $default; - } - - return $field->fieldtype()->fakeValue($faker) ?? $default; - } } 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 43854487d83..980b0a97a7a 100644 --- a/src/Forms/PreviewSubmission.php +++ b/src/Forms/PreviewSubmission.php @@ -5,8 +5,6 @@ use Statamic\Contracts\Forms\Form as FormContract; use Statamic\Fields\Field; -use function Statamic\trans as __; - class PreviewSubmission { public function values(FormContract $form): array @@ -15,9 +13,4 @@ public function values(FormContract $form): array ->mapWithKeys(fn (Field $field) => [$field->handle() => $field->fieldtype()->previewValue()]) ->all(); } - - public static function placeholder(Field $field): string - { - return '['.__($field->display()).']'; - } } From 1f96c21fc867dfaaa6dc2c53e4a56058f3933b3f Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Tue, 10 Nov 2026 15:10:06 -0500 Subject: [PATCH 4/7] Drop the generator's default-value rule Fakes now always come from fakeValue(). The only field whose default is its real value is a hidden field, so Hidden's previewValue() returns its default, and its fakeValue() falls back to that. Revealer never stores a value, so it no longer implements either hook. Co-Authored-By: Claude Opus 5.5 --- src/Fields/SampleValue.php | 16 ---------------- src/Fieldtypes/Group.php | 3 +-- src/Fieldtypes/Hidden.php | 8 +------- src/Fieldtypes/Revealer.php | 12 ------------ src/Forms/FakeSubmissionGenerator.php | 3 +-- tests/Fields/FieldtypeSampleValuesTest.php | 10 +++++++--- tests/Forms/FakeSubmissionGeneratorTest.php | 8 +++++--- 7 files changed, 15 insertions(+), 45 deletions(-) diff --git a/src/Fields/SampleValue.php b/src/Fields/SampleValue.php index 6fdb1e4e567..da8b530ac5a 100644 --- a/src/Fields/SampleValue.php +++ b/src/Fields/SampleValue.php @@ -2,28 +2,12 @@ namespace Statamic\Fields; -use Faker\Generator; use Illuminate\Support\Arr; use function Statamic\trans as __; class SampleValue { - private const RANDOMIZED_DEFAULTS = [ - 'toggle', 'checkboxes', 'select', 'radio', 'button_group', 'image_choice', 'yes_no', 'ranking', 'dictionary', - ]; - - public static function fake(Field $field, Generator $faker): mixed - { - $default = $field->defaultValue(); - - if ($default !== null && ! in_array($field->type(), self::RANDOMIZED_DEFAULTS)) { - return $default; - } - - return $field->fieldtype()->fakeValue($faker) ?? $default; - } - public static function placeholder(Field $field): string { return '['.__($field->display()).']'; diff --git a/src/Fieldtypes/Group.php b/src/Fieldtypes/Group.php index a9d4cb84cde..9a89f0de8e5 100644 --- a/src/Fieldtypes/Group.php +++ b/src/Fieldtypes/Group.php @@ -9,7 +9,6 @@ use Statamic\Fields\Field; use Statamic\Fields\Fields; use Statamic\Fields\Fieldtype; -use Statamic\Fields\SampleValue; use Statamic\Fields\Values; use Statamic\Forms\Fields\FormField; use Statamic\Forms\Fields\FormFields; @@ -240,7 +239,7 @@ public function previewValue(): mixed public function fakeValue(Generator $faker): mixed { return $this->sampleFields() - ->map(fn (Field $field) => SampleValue::fake($field, $faker)) + ->map(fn (Field $field) => $field->fieldtype()->fakeValue($faker)) ->all(); } diff --git a/src/Fieldtypes/Hidden.php b/src/Fieldtypes/Hidden.php index d25e36e7d93..3c683d2284d 100644 --- a/src/Fieldtypes/Hidden.php +++ b/src/Fieldtypes/Hidden.php @@ -2,7 +2,6 @@ namespace Statamic\Fieldtypes; -use Faker\Generator; use Statamic\Fields\Fieldtype; use Statamic\Fields\SampleValue; @@ -30,11 +29,6 @@ protected function configFieldItems(): array public function previewValue(): mixed { - return SampleValue::placeholder($this->field()); - } - - public function fakeValue(Generator $faker): mixed - { - return app(Text::class)->setField($this->field())->fakeValue($faker); + return $this->field()->defaultValue() ?? SampleValue::placeholder($this->field()); } } diff --git a/src/Fieldtypes/Revealer.php b/src/Fieldtypes/Revealer.php index 2eca98f00c3..41a69387439 100644 --- a/src/Fieldtypes/Revealer.php +++ b/src/Fieldtypes/Revealer.php @@ -2,9 +2,7 @@ namespace Statamic\Fieldtypes; -use Faker\Generator; use Statamic\Fields\Fieldtype; -use Statamic\Fields\SampleValue; use function Statamic\trans as __; @@ -56,14 +54,4 @@ public function process($data) // return null; } - - public function previewValue(): mixed - { - return SampleValue::placeholder($this->field()); - } - - public function fakeValue(Generator $faker): mixed - { - return app(Text::class)->setField($this->field())->fakeValue($faker); - } } diff --git a/src/Forms/FakeSubmissionGenerator.php b/src/Forms/FakeSubmissionGenerator.php index fffa2156fb8..e9903f6d06b 100644 --- a/src/Forms/FakeSubmissionGenerator.php +++ b/src/Forms/FakeSubmissionGenerator.php @@ -5,7 +5,6 @@ use Faker\Generator; use Statamic\Contracts\Forms\Form as FormContract; use Statamic\Fields\Field; -use Statamic\Fields\SampleValue; class FakeSubmissionGenerator { @@ -24,7 +23,7 @@ public function __construct() public function generate(FormContract $form): array { return $form->blueprint()->fields()->all() - ->mapWithKeys(fn (Field $field) => [$field->handle() => SampleValue::fake($field, $this->faker)]) + ->mapWithKeys(fn (Field $field) => [$field->handle() => $field->fieldtype()->fakeValue($this->faker)]) ->all(); } } diff --git a/tests/Fields/FieldtypeSampleValuesTest.php b/tests/Fields/FieldtypeSampleValuesTest.php index db648568b56..068fca4f5ba 100644 --- a/tests/Fields/FieldtypeSampleValuesTest.php +++ b/tests/Fields/FieldtypeSampleValuesTest.php @@ -69,7 +69,8 @@ public static function previewValueProvider() 'textarea' => [['type' => 'textarea', 'display' => 'Message'], '[Message]'], 'markdown' => [['type' => 'markdown', 'display' => 'Body'], '[Body]'], 'hidden' => [['type' => 'hidden', 'display' => 'Source'], '[Source]'], - 'revealer' => [['type' => 'revealer', 'display' => 'More'], '[More]'], + 'hidden with default' => [['type' => 'hidden', 'display' => 'Source', 'default' => 'newsletter'], 'newsletter'], + 'revealer' => [['type' => 'revealer', 'display' => 'More'], null], 'list' => [['type' => 'list', 'display' => 'Items'], ['[Items]']], 'integer' => [['type' => 'integer'], 1], 'integer with min' => [['type' => 'integer', 'min' => 16], 16], @@ -120,7 +121,10 @@ public static function fakeValueProvider() '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->assertIsString($value)], + 'hidden' => [['type' => 'hidden', 'display' => 'Source'], fn ($value) => $this->assertSame('[Source]', $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])], @@ -140,7 +144,7 @@ public static function fakeValueProvider() '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' => 'text', 'default' => 'Toronto']], + ['handle' => 'city', 'field' => ['type' => 'hidden', 'default' => 'Toronto']], ['handle' => 'agree', 'field' => ['type' => 'toggle']], ]], function ($value) { $this->assertSame('Toronto', $value['city']); diff --git a/tests/Forms/FakeSubmissionGeneratorTest.php b/tests/Forms/FakeSubmissionGeneratorTest.php index 26a23a06f56..5240e24df45 100644 --- a/tests/Forms/FakeSubmissionGeneratorTest.php +++ b/tests/Forms/FakeSubmissionGeneratorTest.php @@ -276,10 +276,11 @@ public function it_falls_back_to_a_custom_fieldtypes_preview_value() } #[Test] - public function it_uses_defaults_for_text_but_randomizes_options() + public function it_uses_hidden_field_defaults_but_ignores_other_defaults() { $form = $this->makeForm('defaults', [ - ['handle' => 'source', 'field' => ['type' => 'short_answer', 'default' => 'newsletter']], + ['handle' => 'source', 'field' => ['type' => 'hidden', 'default' => 'newsletter']], + ['handle' => 'subject', 'field' => ['type' => 'short_answer', 'default' => 'Hello']], ['handle' => 'colour', 'field' => [ 'type' => 'dropdown', 'default' => 'red', @@ -293,6 +294,7 @@ public function it_uses_defaults_for_text_but_randomizes_options() $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()); } @@ -304,7 +306,7 @@ public function it_generates_values_for_groups_with_core_fieldtypes() 'type' => 'group', 'fields' => [ ['handle' => 'city', 'field' => ['type' => 'text', 'display' => 'City']], - ['handle' => 'country', 'field' => ['type' => 'text', 'default' => 'Canada']], + ['handle' => 'country', 'field' => ['type' => 'hidden', 'default' => 'Canada']], ['handle' => 'floor', 'field' => ['type' => 'integer', 'min' => 1, 'max' => 3]], ], ]], From fd319cace8ba65cd06d9d2a8545a8612479775d1 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Tue, 10 Nov 2026 15:37:52 -0500 Subject: [PATCH 5/7] Use example values instead of placeholders in previews Previews now show values like a real submission would (Jane Doe, 555-0100, lorem ipsum) rather than the field's label in brackets. Text picks its example value with the same handle/label matching as fakes, which now treats spaces in labels as underscores so labels like "First Name" match. Co-Authored-By: Claude Opus 5.5 --- src/Fields/SampleValue.php | 11 ++- src/Fieldtypes/Hidden.php | 3 +- src/Fieldtypes/Lists.php | 3 +- src/Fieldtypes/Markdown.php | 2 +- src/Fieldtypes/Slug.php | 2 +- src/Fieldtypes/Text.php | 86 +++++++++++++------ src/Fieldtypes/Textarea.php | 2 +- tests/Fields/FieldtypeSampleValuesTest.php | 22 +++-- tests/Forms/Connectors/EmailConnectorTest.php | 6 +- tests/Forms/PreviewSubmissionTest.php | 17 ++-- 10 files changed, 98 insertions(+), 56 deletions(-) diff --git a/src/Fields/SampleValue.php b/src/Fields/SampleValue.php index da8b530ac5a..84dfbc6d4bd 100644 --- a/src/Fields/SampleValue.php +++ b/src/Fields/SampleValue.php @@ -4,13 +4,16 @@ use Illuminate\Support\Arr; -use function Statamic\trans as __; - class SampleValue { - public static function placeholder(Field $field): string + public static function sentence(): string + { + return 'Lorem ipsum dolor sit amet'; + } + + public static function paragraph(): string { - return '['.__($field->display()).']'; + return 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.'; } public static function optionKeys(Field $field): array diff --git a/src/Fieldtypes/Hidden.php b/src/Fieldtypes/Hidden.php index 3c683d2284d..e2ddefdd43b 100644 --- a/src/Fieldtypes/Hidden.php +++ b/src/Fieldtypes/Hidden.php @@ -3,7 +3,6 @@ namespace Statamic\Fieldtypes; use Statamic\Fields\Fieldtype; -use Statamic\Fields\SampleValue; use function Statamic\trans as __; @@ -29,6 +28,6 @@ protected function configFieldItems(): array public function previewValue(): mixed { - return $this->field()->defaultValue() ?? SampleValue::placeholder($this->field()); + return $this->field()->defaultValue(); } } diff --git a/src/Fieldtypes/Lists.php b/src/Fieldtypes/Lists.php index 63e2817a647..d2fdf38821e 100644 --- a/src/Fieldtypes/Lists.php +++ b/src/Fieldtypes/Lists.php @@ -5,7 +5,6 @@ use Faker\Generator; use Statamic\Facades\GraphQL; use Statamic\Fields\Fieldtype; -use Statamic\Fields\SampleValue; use function Statamic\trans as __; @@ -62,7 +61,7 @@ public function toGqlType() public function previewValue(): mixed { - return [SampleValue::placeholder($this->field())]; + return ['Lorem', 'Ipsum', 'Dolor']; } public function fakeValue(Generator $faker): mixed diff --git a/src/Fieldtypes/Markdown.php b/src/Fieldtypes/Markdown.php index d5233d52a99..39331d21600 100644 --- a/src/Fieldtypes/Markdown.php +++ b/src/Fieldtypes/Markdown.php @@ -271,7 +271,7 @@ public function replaceAssetReferences($data, ?string $newValue, string $oldValu public function previewValue(): mixed { - return SampleValue::placeholder($this->field()); + return SampleValue::paragraph(); } public function fakeValue(Generator $faker): mixed diff --git a/src/Fieldtypes/Slug.php b/src/Fieldtypes/Slug.php index ade9900f85f..68cd6a273a2 100644 --- a/src/Fieldtypes/Slug.php +++ b/src/Fieldtypes/Slug.php @@ -47,7 +47,7 @@ protected function configFieldItems(): array public function previewValue(): mixed { - return IlluminateStr::slug(__($this->field()->display())); + return IlluminateStr::slug(parent::previewValue()); } public function fakeValue(Generator $faker): mixed diff --git a/src/Fieldtypes/Text.php b/src/Fieldtypes/Text.php index 37d0a909fdf..462d469e5c0 100644 --- a/src/Fieldtypes/Text.php +++ b/src/Fieldtypes/Text.php @@ -179,49 +179,85 @@ public function preProcessIndex($value) public function previewValue(): mixed { - return match ($this->field()->get('input_type')) { + return match ($this->sampleKind()) { 'email' => 'name@example.com', 'url' => 'https://example.com', - default => SampleValue::placeholder($this->field()), + '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 match ($this->field()->get('input_type')) { + return (string) match ($this->sampleKind()) { 'email' => $faker->safeEmail(), 'url' => $faker->url(), - 'tel' => $faker->phoneNumber(), - default => $this->fakeValueFromContext($faker), + '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 fakeValueFromContext(Generator $faker): string + private function sampleKind(): ?string { - $haystack = Str::lower(implode(' ', array_filter( + $inputType = match ($this->field()->get('input_type')) { + 'email' => 'email', + 'url' => 'url', + 'tel' => 'phone', + default => null, + }; + + if ($inputType) { + return $inputType; + } + + $haystack = str_replace(' ', '_', Str::lower(implode(' ', array_filter( [$this->field()->handle(), $this->field()->display()], fn ($part) => is_string($part) && $part !== '' - ))); + )))); $looksLike = fn (array $tokens) => collect($tokens)->contains(fn ($token) => str_contains($haystack, $token)); - return (string) match (true) { - $looksLike(['email', 'e-mail']) => $faker->safeEmail(), - $looksLike(['phone', 'tel', 'mobile', 'fax']) => $faker->phoneNumber(), - $looksLike(['first_name', 'firstname', 'given_name']) => $faker->firstName(), - $looksLike(['last_name', 'lastname', 'surname', 'family_name']) => $faker->lastName(), - $looksLike(['full_name', 'name']) || $this->field()->get('autocomplete') === 'name' => $faker->name(), - $looksLike(['company', 'organization', 'organisation', 'business']) => $faker->company(), - $looksLike(['address', 'street']) => $faker->streetAddress(), - $looksLike(['city', 'town']) => $faker->city(), - $looksLike(['state', 'province', 'region']) => $faker->state(), - $looksLike(['country']) => $faker->country(), - $looksLike(['zip', 'postal', 'postcode']) => $faker->postcode(), - $looksLike(['url', 'website', 'site']) => $faker->url(), - $looksLike(['job', 'title', 'position', 'role']) => $faker->jobTitle(), - $looksLike(['subject']) => $faker->sentence(6), - $looksLike(['message', 'comment', 'notes', 'description', 'bio']) => $faker->paragraph(2), - default => $faker->sentence(4), + return match (true) { + $looksLike(['email', 'e-mail']) => 'email', + $looksLike(['phone', 'tel', 'mobile', 'fax']) => 'phone', + $looksLike(['first_name', 'firstname', 'given_name']) => 'first_name', + $looksLike(['last_name', 'lastname', 'surname', 'family_name']) => 'last_name', + $looksLike(['full_name', 'name']) || $this->field()->get('autocomplete') === 'name' => 'name', + $looksLike(['company', 'organization', 'organisation', 'business']) => 'company', + $looksLike(['address', 'street']) => 'address', + $looksLike(['city', 'town']) => 'city', + $looksLike(['state', 'province', 'region']) => 'state', + $looksLike(['country']) => 'country', + $looksLike(['zip', 'postal', 'postcode']) => 'postcode', + $looksLike(['url', 'website', 'site']) => 'url', + $looksLike(['job', 'title', 'position', 'role']) => 'job', + $looksLike(['subject']) => 'subject', + $looksLike(['message', 'comment', 'notes', 'description', 'bio']) => 'paragraph', + default => null, }; } } diff --git a/src/Fieldtypes/Textarea.php b/src/Fieldtypes/Textarea.php index adc3e446e86..040bce49732 100644 --- a/src/Fieldtypes/Textarea.php +++ b/src/Fieldtypes/Textarea.php @@ -71,7 +71,7 @@ public function filter() public function previewValue(): mixed { - return SampleValue::placeholder($this->field()); + return SampleValue::paragraph(); } public function fakeValue(Generator $faker): mixed diff --git a/tests/Fields/FieldtypeSampleValuesTest.php b/tests/Fields/FieldtypeSampleValuesTest.php index 068fca4f5ba..40af3f9caad 100644 --- a/tests/Fields/FieldtypeSampleValuesTest.php +++ b/tests/Fields/FieldtypeSampleValuesTest.php @@ -9,6 +9,7 @@ use PHPUnit\Framework\Attributes\Test; use Statamic\Fields\Field; use Statamic\Fields\Fieldtype; +use Statamic\Fields\SampleValue; use Tests\TestCase; class FieldtypeSampleValuesTest extends TestCase @@ -61,17 +62,20 @@ public static function previewValueProvider() $options = ['options' => ['red' => 'Red', 'blue' => 'Blue']]; return [ - 'text' => [['type' => 'text', 'display' => 'First Name'], '[First Name]'], + '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' => 'Phone', 'input_type' => 'tel'], '[Phone]'], - 'slug' => [['type' => 'slug', 'display' => 'Page Title'], 'page-title'], - 'textarea' => [['type' => 'textarea', 'display' => 'Message'], '[Message]'], - 'markdown' => [['type' => 'markdown', 'display' => 'Body'], '[Body]'], - 'hidden' => [['type' => 'hidden', 'display' => 'Source'], '[Source]'], + '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', 'display' => 'Items'], ['[Items]']], + 'list' => [['type' => 'list'], ['Lorem', 'Ipsum', 'Dolor']], 'integer' => [['type' => 'integer'], 1], 'integer with min' => [['type' => 'integer', 'min' => 16], 16], 'range' => [['type' => 'range', 'min' => 0], 0], @@ -96,7 +100,7 @@ public static function previewValueProvider() 'group' => [['type' => 'group', 'fields' => [ ['handle' => 'city', 'field' => ['type' => 'text', 'display' => 'City']], ['handle' => 'agree', 'field' => ['type' => 'toggle']], - ]], ['city' => '[City]', 'agree' => true]], + ]], ['city' => 'Springfield', 'agree' => true]], ]; } @@ -121,7 +125,7 @@ public static function fakeValueProvider() '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', 'display' => 'Source'], fn ($value) => $this->assertSame('[Source]', $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)], 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/PreviewSubmissionTest.php b/tests/Forms/PreviewSubmissionTest.php index a86c57589df..c44c29f37b9 100644 --- a/tests/Forms/PreviewSubmissionTest.php +++ b/tests/Forms/PreviewSubmissionTest.php @@ -7,6 +7,7 @@ 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; @@ -48,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'); @@ -138,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']); @@ -163,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']); @@ -196,7 +197,7 @@ public function it_uses_null_for_fieldtypes_without_a_preview_value() } #[Test] - public function it_uses_placeholders_for_groups_with_core_fieldtypes() + public function it_uses_example_values_for_groups_with_core_fieldtypes() { $form = $this->makeForm('core_group', [ ['handle' => 'address', 'field' => [ @@ -208,7 +209,7 @@ public function it_uses_placeholders_for_groups_with_core_fieldtypes() ]], ]); - $this->assertSame(['city' => '[City]', 'floor' => 2], (new PreviewSubmission)->values($form)['address']); + $this->assertSame(['city' => 'Springfield', 'floor' => 2], (new PreviewSubmission)->values($form)['address']); } #[Test] From 36a6d7031887ea58263dc40d04d1022ca4e9b634 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Tue, 10 Nov 2026 16:22:25 -0500 Subject: [PATCH 6/7] Match text sample values on whole words Text now picks its sample kind from input_type, then the autocomplete attribute, then whole words in the handle and label, instead of substrings. Fields like hotel, statement and page_title no longer match phone, state and job title. Rules are checked from most to least specific, so company_email is an email and company_name is a company. Co-Authored-By: Claude Opus 5.5 --- src/Fieldtypes/Text.php | 111 ++++++++++++++++----- tests/Fields/FieldtypeSampleValuesTest.php | 43 ++++++++ 2 files changed, 128 insertions(+), 26 deletions(-) diff --git a/src/Fieldtypes/Text.php b/src/Fieldtypes/Text.php index 462d469e5c0..07f01643fbb 100644 --- a/src/Fieldtypes/Text.php +++ b/src/Fieldtypes/Text.php @@ -223,41 +223,100 @@ public function fakeValue(Generator $faker): mixed private function sampleKind(): ?string { - $inputType = match ($this->field()->get('input_type')) { + 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, }; + } - if ($inputType) { - return $inputType; - } - - $haystack = str_replace(' ', '_', Str::lower(implode(' ', array_filter( - [$this->field()->handle(), $this->field()->display()], - fn ($part) => is_string($part) && $part !== '' - )))); + private function sampleKindFromAutocomplete(): ?string + { + $autocomplete = $this->field()->get('autocomplete'); - $looksLike = fn (array $tokens) => collect($tokens)->contains(fn ($token) => str_contains($haystack, $token)); + if (! is_string($autocomplete) || $autocomplete === '') { + return null; + } - return match (true) { - $looksLike(['email', 'e-mail']) => 'email', - $looksLike(['phone', 'tel', 'mobile', 'fax']) => 'phone', - $looksLike(['first_name', 'firstname', 'given_name']) => 'first_name', - $looksLike(['last_name', 'lastname', 'surname', 'family_name']) => 'last_name', - $looksLike(['full_name', 'name']) || $this->field()->get('autocomplete') === 'name' => 'name', - $looksLike(['company', 'organization', 'organisation', 'business']) => 'company', - $looksLike(['address', 'street']) => 'address', - $looksLike(['city', 'town']) => 'city', - $looksLike(['state', 'province', 'region']) => 'state', - $looksLike(['country']) => 'country', - $looksLike(['zip', 'postal', 'postcode']) => 'postcode', - $looksLike(['url', 'website', 'site']) => 'url', - $looksLike(['job', 'title', 'position', 'role']) => 'job', - $looksLike(['subject']) => 'subject', - $looksLike(['message', 'comment', 'notes', 'description', 'bio']) => 'paragraph', + 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/tests/Fields/FieldtypeSampleValuesTest.php b/tests/Fields/FieldtypeSampleValuesTest.php index 40af3f9caad..e71f083d109 100644 --- a/tests/Fields/FieldtypeSampleValuesTest.php +++ b/tests/Fields/FieldtypeSampleValuesTest.php @@ -104,6 +104,49 @@ public static function previewValueProvider() ]; } + #[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) From 396a167374a3b91b88db632864d88e24ddcbc1e2 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Tue, 10 Nov 2026 16:51:14 -0500 Subject: [PATCH 7/7] Give multi-select fakes an array of options Selects with multiple enabled now get between one and max_items keys (or up to three) instead of a single key. Checkboxes, image choice and dictionary share the same random-subset helper. Co-Authored-By: Claude Opus 5.5 --- src/Fields/SampleValue.php | 12 ++++++++++++ src/Fieldtypes/Checkboxes.php | 13 +------------ src/Fieldtypes/Dictionary.php | 3 ++- src/Fieldtypes/HasSelectOptions.php | 6 +++++- src/Fieldtypes/ImageChoice.php | 13 +------------ tests/Fields/FieldtypeSampleValuesTest.php | 9 +++++++++ 6 files changed, 30 insertions(+), 26 deletions(-) diff --git a/src/Fields/SampleValue.php b/src/Fields/SampleValue.php index 84dfbc6d4bd..ff643c74df3 100644 --- a/src/Fields/SampleValue.php +++ b/src/Fields/SampleValue.php @@ -2,6 +2,7 @@ namespace Statamic\Fields; +use Faker\Generator; use Illuminate\Support\Arr; class SampleValue @@ -16,6 +17,17 @@ public static function paragraph(): string return 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.'; } + public static function randomKeys(array $keys, Generator $faker, mixed $max = null): array + { + if ($keys === []) { + return []; + } + + $limit = min((int) $max > 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', [])); diff --git a/src/Fieldtypes/Checkboxes.php b/src/Fieldtypes/Checkboxes.php index 94fbf2177c9..43ad2bec1eb 100644 --- a/src/Fieldtypes/Checkboxes.php +++ b/src/Fieldtypes/Checkboxes.php @@ -88,17 +88,6 @@ public function previewValue(): mixed public function fakeValue(Generator $faker): mixed { - return $this->randomOptionKeys($faker); - } - - private function randomOptionKeys(Generator $faker): array - { - $keys = SampleValue::optionKeys($this->field()); - - if ($keys === []) { - return []; - } - - return $faker->randomElements($keys, $faker->numberBetween(1, min(2, count($keys)))); + return SampleValue::randomKeys(SampleValue::optionKeys($this->field()), $faker); } } diff --git a/src/Fieldtypes/Dictionary.php b/src/Fieldtypes/Dictionary.php index 6b65a8f7e4b..648859d0cbf 100644 --- a/src/Fieldtypes/Dictionary.php +++ b/src/Fieldtypes/Dictionary.php @@ -10,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 __; @@ -220,7 +221,7 @@ public function fakeValue(Generator $faker): mixed return $faker->randomElement($keys); } - return $faker->randomElements($keys, $faker->numberBetween(1, min($maxItems ? (int) $maxItems : 2, count($keys)))); + return SampleValue::randomKeys($keys, $faker, $maxItems); } private function sampleOptions(): array diff --git a/src/Fieldtypes/HasSelectOptions.php b/src/Fieldtypes/HasSelectOptions.php index 323d0b954b7..330a9defabd 100644 --- a/src/Fieldtypes/HasSelectOptions.php +++ b/src/Fieldtypes/HasSelectOptions.php @@ -224,7 +224,7 @@ public function previewValue(): mixed { $keys = SampleValue::optionKeys($this->field()); - return $this->field()->get('multiple') + return $this->multiple() ? array_slice($keys, 0, 1) : ($keys[0] ?? null); } @@ -233,6 +233,10 @@ 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/ImageChoice.php b/src/Fieldtypes/ImageChoice.php index 1506faaaedb..f9dc4e014b9 100644 --- a/src/Fieldtypes/ImageChoice.php +++ b/src/Fieldtypes/ImageChoice.php @@ -106,22 +106,11 @@ public function previewValue(): mixed public function fakeValue(Generator $faker): mixed { if ($this->field()->get('multiple')) { - return $this->randomOptionKeys($faker); + return SampleValue::randomKeys(SampleValue::optionKeys($this->field()), $faker); } $keys = SampleValue::optionKeys($this->field()); return $keys === [] ? null : $faker->randomElement($keys); } - - private function randomOptionKeys(Generator $faker): array - { - $keys = SampleValue::optionKeys($this->field()); - - if ($keys === []) { - return []; - } - - return $faker->randomElements($keys, $faker->numberBetween(1, min(2, count($keys)))); - } } diff --git a/tests/Fields/FieldtypeSampleValuesTest.php b/tests/Fields/FieldtypeSampleValuesTest.php index e71f083d109..794b7a04075 100644 --- a/tests/Fields/FieldtypeSampleValuesTest.php +++ b/tests/Fields/FieldtypeSampleValuesTest.php @@ -183,6 +183,15 @@ public static function fakeValueProvider() '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']))],