diff --git a/resources/js/components/blueprints/Builder.vue b/resources/js/components/blueprints/Builder.vue
index 7a1d7b0598b..b3b8adb3762 100644
--- a/resources/js/components/blueprints/Builder.vue
+++ b/resources/js/components/blueprints/Builder.vue
@@ -56,7 +56,6 @@ export default {
initialBlueprint: Object,
showTitle: Boolean,
useTabs: { type: Boolean, default: true },
- isFormBlueprint: { type: Boolean, default: false },
},
data() {
@@ -84,15 +83,9 @@ export default {
this.$events.$on('root-form-save', () => {
this.$nextTick(() => this.save());
});
-
- if (this.isFormBlueprint) {
- Statamic.$config.set('isFormBlueprint', true);
- }
},
beforeUnmount() {
- Statamic.$config.set('isFormBlueprint', false);
-
this.$events.$off('root-form-save');
this.saveKeyBinding.destroy();
diff --git a/resources/js/components/fields/FieldtypeSelector.vue b/resources/js/components/fields/FieldtypeSelector.vue
index 461d40aa72a..fd655eb449f 100644
--- a/resources/js/components/fields/FieldtypeSelector.vue
+++ b/resources/js/components/fields/FieldtypeSelector.vue
@@ -225,11 +225,7 @@ export default {
created() {
if (this.fieldtypesLoaded) return;
- let url = cp_url('fields/fieldtypes?selectable=true');
-
- if (this.$config.get('isFormBlueprint')) url += '&forms=true';
-
- this.$axios.get(url)
+ this.$axios.get(cp_url('fields/fieldtypes?selectable=true'))
.then((response) => (loadedFieldtypes.value = response.data))
.catch((e) => {
this.$toast.error(e.response?.data?.message || __('Something went wrong'));
diff --git a/resources/js/pages/blueprints/Edit.vue b/resources/js/pages/blueprints/Edit.vue
index 090e5002e79..46e855ab778 100644
--- a/resources/js/pages/blueprints/Edit.vue
+++ b/resources/js/pages/blueprints/Edit.vue
@@ -10,7 +10,6 @@ defineProps({
canDefineLocalizable: { type: Boolean, default: undefined },
resetRoute: String,
isResettable: Boolean,
- isFormBlueprint: Boolean,
});
@@ -24,7 +23,6 @@ defineProps({
:initial-blueprint="blueprint"
:use-tabs="useTabs"
:can-define-localizable="canDefineLocalizable"
- :is-form-blueprint="isFormBlueprint"
>
diff --git a/resources/js/pages/blueprints/Index.vue b/resources/js/pages/blueprints/Index.vue
index d9ca169b8e2..f1ef3c04e07 100644
--- a/resources/js/pages/blueprints/Index.vue
+++ b/resources/js/pages/blueprints/Index.vue
@@ -4,7 +4,7 @@ import { Link } from '@inertiajs/vue3';
import Head from '@/pages/layout/Head.vue';
import { Header, Dropdown, DropdownMenu, DropdownLabel, DropdownItem, Button, Subheading, Panel, DocsCallout, Icon, StatusIndicator } from '@ui';
-defineProps(['collections', 'taxonomies', 'navs', 'assetContainers', 'globals', 'forms', 'userBlueprint', 'groupBlueprint', 'additional']);
+defineProps(['collections', 'taxonomies', 'navs', 'assetContainers', 'globals', 'userBlueprint', 'groupBlueprint', 'additional']);
const resetters = ref({});
@@ -179,29 +179,6 @@ const resetters = ref({});
-
-
-
-
-
-
- | {{ __('Blueprint') }} |
-
-
-
-
- |
-
-
-
-
- |
-
-
-
-
-
-
diff --git a/resources/js/pages/forms/Connect.vue b/resources/js/pages/forms/Connect.vue
index 24af846472f..a4c7a3c3a97 100644
--- a/resources/js/pages/forms/Connect.vue
+++ b/resources/js/pages/forms/Connect.vue
@@ -8,6 +8,7 @@ defineOptions({ layout: [Layout, FormsLayout] });
const props = defineProps({
form: Object,
+ fieldtypes: Array,
});
const formTitle = computed(() => props.form?.title || __('Untitled Form'));
@@ -33,4 +34,13 @@ const formTitle = computed(() => props.form?.title || __('Untitled Form'));
+
+
+
+
+ -
+ {{ fieldtype.title }}
+
+
+
diff --git a/resources/js/pages/forms/Index.vue b/resources/js/pages/forms/Index.vue
index af85ee15de7..8b1843af8fd 100644
--- a/resources/js/pages/forms/Index.vue
+++ b/resources/js/pages/forms/Index.vue
@@ -74,12 +74,6 @@ const reloadPage = () => router.reload();
-
diff --git a/resources/js/pages/forms/Show.vue b/resources/js/pages/forms/Show.vue
index 8c0daaccaef..57fa23dcbb8 100644
--- a/resources/js/pages/forms/Show.vue
+++ b/resources/js/pages/forms/Show.vue
@@ -123,12 +123,6 @@ function exportSubmissions() {
-
-
-
name('asset-containers.edit');
Route::patch('asset-containers/{asset_container}', [AssetContainerBlueprintController::class, 'update'])->name('asset-containers.update');
- Route::get('forms/{form}/edit', [FormBlueprintController::class, 'edit'])->name('forms.edit');
- Route::patch('forms/{form}', [FormBlueprintController::class, 'update'])->name('forms.update');
+ Route::get('forms/{form}/edit', FormBlueprintController::class)->name('forms.edit');
Route::get('globals/{global_set}/edit', [GlobalsBlueprintController::class, 'edit'])->name('globals.edit');
Route::patch('globals/{global_set}', [GlobalsBlueprintController::class, 'update'])->name('globals.update');
diff --git a/src/CommandPalette/Palette.php b/src/CommandPalette/Palette.php
index 99751cd3cdd..dd363fe44fd 100644
--- a/src/CommandPalette/Palette.php
+++ b/src/CommandPalette/Palette.php
@@ -114,10 +114,6 @@ protected function buildFields(): self
->map(fn ($set) => $set->blueprintCommandPaletteLink())
->each(fn (Link $link) => $this->addCommand($link));
- Facades\Form::all()
- ->map(fn ($form) => $form->blueprintCommandPaletteLink())
- ->each(fn (Link $link) => $this->addCommand($link));
-
$this->addCommand(Facades\User::blueprintCommandPaletteLink());
$this->addCommand(Facades\UserGroup::blueprintCommandPaletteLink());
diff --git a/src/Exceptions/FormFieldtypeNotFoundException.php b/src/Exceptions/FormFieldtypeNotFoundException.php
new file mode 100644
index 00000000000..4605473f935
--- /dev/null
+++ b/src/Exceptions/FormFieldtypeNotFoundException.php
@@ -0,0 +1,13 @@
+selectable;
}
+ /**
+ * @deprecated Use FormFieldtype::isSelectable() instead.
+ */
public function selectableInForms(): bool
{
if (FieldtypeRepository::selectableInFormIsOverriden($this->handle())) {
@@ -110,11 +116,17 @@ public function selectableInForms(): bool
return $this->selectableInForms;
}
+ /**
+ * @deprecated Use FormFieldtype::makeSelectable() instead.
+ */
public static function makeSelectableInForms()
{
FieldtypeRepository::makeSelectableInForms(self::handle());
}
+ /**
+ * @deprecated Use FormFieldtype::makeUnselectable() instead.
+ */
public static function makeUnselectableInForms()
{
FieldtypeRepository::makeUnselectableInForms(self::handle());
diff --git a/src/Fields/FieldtypeRepository.php b/src/Fields/FieldtypeRepository.php
index 0fe7eefa277..5b3d68829dc 100644
--- a/src/Fields/FieldtypeRepository.php
+++ b/src/Fields/FieldtypeRepository.php
@@ -39,21 +39,33 @@ public function handles()
});
}
+ /**
+ * @deprecated Use FormFieldtype::makeSelectable() instead.
+ */
public function makeSelectableInForms($handle)
{
$this->selectableInForms[$handle] = true;
}
+ /**
+ * @deprecated Use FormFieldtype::makeUnselectable() instead.
+ */
public function makeUnselectableInForms($handle)
{
$this->selectableInForms[$handle] = false;
}
+ /**
+ * @deprecated Use FormFieldtype::isSelectable() instead.
+ */
public function hasBeenMadeSelectableInForms($handle)
{
return $this->selectableInForms[$handle] ?? false;
}
+ /**
+ * @deprecated Use FormFieldtype::isSelectable() instead.
+ */
public function selectableInFormIsOverriden($handle)
{
return array_key_exists($handle, $this->selectableInForms);
diff --git a/src/Forms/Exceptions/BlueprintUndefinedException.php b/src/Forms/Exceptions/BlueprintUndefinedException.php
deleted file mode 100644
index 15a687e99ba..00000000000
--- a/src/Forms/Exceptions/BlueprintUndefinedException.php
+++ /dev/null
@@ -1,36 +0,0 @@
-handle()}] does not have a blueprint"))->setForm($form);
- }
-
- public function setForm(Form $form)
- {
- $this->form = $form;
-
- return $this;
- }
-
- public function getSolution(): Solution
- {
- return BaseSolution::create("The {$this->form->handle()} form does not have a blueprint defined.")
- ->setSolutionDescription("A blueprint defines the form's available fields and their behaviors.\n\nYou can add `blueprint: handle` to a form's YAML file.")
- ->setDocumentationLinks([
- 'Read the forms guide' => Statamic::docsUrl('forms'),
- ]);
- }
-}
diff --git a/src/Forms/Fields/Email.php b/src/Forms/Fields/Email.php
new file mode 100644
index 00000000000..148c95a5345
--- /dev/null
+++ b/src/Forms/Fields/Email.php
@@ -0,0 +1,34 @@
+ [
+ 'display' => __('Placeholder'),
+ 'instructions' => __('statamic::fieldtypes.text.config.placeholder'),
+ 'type' => 'text',
+ ],
+ ];
+ }
+
+ public function toFieldArray(): array
+ {
+ return [
+ 'type' => 'text',
+ 'input_type' => 'email',
+ 'placeholder' => $this->config('placeholder'),
+ 'validate' => array_values(array_unique([...((array) $this->config('validate', [])), 'email'])),
+ ...Arr::except($this->config(), ['type', 'input_type', 'placeholder', 'validate']),
+ ];
+ }
+}
diff --git a/src/Forms/Fields/Fallback.php b/src/Forms/Fields/Fallback.php
new file mode 100644
index 00000000000..51f00c80e83
--- /dev/null
+++ b/src/Forms/Fields/Fallback.php
@@ -0,0 +1,43 @@
+wrappedFieldtype = $fieldtype;
+
+ return $this;
+ }
+
+ public function toFieldArray(): array
+ {
+ return $this->config();
+ }
+
+ public function title(): string
+ {
+ return $this->wrappedFieldtype?->title() ?? parent::title();
+ }
+
+ public function toArray(): array
+ {
+ if (! $this->wrappedFieldtype) {
+ return parent::toArray();
+ }
+
+ return [
+ 'handle' => $this->wrappedFieldtype->handle(),
+ 'title' => $this->wrappedFieldtype->title(),
+ 'categories' => $this->wrappedFieldtype->categories(),
+ 'keywords' => $this->wrappedFieldtype->keywords(),
+ 'icon' => $this->wrappedFieldtype->icon(),
+ 'config' => $this->wrappedFieldtype->configFields()->toPublishArray(),
+ ];
+ }
+}
diff --git a/src/Forms/Fields/FormField.php b/src/Forms/Fields/FormField.php
new file mode 100644
index 00000000000..9e408cd33d3
--- /dev/null
+++ b/src/Forms/Fields/FormField.php
@@ -0,0 +1,56 @@
+handle;
+ }
+
+ public function config(): array
+ {
+ return $this->config;
+ }
+
+ public function type(): string
+ {
+ return Arr::get($this->config, 'type', 'short_answer');
+ }
+
+ public function fieldtype()
+ {
+ try {
+ return FormFieldtypeRepository::find($this->type())->setField($this);
+ } catch (FormFieldtypeNotFoundException $e) {
+ return (new Fallback)->setField($this);
+ }
+ }
+
+ public function display()
+ {
+ return Arr::get($this->config, 'display', __(Str::slugToTitle($this->handle)));
+ }
+
+ public function instructions()
+ {
+ return Arr::get($this->config, 'instructions');
+ }
+
+ public function toFieldArray(): array
+ {
+ return $this->fieldtype()->toFieldArray();
+ }
+}
diff --git a/src/Forms/Fields/FormFields.php b/src/Forms/Fields/FormFields.php
new file mode 100644
index 00000000000..1c3e35a3593
--- /dev/null
+++ b/src/Forms/Fields/FormFields.php
@@ -0,0 +1,141 @@
+contents;
+ }
+
+ public function items(): Collection
+ {
+ return collect($this->contents['sections'] ?? [])->flatMap(fn (array $section): array => $section['fields'] ?? []);
+ }
+
+ public function fields(): Collection
+ {
+ return $this->items()->flatMap(function (array $config): array {
+ if (isset($config['import'])) {
+ return $this->getImportedFields($config);
+ }
+
+ if (is_string($config['field'])) {
+ return [$config['handle'] => $this->getReferencedField($config)];
+ }
+
+ return [$config['handle'] => new FormField($config['handle'], $config['field'])];
+ })->filter();
+ }
+
+ public function field(string $handle): ?FormField
+ {
+ return $this->fields()->get($handle);
+ }
+
+ public function toBlueprint(): Blueprint
+ {
+ $contents = collect($this->contents['sections'] ?? [])
+ ->map(function (array $section): array {
+ return [
+ ...$section,
+ 'fields' => collect($section['fields'] ?? [])
+ ->map(function (array $config): array {
+ if (isset($config['import']) || is_string($config['field'])) {
+ return $config;
+ }
+
+ $formField = $this->field($config['handle']);
+
+ return [
+ 'handle' => $config['handle'],
+ 'field' => Arr::removeNullValues($formField->toFieldArray()),
+ ];
+ })
+ ->all(),
+ ];
+ })
+ ->all();
+
+ return Facades\Blueprint::make()->setContents([
+ 'tabs' => [
+ 'main' => [
+ 'sections' => $contents,
+ ],
+ ],
+ ]);
+ }
+
+ /**
+ * Borrowed from \Statamic\Fields\Fields.
+ */
+ private function getReferencedField(array $config): FormField
+ {
+ if (! $field = FieldRepository::find($config['field'])) {
+ throw new \Exception("Field {$config['field']} not found.");
+ }
+
+ if ($overrides = Arr::get($config, 'config')) {
+ $field->setConfig(array_merge($field->config(), $overrides));
+ }
+
+ return new FormField($field->handle(), $field->config());
+ }
+
+ /**
+ * Borrowed from \Statamic\Fields\Fields.
+ */
+ private function getImportedFields(array $config): array
+ {
+ $recursion = tap(app(FieldsetRecursionStack::class))->push($config['import']);
+
+ $blink = 'form-fields-imported-fields-'.md5(json_encode($config));
+
+ $imported = Blink::once($blink, function () use ($config) {
+ if (! $fieldset = FieldsetRepository::find($config['import'])) {
+ throw new FieldsetNotFoundException($config['import']);
+ }
+
+ $fields = $fieldset->fields()->all();
+
+ if ($overrides = $config['config'] ?? null) {
+ $fields = $fields->map(function ($field, $handle) use ($overrides) {
+ return $field->setConfig(array_merge($field->config(), $overrides[$handle] ?? []));
+ });
+ }
+
+ if ($prefix = Arr::get($config, 'prefix')) {
+ $fields = $fields->mapWithKeys(function ($field) use ($prefix) {
+ $field = clone $field;
+ $handle = $prefix.$field->handle();
+ $prefix = $prefix.$field->prefix();
+
+ return [$handle => $field->setHandle($handle)->setPrefix($prefix)];
+ });
+ }
+
+ return $fields;
+ })->map(function ($field) {
+ return new FormField($field->handle(), $field->config());
+ })->all();
+
+ $recursion->pop();
+
+ return $imported;
+ }
+}
diff --git a/src/Forms/Fields/FormFieldtype.php b/src/Forms/Fields/FormFieldtype.php
new file mode 100644
index 00000000000..11c5b87fa15
--- /dev/null
+++ b/src/Forms/Fields/FormFieldtype.php
@@ -0,0 +1,162 @@
+field = clone $field;
+
+ return $this;
+ }
+
+ public function field(): ?FormField
+ {
+ return $this->field;
+ }
+
+ public static function handle(): string
+ {
+ return Str::removeRight(static::traitHandle(), '_form_field');
+ }
+
+ public static function fieldtype(): ?string
+ {
+ return static::$fieldtype;
+ }
+
+ public function categories(): array
+ {
+ return $this->categories;
+ }
+
+ public function keywords(): array
+ {
+ return $this->keywords;
+ }
+
+ public function icon(): string
+ {
+ return $this->icon ?? "form-field-{$this->handle()}";
+ }
+
+ public function config(?string $key = null, $fallback = null)
+ {
+ if (! $this->field) {
+ return $fallback;
+ }
+
+ $config = $this->configFields()->all()
+ ->map->defaultValue()
+ ->merge($this->field->config());
+
+ return $key
+ ? ($config->get($key) ?? $fallback)
+ : $config->all();
+ }
+
+ public function configFields(): Fields
+ {
+ if ($cached = Blink::get($blink = 'form-config-fields-'.$this->handle())) {
+ return $cached;
+ }
+
+ $fields = collect($this->configFieldItems());
+
+ $fields = $fields
+ ->map(function ($field, $handle) {
+ return compact('handle', 'field');
+ });
+
+ $fields = new ConfigFields($fields);
+
+ Blink::put($blink, $fields);
+
+ return $fields;
+ }
+
+ protected function configFieldItems(): array
+ {
+ return $this->configFields;
+ }
+
+ public function toField(): Field
+ {
+ return new Field($this->handle(), $this->toFieldArray());
+ }
+
+ abstract public function toFieldArray(): array;
+
+ public function isSelectable(): bool
+ {
+ if (FormFieldtypeRepository::selectableIsOverriden($this->handle())) {
+ return FormFieldtypeRepository::hasBeenMadeSelectable($this->handle());
+ }
+
+ return $this->selectable;
+ }
+
+ public static function makeSelectable(): void
+ {
+ FormFieldtypeRepository::makeSelectable(static::handle());
+ }
+
+ public static function makeUnselectable(): void
+ {
+ FormFieldtypeRepository::makeUnselectable(static::handle());
+ }
+
+ public function toArray(): array
+ {
+ return [
+ 'handle' => $this->handle(),
+ 'title' => $this->title(),
+ 'categories' => $this->categories(),
+ 'keywords' => $this->keywords(),
+ 'icon' => $this->icon(),
+ 'config' => $this->configFields()->toPublishArray(),
+ ];
+ }
+}
diff --git a/src/Forms/Fields/FormFieldtypeRepository.php b/src/Forms/Fields/FormFieldtypeRepository.php
new file mode 100644
index 00000000000..ece5c80fa19
--- /dev/null
+++ b/src/Forms/Fields/FormFieldtypeRepository.php
@@ -0,0 +1,57 @@
+formFieldtypes[$handle])) {
+ return clone $this->formFieldtypes[$handle];
+ }
+
+ if (! ($formFields = $this->classes())->has($handle)) {
+ throw new FormFieldtypeNotFoundException($handle);
+ }
+
+ return $this->formFieldtypes[$handle] = app($formFields->get($handle));
+ }
+
+ public function classes(): Collection
+ {
+ return app('statamic.form-fieldtypes');
+ }
+
+ public function handles(): Collection
+ {
+ return $this->classes()->map(function ($class) {
+ return $class::handle();
+ });
+ }
+
+ public function makeSelectable(string $handle): void
+ {
+ $this->selectable[$handle] = true;
+ }
+
+ public function makeUnselectable(string $handle): void
+ {
+ $this->selectable[$handle] = false;
+ }
+
+ public function hasBeenMadeSelectable(string $handle): bool
+ {
+ return $this->selectable[$handle] ?? false;
+ }
+
+ public function selectableIsOverriden(string $handle): bool
+ {
+ return array_key_exists($handle, $this->selectable);
+ }
+}
diff --git a/src/Forms/Fields/LongAnswer.php b/src/Forms/Fields/LongAnswer.php
new file mode 100644
index 00000000000..6c5d888e437
--- /dev/null
+++ b/src/Forms/Fields/LongAnswer.php
@@ -0,0 +1,36 @@
+ [
+ 'display' => __('Placeholder'),
+ 'instructions' => __('statamic::fieldtypes.text.config.placeholder'),
+ 'type' => 'text',
+ ],
+ 'character_limit' => [
+ 'display' => __('Character Limit'),
+ 'instructions' => __('statamic::fieldtypes.text.config.character_limit_instructions'),
+ 'type' => 'integer',
+ ],
+ ];
+ }
+
+ public function toFieldArray(): array
+ {
+ return [
+ 'type' => 'textarea',
+ 'placeholder' => $this->config('placeholder'),
+ 'character_limit' => $this->config('character_limit'),
+ ...Arr::except($this->config(), ['type', 'placeholder', 'character_limit']),
+ ];
+ }
+}
diff --git a/src/Forms/Fields/ShortAnswer.php b/src/Forms/Fields/ShortAnswer.php
new file mode 100644
index 00000000000..57d819d71b0
--- /dev/null
+++ b/src/Forms/Fields/ShortAnswer.php
@@ -0,0 +1,36 @@
+ [
+ 'display' => __('Placeholder'),
+ 'instructions' => __('statamic::fieldtypes.text.config.placeholder'),
+ 'type' => 'text',
+ ],
+ 'character_limit' => [
+ 'display' => __('Character Limit'),
+ 'instructions' => __('statamic::fieldtypes.text.config.character_limit_instructions'),
+ 'type' => 'integer',
+ ],
+ ];
+ }
+
+ public function toFieldArray(): array
+ {
+ return [
+ 'type' => 'text',
+ 'placeholder' => $this->config('placeholder'),
+ 'character_limit' => $this->config('character_limit'),
+ ...Arr::except($this->config(), ['type', 'placeholder', 'character_limit']),
+ ];
+ }
+}
diff --git a/src/Forms/Form.php b/src/Forms/Form.php
index 1f91f392276..efdfc202cf3 100644
--- a/src/Forms/Form.php
+++ b/src/Forms/Form.php
@@ -18,13 +18,15 @@
use Statamic\Events\FormDeleting;
use Statamic\Events\FormSaved;
use Statamic\Events\FormSaving;
-use Statamic\Facades\Blueprint;
+use Statamic\Facades;
+use Statamic\Facades\Blink;
use Statamic\Facades\File;
use Statamic\Facades\Form as FormFacade;
use Statamic\Facades\FormSubmission;
use Statamic\Facades\YAML;
-use Statamic\Forms\Exceptions\BlueprintUndefinedException;
+use Statamic\Fields\Blueprint;
use Statamic\Forms\Exporters\Exporter;
+use Statamic\Forms\Fields\FormFields;
use Statamic\Statamic;
use Statamic\Support\Arr;
use Statamic\Support\Str;
@@ -36,7 +38,7 @@ class Form implements Arrayable, Augmentable, ContainsQueryableValues, FormContr
protected $handle;
protected $title;
- protected $blueprint;
+ protected $fields;
protected $honeypot;
protected $store;
protected $email;
@@ -78,6 +80,83 @@ public function title($title = null)
return $this->fluentlyGetOrSet('title')->args(func_get_args());
}
+ public function formFields($fields = null)
+ {
+ return $this
+ ->fluentlyGetOrSet('fields')
+ ->getter(function ($fields) {
+ if (empty($fields) && $blueprint = Facades\Blueprint::find("forms.{$this->handle()}")) {
+ $fields = $this->convertFieldsFromBlueprint($blueprint);
+ }
+
+ return new FormFields($fields ?? []);
+ })
+ ->setter(function ($fields) {
+ Blink::forget('form-blueprint-'.$this->handle());
+
+ if (isset($fields['tabs'])) {
+ $fields = [
+ 'sections' => collect($fields['tabs'])->flatMap(fn ($tab) => $tab['sections'])->all(),
+ ];
+ }
+
+ if (isset($fields['fields'])) {
+ $fields = [
+ 'sections' => [
+ ['fields' => $fields['fields']],
+ ],
+ ];
+ }
+
+ return $fields;
+ })
+ ->args(func_get_args());
+ }
+
+ private function convertFieldsFromBlueprint(Blueprint $blueprint): array
+ {
+ $sections = collect($blueprint->contents()['tabs'] ?? [])->flatMap(function (array $tab): array {
+ return collect($tab['sections'] ?? [])->map(function (array $section): array {
+ return [
+ ...$section,
+ 'fields' => collect($section['fields'] ?? [])->map(function (array $field): array {
+ $validate = Arr::get($field, 'field.validate');
+ $validateRules = is_string($validate) ? explode('|', $validate) : ($validate ?? []);
+
+ $isEmailRule = fn ($rule) => is_string($rule) && ($rule === 'email' || str_starts_with($rule, 'email:'));
+
+ if (Arr::get($field, 'field.type') === 'text' && collect($validateRules)->contains($isEmailRule)) {
+ Arr::set($field, 'field.type', 'email');
+ Arr::pull($field, 'field.input_type');
+
+ $remainingValidationRules = collect($validateRules)
+ ->reject($isEmailRule)
+ ->values();
+
+ if ($remainingValidationRules->isEmpty()) {
+ unset($field['field']['validate']);
+ } else {
+ $field['field']['validate'] = $remainingValidationRules->all();
+ }
+ }
+
+ if (Arr::get($field, 'field.type') === 'text') {
+ Arr::set($field, 'field.type', 'short_answer');
+ }
+
+ if (Arr::get($field, 'field.type') === 'textarea') {
+ Arr::set($field, 'field.type', 'long_answer');
+ }
+
+ return $field;
+ })->all(),
+ ];
+ })->all();
+ })->all();
+
+ return ['sections' => $sections];
+ }
+
/**
* Get the blueprint.
*
@@ -85,22 +164,19 @@ public function title($title = null)
*/
public function blueprint()
{
- $blueprint = Blueprint::find('forms.'.$this->handle())
- ?? Blueprint::makeFromFields([])->setHandle($this->handle())->setNamespace('forms');
+ if (Blink::has($blink = 'form-blueprint-'.$this->handle())) {
+ return Blink::get($blink);
+ }
+
+ $blueprint = $this->formFields()->toBlueprint();
+
+ Blink::put($blink, $blueprint);
FormBlueprintFound::dispatch($blueprint, $this);
return $blueprint;
}
- public function blueprintCommandPaletteLink()
- {
- return $this->blueprint()?->commandPaletteLink(
- type: 'Forms',
- url: $this->editBlueprintUrl(),
- );
- }
-
/**
* Get or set the honeypot field.
*
@@ -155,11 +231,7 @@ public function email($emails = null)
*/
public function fields()
{
- if (! $blueprint = $this->blueprint()) {
- throw BlueprintUndefinedException::create($this);
- }
-
- return $blueprint->fields()->all();
+ return $this->blueprint()->fields()->all();
}
/**
@@ -211,6 +283,7 @@ public function save()
$data = $this->data->merge(collect([
'title' => $this->title,
+ 'fields' => $this->formFields()->contents(),
'honeypot' => $this->honeypot,
'email' => collect(isset($this->email['to']) ? [$this->email] : $this->email)->map(function ($email) {
$email['markdown'] = Arr::get($email, 'markdown') === true ? true : null;
@@ -231,6 +304,10 @@ public function save()
File::put($this->path(), YAML::dump($data));
+ if ($blueprint = Facades\Blueprint::find("forms.{$this->handle()}")) {
+ $blueprint->delete();
+ }
+
foreach ($afterSaveCallbacks as $callback) {
$callback($this);
}
@@ -290,7 +367,7 @@ public function hydrate()
'email',
];
- $this->merge(collect($contents)->except($methods));
+ $this->merge(collect($contents)->except([...$methods, 'fields']));
collect($contents)
->filter(function ($value, $property) use ($methods) {
@@ -300,6 +377,10 @@ public function hydrate()
$this->{$property}($value);
});
+ if (isset($contents['fields'])) {
+ $this->formFields($contents['fields']);
+ }
+
return $this;
}
@@ -404,9 +485,10 @@ public function deleteUrl()
return cp_route('forms.destroy', $this->handle());
}
+ /** @deprecated */
public function editBlueprintUrl()
{
- return cp_route('blueprints.forms.edit', $this->handle());
+ return cp_route('forms.fields.index', $this->handle());
}
public function hasFiles()
diff --git a/src/Http/Controllers/CP/Fields/BlueprintController.php b/src/Http/Controllers/CP/Fields/BlueprintController.php
index 1b5b148f45c..6fee4335ee9 100644
--- a/src/Http/Controllers/CP/Fields/BlueprintController.php
+++ b/src/Http/Controllers/CP/Fields/BlueprintController.php
@@ -6,11 +6,9 @@
use Statamic\Facades\AssetContainer;
use Statamic\Facades\Blueprint;
use Statamic\Facades\Collection;
-use Statamic\Facades\Form;
use Statamic\Facades\GlobalSet;
use Statamic\Facades\Nav;
use Statamic\Facades\Taxonomy;
-use Statamic\Facades\User;
use Statamic\Http\Controllers\CP\CpController;
class BlueprintController extends CpController
@@ -32,7 +30,6 @@ public function index()
'navs' => $this->navs(),
'assetContainers' => $this->assets(),
'globals' => $this->globals(),
- 'forms' => $this->forms(),
'userBlueprint' => [
'edit_url' => cp_route('blueprints.users.edit'),
],
@@ -99,15 +96,4 @@ public function globals()
'edit_url' => cp_route('blueprints.globals.edit', $set->handle()),
])->values()->all();
}
-
- public function forms(): array
- {
- return User::current()->can('configure form fields')
- ? Form::all()->map(fn ($form) => [
- 'title' => $form->title(),
- 'handle' => $form->handle(),
- 'edit_url' => cp_route('blueprints.forms.edit', $form->handle()),
- ])->values()->all()
- : [];
- }
}
diff --git a/src/Http/Controllers/CP/Fields/FieldtypesController.php b/src/Http/Controllers/CP/Fields/FieldtypesController.php
index 892cccbd9f0..9408688e089 100644
--- a/src/Http/Controllers/CP/Fields/FieldtypesController.php
+++ b/src/Http/Controllers/CP/Fields/FieldtypesController.php
@@ -16,8 +16,7 @@ public function index(Request $request)
});
if ($request->selectable) {
- $selectableMethod = $request->forms ? 'selectableInForms' : 'selectable';
- $fieldtypes = $fieldtypes->filter->$selectableMethod();
+ $fieldtypes = $fieldtypes->filter->selectable();
}
return $fieldtypes->sortBy->handle()->values();
diff --git a/src/Http/Controllers/CP/Forms/FormBlueprintController.php b/src/Http/Controllers/CP/Forms/FormBlueprintController.php
index 013e974dc0d..5dda9c993dd 100644
--- a/src/Http/Controllers/CP/Forms/FormBlueprintController.php
+++ b/src/Http/Controllers/CP/Forms/FormBlueprintController.php
@@ -2,58 +2,13 @@
namespace Statamic\Http\Controllers\CP\Forms;
-use Illuminate\Http\Request;
-use Statamic\CP\Breadcrumbs\Breadcrumb;
-use Statamic\CP\Breadcrumbs\Breadcrumbs;
-use Statamic\Facades\Form;
use Statamic\Http\Controllers\CP\CpController;
-use Statamic\Http\Controllers\CP\Fields\ManagesBlueprints;
+/** @deprecated */
class FormBlueprintController extends CpController
{
- use ManagesBlueprints;
-
- public function __construct()
- {
- $this->middleware(\Illuminate\Auth\Middleware\Authorize::class.':configure form fields');
- }
-
- public function edit($form)
+ public function __invoke($form)
{
- $blueprint = $form->blueprint();
-
- Breadcrumbs::push(new Breadcrumb(
- text: 'Forms',
- ));
-
- Breadcrumbs::push(new Breadcrumb(
- text: $form->title(),
- url: request()->url(),
- icon: 'forms',
- links: Form::all()
- ->reject(fn ($f) => $f->handle() === $form->handle())
- ->map(fn ($f) => [
- 'text' => $f->title(),
- 'icon' => 'forms',
- 'url' => cp_route('blueprints.forms.edit', $f->handle()),
- ])
- ->values()
- ->all(),
- ));
-
- return $this->renderEditPage([
- 'blueprint' => $this->toVueObject($blueprint),
- 'action' => cp_route('blueprints.forms.update', $form->handle()),
- 'isFormBlueprint' => true,
- 'canDefineLocalizable' => false,
- 'useTabs' => false,
- ]);
- }
-
- public function update(Request $request, $form)
- {
- $request->validate(['tabs' => 'array']);
-
- $this->updateBlueprint($request, $form->blueprint());
+ return redirect(cp_route('forms.fields.index', $form->handle()));
}
}
diff --git a/src/Http/Controllers/CP/Forms/FormConnectController.php b/src/Http/Controllers/CP/Forms/FormConnectController.php
index 0c6ef889db9..41b62a767a0 100644
--- a/src/Http/Controllers/CP/Forms/FormConnectController.php
+++ b/src/Http/Controllers/CP/Forms/FormConnectController.php
@@ -2,15 +2,41 @@
namespace Statamic\Http\Controllers\CP\Forms;
+use Facades\Statamic\Fields\FieldtypeRepository;
use Inertia\Inertia;
+use Statamic\Forms\Fields\Fallback;
+use Statamic\Forms\Fields\FormFieldtype;
use Statamic\Http\Controllers\CP\CpController;
class FormConnectController extends CpController
{
public function __invoke($form)
{
+ $this->authorize('edit', $form);
+
+ // TODO: Remove from this controller when wiring up the form builder.
+ $formFieldtypes = app('statamic.form-fieldtypes')
+ ->unique()
+ ->map(fn ($class) => app($class))
+ ->filter->isSelectable()
+ ->values();
+
+ $fieldtypesPortedToFormFieldtypes = $formFieldtypes
+ ->map(fn (FormFieldtype $fieldtype) => $fieldtype::fieldtype())
+ ->filter()
+ ->unique()
+ ->values();
+
+ $legacySelectableFieldtypes = FieldtypeRepository::classes()
+ ->map(fn ($class) => app($class))
+ ->filter->selectableInForms()
+ ->reject(fn ($fieldtype) => $fieldtypesPortedToFormFieldtypes->contains($fieldtype->handle()))
+ ->map(fn ($fieldtype) => (new Fallback)->wrapping($fieldtype))
+ ->values();
+
return Inertia::render('forms/Connect', [
'form' => $form,
+ 'fieldtypes' => $formFieldtypes->merge($legacySelectableFieldtypes)->sortBy->title()->values(),
]);
}
}
diff --git a/src/Http/Controllers/CP/Forms/FormsController.php b/src/Http/Controllers/CP/Forms/FormsController.php
index ed5a9da71e7..7aaf4e57525 100644
--- a/src/Http/Controllers/CP/Forms/FormsController.php
+++ b/src/Http/Controllers/CP/Forms/FormsController.php
@@ -39,7 +39,6 @@ public function index(Request $request)
'show_url' => $form->showUrl(),
'edit_url' => $form->editUrl(),
'fields_url' => cp_route('forms.fields.index', $form->handle()),
- 'blueprint_url' => cp_route('blueprints.forms.edit', $form->handle()),
'can_edit' => User::current()->can('edit', $form),
'can_edit_blueprint' => User::current()->can('configure form fields', $form),
];
@@ -74,7 +73,6 @@ public function show($form)
'handle' => $form->handle(),
'editUrl' => $form->editUrl(),
'deleteUrl' => $form->deleteUrl(),
- 'blueprintUrl' => cp_route('blueprints.forms.edit', $form->handle()),
'canEdit' => User::current()->can('edit', $form),
'canDelete' => User::current()->can('delete', $form),
'canConfigureFields' => User::current()->can('configure form fields'),
@@ -236,18 +234,6 @@ protected function editFormBlueprint($form)
'fields' => [
'display' => __('Fields'),
'fields' => [
- 'blueprint' => [
- 'display' => __('Blueprint'),
- 'instructions' => __('statamic::messages.form_configure_blueprint_instructions'),
- 'type' => 'blueprints',
- 'options' => [
- [
- 'handle' => 'default',
- 'title' => __('Edit Blueprint'),
- 'edit_url' => cp_route('blueprints.forms.edit', $form->handle()),
- ],
- ],
- ],
'honeypot' => [
'type' => 'text',
'instructions' => __('statamic::messages.form_configure_honeypot_instructions'),
diff --git a/src/Providers/AddonServiceProvider.php b/src/Providers/AddonServiceProvider.php
index aa827598bd0..3393f3f1bb6 100644
--- a/src/Providers/AddonServiceProvider.php
+++ b/src/Providers/AddonServiceProvider.php
@@ -21,6 +21,7 @@
use Statamic\Facades\Path;
use Statamic\Facades\YAML;
use Statamic\Fields\Fieldtype;
+use Statamic\Forms\Fields\FormFieldtype;
use Statamic\Forms\JsDrivers\JsDriver;
use Statamic\Modifiers\Modifier;
use Statamic\Query\Scopes\Scope;
@@ -70,6 +71,11 @@ abstract class AddonServiceProvider extends ServiceProvider
*/
protected $fieldtypes = [];
+ /**
+ * @var list>
+ */
+ protected $formFieldtypes = [];
+
/**
* @var list>
*/
@@ -203,6 +209,7 @@ public function boot()
->bootActions()
->bootDictionaries()
->bootFieldtypes()
+ ->bootFormFieldtypes()
->bootModifiers()
->bootWidgets()
->bootFormJsDrivers()
@@ -361,6 +368,19 @@ protected function bootFieldtypes()
return $this;
}
+ protected function bootFormFieldtypes()
+ {
+ $formFieldtypes = collect($this->formFieldtypes)
+ ->merge($this->autoloadFilesFromFolder('FormFieldtypes', FormFieldtype::class))
+ ->unique();
+
+ foreach ($formFieldtypes as $class) {
+ $class::register();
+ }
+
+ return $this;
+ }
+
protected function bootModifiers()
{
$modifiers = collect($this->modifiers)
diff --git a/src/Providers/ExtensionServiceProvider.php b/src/Providers/ExtensionServiceProvider.php
index 551d23d7d92..522f46d3cdc 100644
--- a/src/Providers/ExtensionServiceProvider.php
+++ b/src/Providers/ExtensionServiceProvider.php
@@ -12,6 +12,8 @@
use Statamic\Dictionaries\Dictionary;
use Statamic\Fields\Fieldtype;
use Statamic\Fieldtypes;
+use Statamic\Forms;
+use Statamic\Forms\Fields\FormFieldtype;
use Statamic\Forms\JsDrivers;
use Statamic\Modifiers\CoreModifiers;
use Statamic\Modifiers\Modifier;
@@ -129,6 +131,12 @@ class ExtensionServiceProvider extends ServiceProvider
\Statamic\Forms\Fieldtype::class,
];
+ protected $formFieldtypes = [
+ Forms\Fields\Email::class,
+ Forms\Fields\LongAnswer::class,
+ Forms\Fields\ShortAnswer::class,
+ ];
+
protected $modifierAliases = [
'+' => 'add',
'-' => 'subtract',
@@ -305,6 +313,11 @@ protected function registerExtensions()
'directory' => 'Fieldtypes',
'extensions' => $this->fieldtypes,
],
+ 'form-fieldtypes' => [
+ 'class' => FormFieldtype::class,
+ 'directory' => 'FormFieldtypes',
+ 'extensions' => $this->formFieldtypes,
+ ],
'modifiers' => [
'class' => Modifier::class,
'directory' => 'Modifiers',
diff --git a/tests/Feature/Forms/EditFormTest.php b/tests/Feature/Forms/EditFormTest.php
index 7e8a97aae24..e5d4cd2c7cc 100644
--- a/tests/Feature/Forms/EditFormTest.php
+++ b/tests/Feature/Forms/EditFormTest.php
@@ -72,7 +72,6 @@ public function fields_can_be_added()
->assertSuccessful()
->assertSeeInOrder([
'Title',
- 'Blueprint',
'Honeypot',
'First injected into fields section',
'Second injected into fields section',
diff --git a/tests/Feature/GraphQL/FormTest.php b/tests/Feature/GraphQL/FormTest.php
index d3ce69622d4..7ab49259800 100644
--- a/tests/Feature/GraphQL/FormTest.php
+++ b/tests/Feature/GraphQL/FormTest.php
@@ -3,11 +3,9 @@
namespace Tests\Feature\GraphQL;
use Facades\Statamic\API\ResourceAuthorizer;
-use Facades\Statamic\Fields\BlueprintRepository;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\Test;
use Statamic\Contracts\GraphQL\CastableToValidationString;
-use Statamic\Facades\Blueprint;
use Statamic\Facades\Form;
use Tests\PreventSavingStacheItemsToDisk;
use Tests\TestCase;
@@ -24,8 +22,6 @@ public function setUp(): void
{
parent::setUp();
- BlueprintRepository::partialMock();
-
Form::all()->each->delete();
}
@@ -111,22 +107,24 @@ public function it_cannot_query_against_non_allowed_sub_resource()
#[Test]
public function it_queries_the_fields()
{
- Form::make('contact')->title('Contact Us')->save();
-
- $blueprint = Blueprint::makeFromFields([
- 'name' => [
- 'type' => 'text',
- 'display' => 'Your Name',
- 'instructions' => 'Enter your name',
- 'placeholder' => 'Type here...',
- 'invalid' => 'This isnt in the fieldtypes config fields so it shouldnt be output',
- 'width' => 50,
+ Form::make('contact')->title('Contact Us')->formFields([
+ 'sections' => [
+ [
+ 'fields' => [
+ ['handle' => 'name', 'field' => [
+ 'type' => 'short_answer',
+ 'display' => 'Your Name',
+ 'instructions' => 'Enter your name',
+ 'placeholder' => 'Type here...',
+ 'invalid' => 'This isnt in the fieldtypes config fields so it shouldnt be output',
+ 'width' => 50,
+ ]],
+ ['handle' => 'subject', 'field' => ['type' => 'select', 'options' => ['disco' => 'Disco', 'house' => 'House'], 'if' => ['name' => 'not empty']]],
+ ['handle' => 'message', 'field' => ['type' => 'long_answer', 'width' => 33, 'unless' => ['subject' => 'equals spam']]],
+ ],
+ ],
],
- 'subject' => ['type' => 'select', 'options' => ['disco' => 'Disco', 'house' => 'House'], 'if' => ['name' => 'not empty']],
- 'message' => ['type' => 'textarea', 'width' => 33, 'unless' => ['subject' => 'equals spam']],
- ]);
-
- BlueprintRepository::shouldReceive('find')->with('forms.contact')->andReturn($blueprint);
+ ])->save();
$query = <<<'GQL'
{
@@ -194,15 +192,17 @@ public function it_queries_the_fields()
#[Test]
public function it_queries_the_validation_rules()
{
- Form::make('contact')->title('Contact Us')->save();
-
- $blueprint = Blueprint::makeFromFields([
- 'name' => ['type' => 'text', 'validate' => ['required']],
- 'subject' => ['type' => 'select', 'options' => ['disco' => 'Disco', 'house' => 'House']],
- 'message' => ['type' => 'textarea', 'validate' => ['required_if:select_field,disco']],
- ]);
-
- BlueprintRepository::shouldReceive('find')->with('forms.contact')->andReturn($blueprint);
+ Form::make('contact')->title('Contact Us')->formFields([
+ 'sections' => [
+ [
+ 'fields' => [
+ ['handle' => 'name', 'field' => ['type' => 'short_answer', 'validate' => ['required']]],
+ ['handle' => 'subject', 'field' => ['type' => 'select', 'options' => ['disco' => 'Disco', 'house' => 'House']]],
+ ['handle' => 'message', 'field' => ['type' => 'long_answer', 'validate' => ['required_if:select_field,disco']]],
+ ],
+ ],
+ ],
+ ])->save();
$query = <<<'GQL'
{
@@ -230,29 +230,26 @@ public function it_queries_the_validation_rules()
#[Test]
public function it_queries_the_sections()
{
- Form::make('contact')->title('Contact Us')->save();
-
- $blueprint = Blueprint::makeFromFields([
- 'name' => [
- 'type' => 'text',
- 'display' => 'Your Name',
- 'instructions' => 'Enter your name',
- 'placeholder' => 'Type here...',
- 'invalid' => 'This isnt in the fieldtypes config fields so it shouldnt be output',
- 'width' => 50,
+ Form::make('contact')->title('Contact Us')->formFields([
+ 'sections' => [
+ [
+ 'display' => 'My Section',
+ 'instructions' => 'The section instructions',
+ 'fields' => [
+ ['handle' => 'name', 'field' => [
+ 'type' => 'short_answer',
+ 'display' => 'Your Name',
+ 'instructions' => 'Enter your name',
+ 'placeholder' => 'Type here...',
+ 'invalid' => 'This isnt in the fieldtypes config fields so it shouldnt be output',
+ 'width' => 50,
+ ]],
+ ['handle' => 'subject', 'field' => ['type' => 'select', 'options' => ['disco' => 'Disco', 'house' => 'House']]],
+ ['handle' => 'message', 'field' => ['type' => 'long_answer', 'width' => 33]],
+ ],
+ ],
],
- 'subject' => ['type' => 'select', 'options' => ['disco' => 'Disco', 'house' => 'House']],
- 'message' => ['type' => 'textarea', 'width' => 33],
- ]);
-
- // Set section display and instructions. You wouldn't really do this for a form blueprint,
- // but this is just to test the section type which doesn't get tested anywhere else.
- $contents = $blueprint->contents();
- $contents['tabs']['main']['sections'][0]['display'] = 'My Section';
- $contents['tabs']['main']['sections'][0]['instructions'] = 'The section instructions';
- $blueprint->setContents($contents);
-
- BlueprintRepository::shouldReceive('find')->with('forms.contact')->andReturn($blueprint);
+ ])->save();
$query = <<<'GQL'
{
@@ -322,25 +319,27 @@ public function it_queries_the_sections()
#[Test]
public function it_returns_string_based_validation_rules_for_mimes_mimetypes_dimension_size_and_image()
{
- Form::make('contact')->title('Contact Us')->save();
-
- $blueprint = Blueprint::makeFromFields([
- 'name' => [
- 'type' => 'assets',
- 'display' => 'Asset',
- 'validate' => [
- 'mimes:image/jpeg,image/png',
- 'mimetypes:image/jpeg,image/png',
- 'dimensions:1024',
- 'size:1000',
- 'image:jpeg',
- 'new Tests\Feature\GraphQL\TestValidationRuleWithToString',
- 'new Tests\Feature\GraphQL\TestValidationRuleWithoutToString',
+ Form::make('contact')->title('Contact Us')->formFields([
+ 'sections' => [
+ [
+ 'fields' => [
+ ['handle' => 'name', 'field' => [
+ 'type' => 'assets',
+ 'display' => 'Asset',
+ 'validate' => [
+ 'mimes:image/jpeg,image/png',
+ 'mimetypes:image/jpeg,image/png',
+ 'dimensions:1024',
+ 'size:1000',
+ 'image:jpeg',
+ 'new Tests\Feature\GraphQL\TestValidationRuleWithToString',
+ 'new Tests\Feature\GraphQL\TestValidationRuleWithoutToString',
+ ],
+ ]],
+ ],
],
],
- ]);
-
- BlueprintRepository::shouldReceive('find')->with('forms.contact')->andReturn($blueprint);
+ ])->save();
$query = <<<'GQL'
{
diff --git a/tests/Forms/EmailTest.php b/tests/Forms/EmailTest.php
index 4f166be1d94..52cae6a8c76 100644
--- a/tests/Forms/EmailTest.php
+++ b/tests/Forms/EmailTest.php
@@ -121,20 +121,23 @@ public function it_adds_subject_from_the_config()
#[Test]
public function it_adds_data_to_the_view()
{
- $social = Blueprint::makeFromFields(['twitter' => ['type' => 'text']])->setHandle('social')->setNamespace('globals');
- $company = Blueprint::makeFromFields(['company_name' => ['type' => 'text']])->setHandle('company')->setNamespace('globals');
- $formBlueprint = Blueprint::makeFromFields(['foo' => ['type' => 'text']]);
+ $socialBlueprint = Blueprint::makeFromFields(['twitter' => ['type' => 'text']])->setHandle('social')->setNamespace('globals');
+ $companyBlueprint = Blueprint::makeFromFields(['company_name' => ['type' => 'text']])->setHandle('company')->setNamespace('globals');
- BlueprintRepository::shouldReceive('find')->with('globals.social')->andReturn($social);
- BlueprintRepository::shouldReceive('find')->with('globals.company')->andReturn($company);
- BlueprintRepository::shouldReceive('find')->with('forms.test')->andReturn($formBlueprint);
+ BlueprintRepository::partialMock();
+ BlueprintRepository::shouldReceive('find')->with('globals.social')->andReturn($socialBlueprint);
+ BlueprintRepository::shouldReceive('find')->with('globals.company')->andReturn($companyBlueprint);
$social = tap(GlobalSet::make('social'))->save();
$social->inDefaultSite()->data(['twitter' => '@statamic'])->save();
$company = tap(GlobalSet::make('company'))->save();
$company->inDefaultSite()->data(['company_name' => 'Statamic'])->save();
- $form = tap(Form::make('test'))->save();
+ $form = tap(Form::make('test')->formFields([
+ 'fields' => [
+ ['handle' => 'foo', 'field' => ['type' => 'short_answer']],
+ ],
+ ]))->save();
$submission = $form->makeSubmission()->data(['foo' => 'bar']);
$email = $this->makeEmailWithSubmission($submission);
@@ -193,20 +196,20 @@ private function makeEmailWithConfig(array $config)
'email' => 'info@example.com',
])->save();
- $formBlueprint = Blueprint::makeFromFields([
- 'name' => ['type' => 'text'],
- 'email' => ['type' => 'text'],
- ]);
-
$companyInformationBlueprint = Blueprint::makeFromFields([
'name' => ['type' => 'text'],
'email' => ['type' => 'text'],
]);
- BlueprintRepository::shouldReceive('find')->with('forms.test')->andReturn($formBlueprint);
+ BlueprintRepository::partialMock();
BlueprintRepository::shouldReceive('find')->with('globals.company_information')->andReturn($companyInformationBlueprint);
- $form = tap(Form::make('test'))->save();
+ $form = tap(Form::make('test')->formFields([
+ 'fields' => [
+ ['handle' => 'name', 'field' => ['type' => 'short_answer']],
+ ['handle' => 'email', 'field' => ['type' => 'email']],
+ ],
+ ]))->save();
$submission = $form->makeSubmission()->data([
'name' => 'Foo Bar',
diff --git a/tests/Forms/Fields/ConvertFieldsFromBlueprintTest.php b/tests/Forms/Fields/ConvertFieldsFromBlueprintTest.php
new file mode 100644
index 00000000000..97f6fa8cf89
--- /dev/null
+++ b/tests/Forms/Fields/ConvertFieldsFromBlueprintTest.php
@@ -0,0 +1,210 @@
+each->delete();
+ }
+
+ public function tearDown(): void
+ {
+ if ($blueprint = Blueprint::find('forms.contact_us')) {
+ Blueprint::delete($blueprint);
+ }
+
+ parent::tearDown();
+ }
+
+ #[Test]
+ #[DataProvider('fieldConversionProvider')]
+ public function it_converts_fields(array $originalBlueprintField, array $expectedFormField)
+ {
+ $this->makeBlueprint([
+ 'tabs' => [
+ 'main' => [
+ 'sections' => [
+ [
+ 'fields' => [
+ [
+ 'handle' => 'field',
+ 'field' => $originalBlueprintField,
+ ],
+ ],
+ ],
+ ],
+ ],
+ ],
+ ]);
+
+ $form = Form::make('contact_us');
+
+ $this->assertEquals([
+ 'handle' => 'field',
+ 'field' => $expectedFormField,
+ ], $form->formFields()->items()->first());
+ }
+
+ public static function fieldConversionProvider(): array
+ {
+ return [
+ 'email' => [
+ ['type' => 'text', 'display' => 'Email', 'input_type' => 'email', 'validate' => ['email']],
+ ['type' => 'email', 'display' => 'Email'],
+ ],
+ 'email, with validation rules' => [
+ ['type' => 'text', 'display' => 'Email', 'input_type' => 'email', 'validate' => ['required', 'email']],
+ ['type' => 'email', 'display' => 'Email', 'validate' => ['required']],
+ ],
+ 'email, with parameterized email rule' => [
+ ['type' => 'text', 'display' => 'Email', 'input_type' => 'email', 'validate' => ['required', 'email:rfc,strict']],
+ ['type' => 'email', 'display' => 'Email', 'validate' => ['required']],
+ ],
+ 'email, pipe-string parameterized email rule' => [
+ ['type' => 'text', 'display' => 'Email', 'input_type' => 'email', 'validate' => 'required|email:rfc'],
+ ['type' => 'email', 'display' => 'Email', 'validate' => ['required']],
+ ],
+ 'short_answer' => [
+ ['type' => 'text', 'display' => 'Name'],
+ ['type' => 'short_answer', 'display' => 'Name'],
+ ],
+ 'long_answer' => [
+ ['type' => 'textarea', 'display' => 'Message'],
+ ['type' => 'long_answer', 'display' => 'Message'],
+ ],
+ 'non-form field should be preserved' => [
+ ['type' => 'video', 'display' => 'Video', 'default' => 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'],
+ ['type' => 'video', 'display' => 'Video', 'default' => 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'],
+ ],
+ ];
+ }
+
+ #[Test]
+ public function it_preserves_fieldsets()
+ {
+ $fieldset = (new Fieldset)->setContents([
+ 'fields' => [
+ ['handle' => 'imported_field', 'field' => ['type' => 'text']],
+ ],
+ ]);
+
+ Facades\Fieldset::shouldReceive('find')
+ ->with('test')
+ ->andReturn($fieldset);
+
+ $this->makeBlueprint([
+ 'tabs' => [
+ 'main' => [
+ 'sections' => [
+ [
+ 'fields' => [
+ ['handle' => 'normal_field', 'field' => ['type' => 'text']],
+ ['import' => 'test'],
+ ['import' => 'test', 'prefix' => 'prefixed_'],
+ ['handle' => 'renamed_imported_field', 'field' => 'test.imported_field', 'config' => ['display' => 'Renamed Imported Field']],
+ ],
+ ],
+ ],
+ ],
+ ],
+ ]);
+
+ $form = Form::make('contact_us');
+
+ $this->assertEquals([
+ 'sections' => [
+ [
+ 'fields' => [
+ ['handle' => 'normal_field', 'field' => ['type' => 'short_answer']],
+ ['import' => 'test'],
+ ['import' => 'test', 'prefix' => 'prefixed_'],
+ ['handle' => 'renamed_imported_field', 'field' => 'test.imported_field', 'config' => ['display' => 'Renamed Imported Field']],
+ ],
+ ],
+ ],
+ ], $form->formFields()->contents());
+ }
+
+ #[Test]
+ public function it_flattens_tabs_into_sections()
+ {
+ $this->makeBlueprint([
+ 'tabs' => [
+ 'one' => [
+ 'sections' => [
+ [
+ 'display' => 'Section One',
+ 'fields' => [
+ ['handle' => 'foo', 'field' => ['type' => 'text']],
+ ],
+ ],
+ [
+ 'display' => 'Section Two',
+ 'fields' => [
+ ['handle' => 'bar', 'field' => ['type' => 'text']],
+ ],
+ ],
+ ],
+ ],
+ 'two' => [
+ 'sections' => [
+ [
+ 'display' => 'Section Three',
+ 'fields' => [
+ ['handle' => 'baz', 'field' => ['type' => 'text']],
+ ],
+ ],
+ ],
+ ],
+ ],
+ ]);
+
+ $form = Form::make('contact_us');
+
+ $contents = $form->formFields()->contents();
+
+ $this->assertArrayHasKey('sections', $contents);
+ $this->assertArrayNotHasKey('tabs', $contents);
+ $this->assertCount(3, $contents['sections']);
+ }
+
+ #[Test]
+ public function it_handles_legacy_format_with_no_tabs()
+ {
+ $this->makeBlueprint([
+ 'sections' => [
+ 'main' => [
+ 'display' => 'Section One',
+ 'fields' => [
+ ['handle' => 'foo', 'field' => ['type' => 'text']],
+ ],
+ ],
+ ],
+ ]);
+
+ $form = Form::make('contact_us');
+
+ $contents = $form->formFields()->contents();
+
+ $this->assertArrayHasKey('sections', $contents);
+ $this->assertArrayNotHasKey('tabs', $contents);
+ $this->assertCount(1, $contents['sections']);
+ }
+
+ private function makeBlueprint(array $contents): void
+ {
+ Blueprint::make()->setHandle('contact_us')->setNamespace('forms')->setContents($contents)->save();
+ }
+}
diff --git a/tests/Forms/Fields/EmailTest.php b/tests/Forms/Fields/EmailTest.php
new file mode 100644
index 00000000000..88b2b0bc1a2
--- /dev/null
+++ b/tests/Forms/Fields/EmailTest.php
@@ -0,0 +1,73 @@
+setField(new FormField('email', ['type' => 'email']));
+
+ $this->assertEquals([
+ 'type' => 'text',
+ 'input_type' => 'email',
+ 'placeholder' => null,
+ 'validate' => ['email'],
+ ], $fieldtype->toFieldArray());
+ }
+
+ #[Test]
+ public function it_preserves_existing_validation_rules()
+ {
+ $fieldtype = (new Email)->setField(new FormField('email', [
+ 'type' => 'email',
+ 'validate' => ['required', 'max:255'],
+ ]));
+
+ $this->assertEquals([
+ 'type' => 'text',
+ 'input_type' => 'email',
+ 'placeholder' => null,
+ 'validate' => ['required', 'max:255', 'email'],
+ ], $fieldtype->toFieldArray());
+ }
+
+ #[Test]
+ public function it_does_not_duplicate_the_email_validation_rule()
+ {
+ $fieldtype = (new Email)->setField(new FormField('email', [
+ 'type' => 'email',
+ 'validate' => ['required', 'email'],
+ ]));
+
+ $this->assertEquals([
+ 'type' => 'text',
+ 'input_type' => 'email',
+ 'placeholder' => null,
+ 'validate' => ['required', 'email'],
+ ], $fieldtype->toFieldArray());
+ }
+
+ #[Test]
+ public function it_passes_through_extra_config()
+ {
+ $fieldtype = (new Email)->setField(new FormField('email', [
+ 'type' => 'email',
+ 'append' => '@example.com',
+ ]));
+
+ $this->assertEquals([
+ 'type' => 'text',
+ 'input_type' => 'email',
+ 'placeholder' => null,
+ 'validate' => ['email'],
+ 'append' => '@example.com',
+ ], $fieldtype->toFieldArray());
+ }
+}
diff --git a/tests/Forms/Fields/FormFieldTest.php b/tests/Forms/Fields/FormFieldTest.php
new file mode 100644
index 00000000000..dd1dcd636df
--- /dev/null
+++ b/tests/Forms/Fields/FormFieldTest.php
@@ -0,0 +1,99 @@
+assertEquals(
+ 'Test Display Value',
+ (new FormField('test', ['display' => 'Test Display Value']))->display()
+ );
+
+ $this->assertEquals(
+ 'Test',
+ (new Field('test', []))->display()
+ );
+
+ $this->assertEquals(
+ 'Test Multi Word Handle And No Explicit Display',
+ (new FormField('test_multi_word_handle_and_no_explicit_display', []))->display()
+ );
+ }
+
+ #[Test]
+ public function it_gets_instructions()
+ {
+ $this->assertEquals(
+ 'The instructions',
+ (new FormField('test', ['instructions' => 'The instructions']))->instructions()
+ );
+
+ $this->assertNull((new FormField('test', []))->instructions());
+ }
+
+ #[Test]
+ public function it_gets_the_fieldtype()
+ {
+ $fieldtype = new class extends FormFieldtype
+ {
+ public function toFieldArray(): array
+ {
+ // TODO: Implement toFieldArray() method.
+ }
+ };
+
+ FormFieldtypeRepository::shouldReceive('find')
+ ->with('the_fieldtype')
+ ->andReturnUsing(fn () => clone $fieldtype);
+ $field = new FormField('test', ['type' => 'the_fieldtype', 'foo' => 'bar']);
+
+ // The fieldtype from the repository should not have the field attached.
+ $this->assertNull($fieldtype->field());
+
+ // The fieldtype from the field should be an instance of that
+ // fieldtype class, and should have the field attached.
+ $this->assertInstanceOf(get_class($fieldtype), $field->fieldtype());
+ $this->assertEquals($field->config(), $field->fieldtype()->field()->config());
+
+ // Double check that the fieldtype from the repository still doesn't somehow have the field attached.
+ $this->assertNull(FormFieldtypeRepository::find('the_fieldtype')->field());
+ }
+
+ #[Test]
+ public function it_falls_back_to_fallback_fieldtype_for_unknown_form_fieldtypes()
+ {
+ $field = new FormField('test', ['type' => 'list', 'display' => 'Shopping List']);
+
+ $this->assertInstanceOf(Fallback::class, $field->fieldtype());
+
+ $this->assertEquals([
+ 'type' => 'list',
+ 'display' => 'Shopping List',
+ ], $field->toFieldArray());
+ }
+
+ #[Test]
+ public function it_converts_to_field_array()
+ {
+ $field = new FormField('email', ['type' => 'email', 'display' => 'Email Address', 'validate' => ['required']]);
+
+ $this->assertEquals([
+ 'type' => 'text',
+ 'validate' => ['required', 'email'],
+ 'input_type' => 'email',
+ 'display' => 'Email Address',
+ 'placeholder' => null,
+ ], $field->toFieldArray());
+ }
+}
diff --git a/tests/Forms/Fields/FormFieldsTest.php b/tests/Forms/Fields/FormFieldsTest.php
new file mode 100644
index 00000000000..a8ff6db271c
--- /dev/null
+++ b/tests/Forms/Fields/FormFieldsTest.php
@@ -0,0 +1,294 @@
+ [
+ [
+ 'display' => 'Contact Info',
+ 'fields' => [
+ ['handle' => 'name', 'field' => ['type' => 'short_answer', 'display' => 'Name']],
+ ['handle' => 'email', 'field' => ['type' => 'email']],
+ ['import' => 'test'],
+ ],
+ ],
+ ],
+ ];
+
+ $formFields = new FormFields($contents);
+
+ $this->assertSame($contents, $formFields->contents());
+ }
+
+ #[Test]
+ public function it_returns_items()
+ {
+ $formFields = new FormFields([
+ 'sections' => [
+ [
+ 'display' => 'Section One',
+ 'fields' => [
+ ['handle' => 'email', 'field' => ['type' => 'email']],
+ ],
+ ],
+ [
+ 'display' => 'Section Two',
+ 'fields' => [
+ ['handle' => 'name', 'field' => ['type' => 'short_answer']],
+ ['handle' => 'message', 'field' => ['type' => 'long_answer']],
+ ],
+ ],
+ ],
+ ]);
+
+ $this->assertEquals([
+ ['handle' => 'email', 'field' => ['type' => 'email']],
+ ['handle' => 'name', 'field' => ['type' => 'short_answer']],
+ ['handle' => 'message', 'field' => ['type' => 'long_answer']],
+ ], $formFields->items()->all());
+ }
+
+ #[Test]
+ public function it_returns_items_from_fieldsets()
+ {
+ $fieldset = (new Fieldset)->setContents([
+ 'fields' => [
+ ['handle' => 'imported_field', 'field' => ['type' => 'text']],
+ ],
+ ]);
+
+ Facades\Fieldset::shouldReceive('find')
+ ->with('test')
+ ->andReturn($fieldset);
+
+ $formFields = new FormFields([
+ 'sections' => [
+ [
+ 'fields' => [
+ ['import' => 'test'],
+ ['import' => 'test', 'prefix' => 'prefixed_'],
+ ['handle' => 'renamed_imported_field', 'field' => 'test.imported_field', 'config' => ['display' => 'Renamed Imported Field']],
+ ],
+ ],
+ ],
+ ]);
+
+ $this->assertEquals([
+ ['import' => 'test'],
+ ['import' => 'test', 'prefix' => 'prefixed_'],
+ ['handle' => 'renamed_imported_field', 'field' => 'test.imported_field', 'config' => ['display' => 'Renamed Imported Field']],
+ ], $formFields->items()->all());
+ }
+
+ #[Test]
+ public function it_returns_fields()
+ {
+ $formFields = new FormFields([
+ 'sections' => [
+ [
+ 'display' => 'Section One',
+ 'fields' => [
+ ['handle' => 'email', 'field' => ['type' => 'email']],
+ ],
+ ],
+ [
+ 'display' => 'Section Two',
+ 'fields' => [
+ ['handle' => 'name', 'field' => ['type' => 'short_answer']],
+ ['handle' => 'message', 'field' => ['type' => 'long_answer']],
+ ],
+ ],
+ ],
+ ]);
+
+ $fields = $formFields->fields();
+
+ $this->assertEveryItemIsInstanceOf(FormField::class, $fields->all());
+ $this->assertEquals('email', $fields->get('email')->type());
+ $this->assertEquals('short_answer', $fields->get('name')->type());
+ $this->assertEquals('long_answer', $fields->get('message')->type());
+ }
+
+ #[Test]
+ public function it_returns_fields_from_fieldsets()
+ {
+ $fieldset = (new Fieldset)->setContents([
+ 'fields' => [
+ ['handle' => 'imported_field', 'field' => ['type' => 'text']],
+ ],
+ ]);
+
+ Facades\Fieldset::shouldReceive('find')
+ ->with('test')
+ ->andReturn($fieldset);
+
+ $formFields = new FormFields([
+ 'sections' => [
+ [
+ 'fields' => [
+ ['import' => 'test'],
+ ['import' => 'test', 'prefix' => 'prefixed_'],
+ ['handle' => 'renamed_imported_field', 'field' => 'test.imported_field', 'config' => ['display' => 'Renamed Imported Field']],
+ ],
+ ],
+ ],
+ ]);
+
+ $fields = $formFields->fields();
+
+ $this->assertEveryItemIsInstanceOf(FormField::class, $fields->all());
+ $this->assertEquals(['imported_field', 'prefixed_imported_field', 'renamed_imported_field'], $fields->keys()->all());
+ $this->assertEveryItem($fields->map->type()->values()->all(), fn (string $type) => $type === 'text');
+ }
+
+ #[Test]
+ public function it_returns_a_single_field()
+ {
+ $formFields = new FormFields([
+ 'sections' => [
+ [
+ 'fields' => [
+ ['handle' => 'name', 'field' => ['type' => 'short_answer']],
+ ['handle' => 'email', 'field' => ['type' => 'email']],
+ ],
+ ],
+ ],
+ ]);
+
+ $field = $formFields->field('name');
+
+ $this->assertInstanceOf(FormField::class, $field);
+ $this->assertEquals('name', $field->handle());
+ $this->assertEquals('short_answer', $field->type());
+ }
+
+ #[Test]
+ public function it_returns_null_for_nonexistent_field()
+ {
+ $formFields = new FormFields([
+ 'sections' => [
+ ['fields' => [['handle' => 'email', 'field' => ['type' => 'email']]]],
+ ],
+ ]);
+
+ $this->assertNull($formFields->field('nonexistent'));
+ }
+
+ #[Test]
+ public function it_converts_to_blueprint()
+ {
+ $formFields = new FormFields([
+ 'sections' => [
+ [
+ 'display' => 'Contact Info',
+ 'fields' => [
+ // Should be converted to their "normal fieldtype" equivalent.
+ ['handle' => 'name', 'field' => ['type' => 'short_answer', 'display' => 'Name']],
+ ['handle' => 'email', 'field' => ['type' => 'email', 'display' => 'Email Address']],
+ ],
+ ],
+ [
+ 'display' => 'Additional Info',
+ 'fields' => [
+ // List isn't a form fieldtype, so its config shouldn't be touched.
+ ['handle' => 'shopping_list', 'field' => ['type' => 'list', 'display' => 'Shopping List']],
+ ],
+ ],
+ ],
+ ]);
+
+ $blueprint = $formFields->toBlueprint();
+
+ $this->assertEquals([
+ 'tabs' => [
+ 'main' => [
+ 'sections' => [
+ [
+ 'display' => 'Contact Info',
+ 'fields' => [
+ [
+ 'handle' => 'name',
+ 'field' => [
+ 'type' => 'text',
+ 'display' => 'Name',
+ ],
+ ],
+ [
+ 'handle' => 'email',
+ 'field' => [
+ 'type' => 'text',
+ 'validate' => ['email'],
+ 'input_type' => 'email',
+ 'display' => 'Email Address',
+ ],
+ ],
+ ],
+ ],
+ [
+ 'display' => 'Additional Info',
+ 'fields' => [
+ ['handle' => 'shopping_list', 'field' => ['type' => 'list', 'display' => 'Shopping List']],
+ ],
+ ],
+ ],
+ ],
+ ],
+ ], $blueprint->contents());
+ }
+
+ #[Test]
+ public function it_converts_to_a_blueprint_with_fieldsets()
+ {
+ $fieldset = (new Fieldset)->setContents([
+ 'fields' => [
+ ['handle' => 'imported_field', 'field' => ['type' => 'text']],
+ ],
+ ]);
+
+ Facades\Fieldset::shouldReceive('find')
+ ->with('test')
+ ->andReturn($fieldset);
+
+ $formFields = new FormFields([
+ 'sections' => [
+ [
+ 'fields' => [
+ ['import' => 'test'],
+ ['import' => 'test', 'prefix' => 'prefixed_'],
+ ['handle' => 'renamed_imported_field', 'field' => 'test.imported_field', 'config' => ['display' => 'Renamed Imported Field']],
+ ],
+ ],
+ ],
+ ]);
+
+ $blueprint = $formFields->toBlueprint();
+
+ $this->assertEquals([
+ 'tabs' => [
+ 'main' => [
+ 'sections' => [
+ [
+ 'fields' => [
+ ['import' => 'test'],
+ ['import' => 'test', 'prefix' => 'prefixed_'],
+ ['handle' => 'renamed_imported_field', 'field' => 'test.imported_field', 'config' => ['display' => 'Renamed Imported Field']],
+ ],
+ ],
+ ],
+ ],
+ ],
+ ], $blueprint->contents());
+ }
+}
diff --git a/tests/Forms/Fields/FormFieldtypeRepositoryTest.php b/tests/Forms/Fields/FormFieldtypeRepositoryTest.php
new file mode 100644
index 00000000000..6cc3a50188e
--- /dev/null
+++ b/tests/Forms/Fields/FormFieldtypeRepositoryTest.php
@@ -0,0 +1,106 @@
+repo = new FormFieldtypeRepository();
+ }
+
+ #[Test]
+ public function it_gets_a_form_fieldtype()
+ {
+ FooFormFieldtype::register();
+
+ $found = $this->repo->find('test');
+ $this->assertInstanceOf(FooFormFieldtype::class, $found);
+
+ // Find it again and assert that it's a different instance each time.
+ $second = $this->repo->find('test');
+ $this->assertInstanceOf(FooFormFieldtype::class, $second);
+ $this->assertNotSame($found, $second);
+ }
+
+ #[Test]
+ public function it_caches_and_clones_existing_instances()
+ {
+ FooFormFieldtype::register();
+
+ $found = $this->repo->find('test');
+ $this->assertInstanceOf(FooFormFieldtype::class, $found);
+
+ // Re-register another fieldtype that uses the same handle.
+ // In reality this wouldn't happen, but we do it for this test to ensure the caching works.
+ BarFormFieldtype::register();
+
+ // Assert that it was registered. If you were to manually resolve it
+ // out of the container you'd get the overridden fieldtype.
+ $this->assertEquals(BarFormFieldtype::class, app('statamic.form-fieldtypes')->get('test'));
+
+ // Find it again through the repo to assert that it's a different instance each time.
+ $second = $this->repo->find('test');
+ $this->assertInstanceOf(FooFormFieldtype::class, $second);
+ $this->assertNotSame($found, $second);
+ }
+
+ #[Test]
+ public function it_throw_exception_when_finding_invalid_form_fieldtype()
+ {
+ $this->expectException(FormFieldtypeNotFoundException::class);
+ $this->expectExceptionMessage('Form Fieldtype [test] not found');
+ $this->repo->find('test');
+ }
+
+ #[Test]
+ public function it_makes_fields_selectable_in_forms()
+ {
+ $this->assertFalse($this->repo->hasBeenMadeSelectable('test-selectable'));
+
+ $this->repo->makeSelectable('test-selectable');
+ $this->assertTrue($this->repo->hasBeenMadeSelectable('test-selectable'));
+ $this->assertTrue($this->repo->selectableIsOverriden('test-selectable'));
+ }
+
+ #[Test]
+ public function it_makes_fields_unselectable_in_forms()
+ {
+ $this->repo->makeSelectable('test-unselectable');
+ $this->assertTrue($this->repo->hasBeenMadeSelectable('test-unselectable'));
+
+ $this->repo->makeUnselectable('test-unselectable');
+ $this->assertFalse($this->repo->hasBeenMadeSelectable('test-unselectable'));
+ $this->assertTrue($this->repo->selectableIsOverriden('test-unselectable'));
+ }
+}
+
+class FooFormFieldtype extends FormFieldtype
+{
+ public static $handle = 'test';
+
+ public function toFieldArray(): array
+ {
+ // TODO: Implement toFieldArray() method.
+ }
+}
+
+class BarFormFieldtype extends FormFieldtype
+{
+ public static $handle = 'test';
+
+ public function toFieldArray(): array
+ {
+ // TODO: Implement toFieldArray() method.
+ }
+}
diff --git a/tests/Forms/Fields/FormFieldtypeTest.php b/tests/Forms/Fields/FormFieldtypeTest.php
new file mode 100644
index 00000000000..6bd95a7b591
--- /dev/null
+++ b/tests/Forms/Fields/FormFieldtypeTest.php
@@ -0,0 +1,57 @@
+assertFalse($formFieldtype->isSelectable());
+
+ $formFieldtype::makeSelectable();
+
+ $this->assertTrue($formFieldtype->isSelectable());
+ $this->assertTrue(FormFieldtypeRepository::hasBeenMadeSelectable('test-selectable'));
+ $this->assertTrue(FormFieldtypeRepository::selectableIsOverriden('test-selectable'));
+ }
+
+ #[Test]
+ public function it_can_make_a_fieldtype_unselectable_in_forms()
+ {
+ $formFieldtype = new class extends FormFieldtype
+ {
+ public static $handle = 'test-unselectable';
+ protected $selectable = true;
+
+ public function toFieldArray(): array
+ {
+ // TODO: Implement toFieldArray() method.
+ }
+ };
+
+ $this->assertTrue($formFieldtype->isSelectable());
+
+ $formFieldtype::makeUnselectable();
+
+ $this->assertFalse($formFieldtype->isSelectable());
+ $this->assertFalse(FormFieldtypeRepository::hasBeenMadeSelectable('test-unselectable'));
+ $this->assertTrue(FormFieldtypeRepository::selectableIsOverriden('test-unselectable'));
+ }
+}
diff --git a/tests/Forms/Fields/LongAnswerTest.php b/tests/Forms/Fields/LongAnswerTest.php
new file mode 100644
index 00000000000..9ad91e5cf26
--- /dev/null
+++ b/tests/Forms/Fields/LongAnswerTest.php
@@ -0,0 +1,45 @@
+setField(new FormField('message', [
+ 'type' => 'long_answer',
+ 'placeholder' => 'Your message',
+ 'character_limit' => 30,
+ ]));
+
+ $this->assertEquals([
+ 'type' => 'textarea',
+ 'placeholder' => 'Your message',
+ 'character_limit' => 30,
+ ], $fieldtype->toFieldArray());
+ }
+
+ #[Test]
+ public function it_passes_through_extra_config()
+ {
+ $fieldtype = (new LongAnswer)->setField(new FormField('message', [
+ 'type' => 'long_answer',
+ 'placeholder' => 'Your message',
+ 'character_limit' => 30,
+ 'default' => 'David Hasselhoff',
+ ]));
+
+ $this->assertEquals([
+ 'type' => 'textarea',
+ 'placeholder' => 'Your message',
+ 'character_limit' => 30,
+ 'default' => 'David Hasselhoff',
+ ], $fieldtype->toFieldArray());
+ }
+}
diff --git a/tests/Forms/Fields/ShortAnswerTest.php b/tests/Forms/Fields/ShortAnswerTest.php
new file mode 100644
index 00000000000..be3a2d72ac9
--- /dev/null
+++ b/tests/Forms/Fields/ShortAnswerTest.php
@@ -0,0 +1,45 @@
+setField(new FormField('name', [
+ 'type' => 'short_answer',
+ 'placeholder' => 'Your name',
+ 'character_limit' => 30,
+ ]));
+
+ $this->assertEquals([
+ 'type' => 'text',
+ 'placeholder' => 'Your name',
+ 'character_limit' => 30,
+ ], $fieldtype->toFieldArray());
+ }
+
+ #[Test]
+ public function it_passes_through_extra_config()
+ {
+ $fieldtype = (new ShortAnswer)->setField(new FormField('name', [
+ 'type' => 'short_answer',
+ 'placeholder' => 'Your name',
+ 'character_limit' => 30,
+ 'default' => 'David Hasselhoff',
+ ]));
+
+ $this->assertEquals([
+ 'type' => 'text',
+ 'placeholder' => 'Your name',
+ 'character_limit' => 30,
+ 'default' => 'David Hasselhoff',
+ ], $fieldtype->toFieldArray());
+ }
+}
diff --git a/tests/Forms/FormTest.php b/tests/Forms/FormTest.php
index 8401c3adb63..c0d24ff649e 100644
--- a/tests/Forms/FormTest.php
+++ b/tests/Forms/FormTest.php
@@ -11,8 +11,11 @@
use Statamic\Events\FormDeleting;
use Statamic\Events\FormSaved;
use Statamic\Events\FormSaving;
+use Statamic\Facades\Blueprint;
+use Statamic\Facades\File;
use Statamic\Facades\Form;
-use Statamic\Fields\Blueprint;
+use Statamic\Facades\YAML;
+use Statamic\Forms\Fields\FormFields;
use Tests\TestCase;
class FormTest extends TestCase
@@ -29,8 +32,6 @@ public function it_saves_a_form()
{
Event::fake();
- $blueprint = (new Blueprint)->setHandle('post')->save();
-
$form = Form::make('contact_us')
->title('Contact Us')
->honeypot('winnie')
@@ -71,8 +72,6 @@ public function it_dispatches_form_created_only_once()
{
Event::fake();
- $blueprint = (new Blueprint)->setHandle('post')->save();
-
$form = Form::make('contact_us')
->title('Contact Us')
->honeypot('winnie');
@@ -88,13 +87,31 @@ public function it_dispatches_form_created_only_once()
Event::assertDispatched(FormCreated::class, 1);
}
+ #[Test]
+ public function it_deletes_blueprint_after_saving()
+ {
+ Blueprint::make()->setHandle('contact_us')->setNamespace('forms')->save();
+
+ $this->assertNotNull(Blueprint::find('forms.contact_us'));
+
+ $form = Form::make('contact_us')
+ ->title('Contact Us')
+ ->honeypot('winnie')
+ ->data([
+ 'foo' => 'bar',
+ 'roo' => 'rar',
+ ]);
+
+ $form->save();
+
+ $this->assertNull(Blueprint::find('forms.contact_us'));
+ }
+
#[Test]
public function it_saves_quietly()
{
Event::fake();
- $blueprint = (new Blueprint)->setHandle('post')->save();
-
$form = Form::make('contact_us')
->title('Contact Us')
->honeypot('winnie')
@@ -115,8 +132,6 @@ public function if_creating_event_returns_false_the_form_doesnt_save()
return false;
});
- $blueprint = (new Blueprint)->setHandle('post')->save();
-
$form = Form::make('contact_us')
->title('Contact Us')
->honeypot('winnie')
@@ -134,8 +149,6 @@ public function if_saving_event_returns_false_the_form_doesnt_save()
return false;
});
- $blueprint = (new Blueprint)->setHandle('post')->save();
-
$form = Form::make('contact_us')
->title('Contact Us')
->honeypot('winnie')
@@ -268,4 +281,78 @@ public function it_clones_internal_collections()
$this->assertEquals('A', $form->getSupplement('bar'));
$this->assertEquals('B', $clone->getSupplement('bar'));
}
+
+ #[Test]
+ public function it_gets_and_sets_form_fields()
+ {
+ $fields = [
+ 'sections' => [
+ [
+ 'fields' => [
+ ['handle' => 'email', 'field' => ['type' => 'email']],
+ ],
+ ],
+ ],
+ ];
+
+ $form = Form::make('contact_us')->formFields($fields);
+
+ $formFields = $form->formFields();
+
+ $this->assertInstanceOf(FormFields::class, $formFields);
+ $this->assertEquals($fields, $formFields->contents());
+ }
+
+ #[Test]
+ public function it_saves_form_fields_to_yaml()
+ {
+ $fields = [
+ 'sections' => [
+ [
+ 'fields' => [
+ ['handle' => 'name', 'field' => ['type' => 'short_answer']],
+ ['handle' => 'email', 'field' => ['type' => 'email']],
+ ],
+ ],
+ ],
+ ];
+
+ $form = tap(Form::make('contact_us')
+ ->title('Contact Us')
+ ->formFields($fields))
+ ->save();
+
+ $saved = YAML::parse(File::get($form->path()));
+
+ $this->assertEquals($fields, $saved['fields']);
+ }
+
+ #[Test]
+ public function it_hydrates_form_fields_from_yaml()
+ {
+ $fields = [
+ 'sections' => [
+ [
+ 'fields' => [
+ ['handle' => 'name', 'field' => ['type' => 'short_answer']],
+ ['handle' => 'email', 'field' => ['type' => 'email']],
+ ],
+ ],
+ ],
+ ];
+
+ Form::make('contact_us')
+ ->title('Contact Us')
+ ->formFields($fields)
+ ->save();
+
+ $form = Form::find('contact_us');
+
+ $formFields = $form->formFields();
+
+ $this->assertInstanceOf(FormFields::class, $formFields);
+ $this->assertCount(2, $formFields->items());
+ $this->assertEquals('email', $formFields->field('email')->handle());
+ $this->assertEquals('name', $formFields->field('name')->handle());
+ }
}
diff --git a/tests/Forms/SendEmailsTest.php b/tests/Forms/SendEmailsTest.php
index 0360d85f685..f144b5afd1d 100644
--- a/tests/Forms/SendEmailsTest.php
+++ b/tests/Forms/SendEmailsTest.php
@@ -97,10 +97,12 @@ public function it_dispatches_delete_attachments_job_after_dispatching_email_job
'from' => 'first@sender.com',
'to' => 'first@recipient.com',
'foo' => 'bar',
+ ])->formFields([
+ 'fields' => [
+ ['handle' => 'attachments', 'field' => ['type' => 'files']],
+ ],
]))->save();
- $form->blueprint()->ensureField('attachments', ['type' => 'files'])->save();
-
(new SendEmails(
$submission = $form->makeSubmission(),
$site = Site::default(),
diff --git a/tests/Forms/SubmissionTest.php b/tests/Forms/SubmissionTest.php
index a2700882bc4..d19fde2c5d3 100644
--- a/tests/Forms/SubmissionTest.php
+++ b/tests/Forms/SubmissionTest.php
@@ -12,7 +12,6 @@
use Statamic\Events\SubmissionDeleted;
use Statamic\Events\SubmissionSaved;
use Statamic\Events\SubmissionSaving;
-use Statamic\Facades\Blueprint;
use Statamic\Facades\Form;
use Tests\PreventSavingStacheItemsToDisk;
use Tests\TestCase;
@@ -76,10 +75,18 @@ public static function utcProvider()
#[Test]
public function it_sets_and_gets_data()
{
- $submission = Form::make('test')->makeSubmission();
+ $form = Form::make('test')
+ ->formFields([
+ 'sections' => [
+ [
+ 'fields' => [
+ ['handle' => 'foo', 'field' => ['type' => 'short_answer']],
+ ],
+ ],
+ ],
+ ]);
- $blueprint = Blueprint::makeFromFields(['foo' => ['type' => 'text']]);
- Blueprint::shouldReceive('find')->with('forms.test')->andReturn($blueprint);
+ $submission = $form->makeSubmission();
$this->assertInstanceOf(Collection::class, $data = $submission->data());
$this->assertEquals([], $data->all());
diff --git a/tests/StaticCaching/InvalidateTest.php b/tests/StaticCaching/InvalidateTest.php
index d720571c2c2..d772097142b 100644
--- a/tests/StaticCaching/InvalidateTest.php
+++ b/tests/StaticCaching/InvalidateTest.php
@@ -5,10 +5,8 @@
use Mockery;
use PHPUnit\Framework\Attributes\Test;
use Statamic\Contracts\Entries\Entry;
-use Statamic\Events\BlueprintSaved;
use Statamic\Events\CollectionTreeEntriesMovedOrRemoved;
use Statamic\Facades\Entry as EntryFacade;
-use Statamic\Facades\Form;
use Statamic\StaticCaching\Cacher;
use Statamic\StaticCaching\Invalidate;
use Statamic\StaticCaching\Invalidator;
@@ -19,22 +17,6 @@ class InvalidateTest extends TestCase
{
use PreventSavingStacheItemsToDisk;
- #[Test]
- public function it_invalidates_a_form_when_its_blueprint_is_saved()
- {
- $form = tap(Form::make('contact'))->save();
-
- $event = new BlueprintSaved($form->blueprint());
-
- $invalidator = Mockery::mock(Invalidator::class)->shouldReceive('invalidate')->once()->withArgs(function ($form) {
- return $form->handle() === 'contact';
- })->getMock();
-
- $invalidate = new Invalidate($invalidator, Mockery::mock(Cacher::class));
-
- $invalidate->invalidateByBlueprint($event);
- }
-
private function mockEntry(string $url): Entry
{
$entry = Mockery::mock(Entry::class);
diff --git a/tests/Tags/Form/FormTestCase.php b/tests/Tags/Form/FormTestCase.php
index 17a3f89e2b2..203055d2946 100644
--- a/tests/Tags/Form/FormTestCase.php
+++ b/tests/Tags/Form/FormTestCase.php
@@ -3,7 +3,6 @@
namespace Tests\Tags\Form;
use Illuminate\Support\Facades\Blade;
-use Statamic\Facades\Blueprint;
use Statamic\Facades\Form;
use Statamic\Facades\Parse;
use Statamic\Support\Arr;
@@ -78,20 +77,20 @@ protected function blade($string, $context = [])
return Blade::render($string, $context);
}
- protected function createForm($blueprintContents = null, $handle = null)
+ protected function createForm($fieldContents = null, $handle = null)
{
- $defaultBlueprintContents = [
- 'fields' => $this->defaultFields,
+ $defaultFieldsContents = [
+ 'sections' => [
+ ['fields' => $this->defaultFields],
+ ],
];
- $blueprint = Blueprint::make()->setContents($blueprintContents ?? $defaultBlueprintContents);
-
$handle = $handle ?? 'contact';
- Blueprint::shouldReceive('find')->with("forms.{$handle}")->andReturn($blueprint);
- Blueprint::makePartial();
-
- $form = Form::make()->handle($handle)->honeypot('winnie');
+ $form = Form::make()
+ ->handle($handle)
+ ->honeypot('winnie')
+ ->formFields($fieldContents ?? $defaultFieldsContents);
Form::shouldReceive('find')->with($handle)->andReturn($form);
Form::makePartial();