diff --git a/lang/en/messages.php b/lang/en/messages.php
index d57a3e72ea8..eafaaca2160 100644
--- a/lang/en/messages.php
+++ b/lang/en/messages.php
@@ -140,6 +140,7 @@
'form_configure_mailer_instructions' => 'Choose the mailer for sending this email. Leave blank to fall back to the default mailer.',
'form_configure_generate_fake_submissions_instructions' => 'Allow generating fake submissions and workflow testing from the submissions screen.',
'form_configure_store_instructions' => 'Disable to stop storing submissions. Events and email notifications will still be sent.',
+ 'form_configure_unique_instances_instructions' => 'Treat each entry using this form as its own instance. Submissions will be attached to the entry they were submitted from.',
'form_configure_title_instructions' => 'Use a call to action, such as \'Contact Us\'.',
'form_configure_close_date_instructions' => 'The form will stop accepting submissions after this date. Leave blank to never close.',
'form_configure_submission_limit_instructions' => 'The maximum number of submissions to accept. Leave blank for no limit.',
@@ -148,6 +149,8 @@
'form_configure_require_login_instructions' => 'Only allow logged in users to submit this form.',
'form_configure_require_login_message_instructions' => 'Shown when a logged out visitor tries to submit the form. Leave blank to use the default message.',
'form_closed_message' => 'This form is no longer accepting submissions.',
+ 'form_fieldtype_configure_instructions' => 'Edit a field to override the form\'s setting for this entry. Synced fields keep following the form.',
+ 'form_fieldtype_sync_confirmation' => 'Are you sure? This override will be removed and the field will follow the form\'s setting again.',
'form_require_login_message' => 'You must be logged in to submit this form.',
'form_create_description' => 'Get started by creating your first form.',
'form_builder' => 'Form Builder',
diff --git a/resources/js/bootstrap/fieldtypes.js b/resources/js/bootstrap/fieldtypes.js
index 6f1c83b7936..9de57d56a24 100644
--- a/resources/js/bootstrap/fieldtypes.js
+++ b/resources/js/bootstrap/fieldtypes.js
@@ -32,6 +32,9 @@ import Grid from '../components/fieldtypes/grid/Grid.vue';
import GridIndex from '../components/fieldtypes/grid/GridIndex.vue';
import GroupFieldtype from '../components/fieldtypes/GroupFieldtype.vue';
import FormBannerFieldtype from '../components/fieldtypes/FormBannerFieldtype.vue';
+import FormFieldtype from '../components/fieldtypes/FormFieldtype.vue';
+import FormIndexFieldtype from '../components/fieldtypes/FormIndexFieldtype.vue';
+import FormRelatedItem from '../components/fieldtypes/FormRelatedItem.vue';
import FormHeadingFieldtype from '../components/fieldtypes/FormHeadingFieldtype.vue';
import FormParagraphFieldtype from '@/components/fieldtypes/FormParagraphFieldtype.vue';
import FormUploadFieldtype from '@/components/fieldtypes/FormUploadFieldtype.vue';
@@ -114,6 +117,9 @@ export default function registerFieldtypes(app) {
app.component('grid-fieldtype', Grid);
app.component('grid-fieldtype-index', GridIndex);
app.component('group-fieldtype', GroupFieldtype);
+ app.component('form-fieldtype', FormFieldtype);
+ app.component('form-fieldtype-index', FormIndexFieldtype);
+ app.component('form-related-item', FormRelatedItem);
app.component('form_banner-fieldtype', FormBannerFieldtype);
app.component('form_heading-fieldtype', FormHeadingFieldtype);
app.component('form_paragraph-fieldtype', FormParagraphFieldtype);
diff --git a/resources/js/components/fieldtypes/FormFieldtype.vue b/resources/js/components/fieldtypes/FormFieldtype.vue
new file mode 100644
index 00000000000..6852fff8b8a
--- /dev/null
+++ b/resources/js/components/fieldtypes/FormFieldtype.vue
@@ -0,0 +1,185 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/resources/js/components/fieldtypes/FormIndexFieldtype.vue b/resources/js/components/fieldtypes/FormIndexFieldtype.vue
new file mode 100644
index 00000000000..d2a27930234
--- /dev/null
+++ b/resources/js/components/fieldtypes/FormIndexFieldtype.vue
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/resources/js/components/fieldtypes/FormRelatedItem.vue b/resources/js/components/fieldtypes/FormRelatedItem.vue
new file mode 100644
index 00000000000..a7767e643e6
--- /dev/null
+++ b/resources/js/components/fieldtypes/FormRelatedItem.vue
@@ -0,0 +1,81 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/resources/js/components/forms/InlineSubmissionForm.vue b/resources/js/components/forms/InlineSubmissionForm.vue
new file mode 100644
index 00000000000..a2c32ead806
--- /dev/null
+++ b/resources/js/components/forms/InlineSubmissionForm.vue
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/resources/js/components/forms/SubmissionListing.vue b/resources/js/components/forms/SubmissionListing.vue
index db3004806d5..f1423c98665 100644
--- a/resources/js/components/forms/SubmissionListing.vue
+++ b/resources/js/components/forms/SubmissionListing.vue
@@ -9,30 +9,41 @@
:sort-direction="sortDirection"
:preferences-prefix="preferencesPrefix"
:filters="filters"
- push-query
+ :allow-presets="false"
+ :push-query="!viewInStack"
>
-
+
-
+
-
+
+
+
+
+
+
+ {{ formattedDate }}
+
+
+
+
+
+
+
diff --git a/resources/js/pages/forms/Submission.vue b/resources/js/pages/forms/Submission.vue
index 702e1359738..a356b904164 100644
--- a/resources/js/pages/forms/Submission.vue
+++ b/resources/js/pages/forms/Submission.vue
@@ -1,14 +1,11 @@
-
-
-
- {{ formattedDate }}
-
+
-
+
+
+
+
-
+
diff --git a/resources/js/tests/components/fieldtypes/FormFieldtype.test.js b/resources/js/tests/components/fieldtypes/FormFieldtype.test.js
new file mode 100644
index 00000000000..1e4b878ccb1
--- /dev/null
+++ b/resources/js/tests/components/fieldtypes/FormFieldtype.test.js
@@ -0,0 +1,108 @@
+import { mount } from '@vue/test-utils';
+import { expect, test } from 'vitest';
+import FormFieldtype from '@/components/fieldtypes/FormFieldtype.vue';
+
+const mountFieldtype = (props = {}) =>
+ mount(FormFieldtype, {
+ props: {
+ handle: 'rsvp_form',
+ config: { type: 'form', max_items: 1 },
+ meta: { configurable: true, data: [] },
+ value: { form: ['contact'], config: { submission_limit: 1 } },
+ ...props,
+ },
+ global: {
+ stubs: {
+ RelationshipFieldtype: true,
+ SubmissionListing: true,
+ InlineSubmissionForm: true,
+ PublishContainer: true,
+ PublishTabs: true,
+ Stack: true,
+ Button: true,
+ },
+ },
+ });
+
+test('config is kept when the form is unchanged', () => {
+ const wrapper = mountFieldtype();
+
+ wrapper.vm.formUpdated(['contact']);
+
+ expect(wrapper.emitted('update:value')[0]).toEqual([{ form: ['contact'], config: { submission_limit: 1 } }]);
+});
+
+test('config is cleared when the form changes', () => {
+ const wrapper = mountFieldtype();
+
+ wrapper.vm.formUpdated(['other']);
+
+ expect(wrapper.emitted('update:value')[0]).toEqual([{ form: ['other'], config: {} }]);
+});
+
+test('plain handles are used when not configurable', () => {
+ const wrapper = mountFieldtype({
+ meta: { configurable: false, data: [] },
+ value: ['contact'],
+ });
+
+ wrapper.vm.formUpdated(['other']);
+
+ expect(wrapper.emitted('update:value')[0]).toEqual([['other']]);
+});
+
+test('only modified fields are persisted as overrides', async () => {
+ const wrapper = mountFieldtype({
+ meta: {
+ configurable: true,
+ data: [],
+ configuration: {
+ form: 'contact',
+ blueprint: {},
+ values: {},
+ meta: {},
+ originValues: { submission_limit: 100, closed_message: null },
+ originMeta: {},
+ },
+ },
+ });
+
+ wrapper.vm.configuringForm = true;
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.configOverrides).toEqual({ submission_limit: 1, closed_message: null });
+ expect(wrapper.vm.modifiedOverrides).toEqual(['submission_limit']);
+
+ wrapper.vm.configOverrides.closed_message = 'Full.';
+ wrapper.vm.modifiedOverrides.push('closed_message');
+ wrapper.vm.applyConfigure();
+
+ expect(wrapper.emitted('update:value')[0]).toEqual([
+ { form: ['contact'], config: { submission_limit: 1, closed_message: 'Full.' } },
+ ]);
+});
+
+test('synced fields are dropped from the overrides', async () => {
+ const wrapper = mountFieldtype({
+ meta: {
+ configurable: true,
+ data: [],
+ configuration: {
+ form: 'contact',
+ blueprint: {},
+ values: {},
+ meta: {},
+ originValues: { submission_limit: 100 },
+ originMeta: {},
+ },
+ },
+ });
+
+ wrapper.vm.configuringForm = true;
+ await wrapper.vm.$nextTick();
+
+ wrapper.vm.modifiedOverrides = [];
+ wrapper.vm.applyConfigure();
+
+ expect(wrapper.emitted('update:value')[0]).toEqual([{ form: ['contact'], config: {} }]);
+});
diff --git a/src/Exceptions/FormRestrictedException.php b/src/Exceptions/FormRestrictedException.php
index f7d273bb008..6d4812d9e98 100644
--- a/src/Exceptions/FormRestrictedException.php
+++ b/src/Exceptions/FormRestrictedException.php
@@ -3,16 +3,17 @@
namespace Statamic\Exceptions;
use Statamic\Contracts\Forms\Form;
+use Statamic\Forms\Instance;
class FormRestrictedException extends \Exception
{
- public function __construct(protected Form $form)
+ public function __construct(protected Instance $instance)
{
- parent::__construct($form->restrictionMessage());
+ parent::__construct($instance->restrictionMessage());
}
public function form(): Form
{
- return $this->form;
+ return $this->instance->form();
}
}
diff --git a/src/Forms/ConfigFields.php b/src/Forms/ConfigFields.php
new file mode 100644
index 00000000000..aefdaeae817
--- /dev/null
+++ b/src/Forms/ConfigFields.php
@@ -0,0 +1,264 @@
+handle()) as $handle => $config) {
+ $merged = false;
+
+ foreach ($fields as $sectionHandle => $section) {
+ if ($section['display'] == __($config['display'])) {
+ $fields[$sectionHandle]['fields'] += $config['fields'];
+ $merged = true;
+ }
+ }
+
+ if (! $merged) {
+ $fields[$handle] = $config;
+ }
+ }
+
+ return Facades\Blueprint::make()->setContents([
+ 'tabs' => [
+ 'main' => [
+ 'sections' => collect($fields)->map(function ($section) {
+ return [
+ 'display' => $section['display'],
+ 'fields' => collect($section['fields'])->map(function ($field, $handle) {
+ return [
+ 'handle' => $handle,
+ 'field' => $field,
+ ];
+ })->values()->all(),
+ ];
+ })->values()->all(),
+ ],
+ ],
+ ]);
+ }
+
+ public static function fields(): array
+ {
+ return [
+ 'name' => [
+ 'display' => __('Name'),
+ 'fields' => [
+ 'title' => [
+ 'type' => 'text',
+ 'validate' => 'required',
+ 'instructions' => __('statamic::messages.form_configure_title_instructions'),
+ ],
+ ],
+ ],
+ 'fields' => [
+ 'display' => __('Fields'),
+ 'fields' => [
+ 'honeypot' => [
+ 'type' => 'text',
+ 'instructions' => __('statamic::messages.form_configure_honeypot_instructions'),
+ ],
+ ],
+ ],
+ 'submissions' => [
+ 'display' => __('Submissions'),
+ 'fields' => [
+ 'store' => [
+ 'display' => __('Store Submissions'),
+ 'type' => 'toggle',
+ 'instructions' => __('statamic::messages.form_configure_store_instructions'),
+ ],
+ ...(Statamic::formsProInstalled() ? [
+ 'unique_instances' => [
+ 'display' => __('Unique Instances'),
+ 'type' => 'toggle',
+ 'instructions' => __('statamic::messages.form_configure_unique_instances_instructions'),
+ ],
+ ] : []),
+ 'generate_fake_submissions' => [
+ 'display' => __('Enable Fake Submission Generator'),
+ 'type' => 'toggle',
+ 'default' => true,
+ 'instructions' => __('statamic::messages.form_configure_generate_fake_submissions_instructions'),
+ ],
+ ],
+ ],
+ 'access' => [
+ 'display' => __('Access'),
+ 'fields' => [
+ 'close_date' => [
+ 'display' => __('Close Date'),
+ 'type' => 'date',
+ 'time_enabled' => true,
+ 'instructions' => __('statamic::messages.form_configure_close_date_instructions'),
+ ],
+ 'submission_limit' => [
+ 'display' => __('Submission Limit'),
+ 'type' => 'integer',
+ 'instructions' => __('statamic::messages.form_configure_submission_limit_instructions'),
+ ],
+ 'submission_limit_period' => [
+ 'display' => __('Submission Limit Period'),
+ 'type' => 'button_group',
+ 'default' => 'total',
+ 'options' => [
+ 'total' => __('Total'),
+ 'day' => __('Per Day'),
+ 'week' => __('Per Week'),
+ 'month' => __('Per Month'),
+ ],
+ 'if' => [
+ 'submission_limit' => 'not empty',
+ ],
+ 'instructions' => __('statamic::messages.form_configure_submission_limit_period_instructions'),
+ ],
+ 'closed_message' => [
+ 'display' => __('Closed Message'),
+ 'type' => 'textarea',
+ 'if_any' => [
+ 'close_date' => 'not empty',
+ 'submission_limit' => 'not empty',
+ ],
+ 'placeholder' => __('statamic::messages.form_closed_message'),
+ 'instructions' => __('statamic::messages.form_configure_closed_message_instructions'),
+ ],
+ 'require_login' => [
+ 'display' => __('Require Login'),
+ 'type' => 'toggle',
+ 'instructions' => __('statamic::messages.form_configure_require_login_instructions'),
+ ],
+ 'require_login_message' => [
+ 'display' => __('Require Login Message'),
+ 'type' => 'textarea',
+ 'if' => [
+ 'require_login' => 'equals true',
+ ],
+ 'placeholder' => __('statamic::messages.form_require_login_message'),
+ 'instructions' => __('statamic::messages.form_configure_require_login_message_instructions'),
+ ],
+ ],
+ ],
+ 'email' => [
+ 'display' => __('Email'),
+ 'fields' => [
+ 'email' => [
+ 'type' => 'grid',
+ 'mode' => 'stacked',
+ 'full_width_setting' => true,
+ 'add_row' => __('Add Email'),
+ 'instructions' => __('statamic::messages.form_configure_email_instructions'),
+ 'fields' => [
+ [
+ 'handle' => 'to',
+ 'field' => [
+ 'type' => 'text',
+ 'display' => __('Recipient(s)'),
+ 'validate' => [
+ 'required',
+ ],
+ 'instructions' => __('statamic::messages.form_configure_email_to_instructions'),
+ ],
+ ],
+ [
+ 'handle' => 'cc',
+ 'field' => [
+ 'type' => 'text',
+ 'display' => __('CC Recipient(s)'),
+ 'instructions' => __('statamic::messages.form_configure_email_cc_instructions'),
+ ],
+ ],
+ [
+ 'handle' => 'bcc',
+ 'field' => [
+ 'type' => 'text',
+ 'display' => __('BCC Recipient(s)'),
+ 'instructions' => __('statamic::messages.form_configure_email_bcc_instructions'),
+ ],
+ ],
+ [
+ 'handle' => 'from',
+ 'field' => [
+ 'type' => 'text',
+ 'display' => __('Sender'),
+ 'instructions' => __('statamic::messages.form_configure_email_from_instructions').' ('.config('mail.from.address').').',
+ ],
+ ],
+ [
+ 'handle' => 'reply_to',
+ 'field' => [
+ 'type' => 'text',
+ 'display' => __('Reply To'),
+ 'instructions' => __('statamic::messages.form_configure_email_reply_to_instructions'),
+ ],
+ ],
+ [
+ 'handle' => 'subject',
+ 'field' => [
+ 'type' => 'text',
+ 'display' => __('Subject'),
+ 'instructions' => __('statamic::messages.form_configure_email_subject_instructions'),
+ ],
+ ],
+ [
+ 'handle' => 'html',
+ 'field' => [
+ 'type' => 'template',
+ 'display' => __('HTML view'),
+ 'instructions' => __('statamic::messages.form_configure_email_html_instructions'),
+ 'folder' => config('statamic.forms.email_view_folder'),
+ 'clearable' => true,
+ ],
+ ],
+ [
+ 'handle' => 'text',
+ 'field' => [
+ 'type' => 'template',
+ 'display' => __('Text view'),
+ 'instructions' => __('statamic::messages.form_configure_email_text_instructions'),
+ 'folder' => config('statamic.forms.email_view_folder'),
+ 'clearable' => true,
+ ],
+ ],
+ [
+ 'handle' => 'markdown',
+ 'field' => [
+ 'type' => 'toggle',
+ 'display' => __('Markdown'),
+ 'instructions' => __('statamic::messages.form_configure_email_markdown_instructions'),
+ ],
+ ],
+ [
+ 'handle' => 'attachments',
+ 'field' => [
+ 'type' => 'toggle',
+ 'display' => __('Attachments'),
+ 'instructions' => __('statamic::messages.form_configure_email_attachments_instructions'),
+ ],
+ ],
+ [
+ 'handle' => 'mailer',
+ 'field' => [
+ 'type' => 'select',
+ 'instructions' => __('statamic::messages.form_configure_mailer_instructions'),
+ 'options' => array_keys(config('mail.mailers')),
+ 'clearable' => true,
+ ],
+ ],
+ ],
+ ],
+ ],
+ ],
+ ];
+ }
+}
diff --git a/src/Forms/Fieldtype.php b/src/Forms/Fieldtype.php
index dd12b54e860..e42b6ddec61 100644
--- a/src/Forms/Fieldtype.php
+++ b/src/Forms/Fieldtype.php
@@ -2,22 +2,31 @@
namespace Statamic\Forms;
+use Statamic\Contracts\Entries\Entry as EntryContract;
+use Statamic\Contracts\Forms\Form as FormContract;
use Statamic\CP\Column;
use Statamic\Data\DataCollection;
use Statamic\Facades;
use Statamic\Facades\GraphQL;
use Statamic\Facades\Scope;
use Statamic\Facades\User;
+use Statamic\Fields\Blueprint;
+use Statamic\Fields\Fields;
use Statamic\Fieldtypes\Relationship;
use Statamic\GraphQL\Types\FormType;
use Statamic\Query\ItemQueryBuilder;
use Statamic\Query\Scopes\Filter;
+use Statamic\Statamic;
+use Statamic\Support\Arr;
use function Statamic\trans as __;
class Fieldtype extends Relationship
{
protected static $handle = 'form';
+ protected $component = 'form';
+ protected $indexComponent = 'form';
+ protected $itemComponent = 'form-related-item';
protected $statusIcons = false;
protected $canCreate = false;
protected $canEdit = false;
@@ -77,9 +86,95 @@ protected function configFieldItems(): array
];
}
- public function fieldsetContents()
+ public function preProcess($data)
{
- return [];
+ if (! $this->configCanBeOverridden()) {
+ return $this->toFormHandles($data);
+ }
+
+ $config = Arr::get($data, 'config', []);
+
+ return [
+ 'form' => $this->toFormHandles($data),
+ 'config' => $this->overrideFields()->addValues($config)->preProcess()->values()->only(array_keys($config))->all(),
+ ];
+ }
+
+ public function preProcessConfig($data)
+ {
+ $handles = $this->toFormHandles($data);
+
+ return $this->config('max_items') === 1 ? Arr::first($handles) : $handles;
+ }
+
+ public function process($data)
+ {
+ if (! $this->configCanBeOverridden()) {
+ return parent::process($this->toFormHandles($data));
+ }
+
+ if (! $form = Arr::first($this->toFormHandles($data))) {
+ return null;
+ }
+
+ $config = Arr::get($data, 'config', []);
+
+ // Only desynced fields are submitted, so a key's presence means it's an
+ // override. Falsy values count; an override is only "empty" when blank.
+ $config = $this->overrideFields()
+ ->addValues($config)
+ ->process()
+ ->values()
+ ->only(array_keys($config))
+ ->reject(fn ($value) => $value === null || $value === '')
+ ->all();
+
+ return $config ? ['form' => $form, 'config' => $config] : $form;
+ }
+
+ public function preProcessValidatable($value)
+ {
+ return $this->toFormHandles($value);
+ }
+
+ private function configCanBeOverridden(): bool
+ {
+ return Statamic::formsProInstalled() && $this->config('max_items') === 1;
+ }
+
+ private function toFormHandles($value): array
+ {
+ if (is_array($value) && Arr::isAssoc($value)) {
+ $value = $value['form'] ?? null;
+ }
+
+ return array_values(array_filter(Arr::wrap($value)));
+ }
+
+ private function overrideFields(): Fields
+ {
+ return $this->overrideBlueprint()->fields();
+ }
+
+ private function overrideBlueprint(): Blueprint
+ {
+ $section = ConfigFields::fields()['access'];
+
+ $section['fields'] = collect($section['fields'])->map(function (array $field): array {
+ $field['localizable'] = true;
+
+ return $field;
+ });
+
+ return Facades\Blueprint::make()->setContents([
+ 'tabs' => ['main' => ['sections' => [[
+ 'display' => $section['display'],
+ 'fields' => collect($section['fields'])
+ ->map(fn ($field, $handle) => ['handle' => $handle, 'field' => $field])
+ ->values()
+ ->all(),
+ ]]]],
+ ]);
}
protected function getColumns()
@@ -145,6 +240,138 @@ public function getIndexItems($request)
return $query->get()->map($formFields);
}
+ public function preload()
+ {
+ $data = parent::preload();
+
+ $data['configurable'] = $this->configCanBeOverridden();
+
+ if ($submissions = $this->submissionsPreloadData()) {
+ $data['submissions'] = $submissions;
+ }
+
+ if ($configuration = $this->configurationPreloadData()) {
+ $data['configuration'] = $configuration;
+ }
+
+ return $data;
+ }
+
+ private function submissionsPreloadData(): ?array
+ {
+ if (! $form = $this->viewableUniqueInstancesForm(Arr::first($this->toFormHandles($this->field->value())))) {
+ return null;
+ }
+
+ if (! $entry = $this->parentEntry()) {
+ return null;
+ }
+
+ return [
+ 'form' => $form->handle(),
+ 'filters' => Scope::filters('form-submissions', ['form' => $form->handle(), 'entry' => $entry->id()]),
+ 'actionUrl' => cp_route('forms.submissions.actions.run', $form->handle()),
+ ];
+ }
+
+ private function configurationPreloadData(): ?array
+ {
+ if (! $this->configCanBeOverridden()) {
+ return null;
+ }
+
+ if (! $form = $this->uniqueInstancesForm(Arr::first($this->toFormHandles($this->field->value())))) {
+ return null;
+ }
+
+ $config = Arr::get($this->field->value(), 'config', []);
+
+ $origin = $this->overrideFields()->addValues($form->data()->all())->preProcess();
+ $fields = $this->overrideFields()->addValues(array_merge($form->data()->all(), $config))->preProcess();
+
+ return [
+ 'form' => $form->handle(),
+ 'blueprint' => $this->overrideBlueprint()->toPublishArray(),
+ 'meta' => $fields->meta(),
+ 'originValues' => $origin->values()->all(),
+ 'originMeta' => $origin->meta(),
+ ];
+ }
+
+ public function preProcessIndex($data)
+ {
+ return parent::preProcessIndex($data)->map(function ($item) {
+ if ($url = $this->submissionsUrl($item['id'])) {
+ $item['submissions_url'] = $url;
+ }
+
+ return $item;
+ });
+ }
+
+ private function submissionsUrl(string $handle): ?string
+ {
+ if (! $form = $this->viewableUniqueInstancesForm($handle)) {
+ return null;
+ }
+
+ if (! $entry = $this->parentEntry()) {
+ return null;
+ }
+
+ $filters = base64_encode(json_encode([
+ 'submission_entry' => ['entry' => $entry->id()],
+ 'submission_status' => ['status' => 'finalized'],
+ ]));
+
+ return $form->submissionsUrl().'?filters='.$filters;
+ }
+
+ private function parentEntry(): ?EntryContract
+ {
+ $parent = $this->field->parent();
+
+ return $parent instanceof EntryContract ? $parent : null;
+ }
+
+ private function uniqueInstancesForm(?string $handle): ?FormContract
+ {
+ if (! $handle || ! $form = Facades\Form::find($handle)) {
+ return null;
+ }
+
+ return $form->hasUniqueInstances() ? $form : null;
+ }
+
+ private function viewableUniqueInstancesForm(?string $handle): ?FormContract
+ {
+ if (! $form = $this->uniqueInstancesForm($handle)) {
+ return null;
+ }
+
+ return User::current()->can('viewSubmissions', $form) ? $form : null;
+ }
+
+ public function getItemData($values)
+ {
+ return parent::getItemData($this->toFormHandles($values));
+ }
+
+ public function augment($values)
+ {
+ return parent::augment($this->toFormHandles($values));
+ }
+
+ public function shallowAugment($values)
+ {
+ return parent::shallowAugment($this->toFormHandles($values));
+ }
+
+ public function toQueryableValue($value)
+ {
+ return parent::toQueryableValue($this->toFormHandles($value));
+ }
+
public function augmentValue($value)
{
return Facades\Form::find($value);
diff --git a/src/Forms/Form.php b/src/Forms/Form.php
index 30ec825563d..eef5457d41c 100644
--- a/src/Forms/Form.php
+++ b/src/Forms/Form.php
@@ -2,7 +2,6 @@
namespace Statamic\Forms;
-use Carbon\Carbon;
use Illuminate\Contracts\Support\Arrayable;
use Statamic\Contracts\Data\Augmentable;
use Statamic\Contracts\Data\Augmented;
@@ -24,7 +23,6 @@
use Statamic\Facades\File;
use Statamic\Facades\Form as FormFacade;
use Statamic\Facades\FormSubmission;
-use Statamic\Facades\User;
use Statamic\Facades\YAML;
use Statamic\Fields\Blueprint;
use Statamic\Forms\Exporters\Exporter;
@@ -34,8 +32,6 @@
use Statamic\Support\Str;
use Statamic\Support\Traits\FluentlyGetsAndSets;
-use function Statamic\trans as __;
-
class Form implements Arrayable, Augmentable, ContainsQueryableValues, FormContract
{
use ContainsData, FluentlyGetsAndSets, HasAugmentedInstance;
@@ -235,6 +231,15 @@ public function hasMultiplePages(): bool
return $this->formFields()->pages()->count() > 1;
}
+ public function hasUniqueInstances(): bool
+ {
+ if (! Statamic::formsProInstalled()) {
+ return false;
+ }
+
+ return (bool) $this->get('unique_instances');
+ }
+
/**
* Get the blueprint.
*
@@ -476,70 +481,24 @@ public function querySubmissions(): SubmissionQueryBuilder
return FormSubmission::query()->where('form', $this->handle());
}
- public function status(): string
- {
- return Blink::once('form-status-'.$this->handle(), fn () => match (true) {
- $this->closingDateHasPassed() => 'closed',
- $this->submissionLimitReached() => 'limit_reached',
- default => 'open',
- });
- }
-
- public function restricted(): bool
- {
- return $this->restrictionMessage() !== null;
- }
-
- public function restrictionMessage(): ?string
- {
- if ($this->closingDateHasPassed() || $this->submissionLimitReached()) {
- return ($msg = $this->get('closed_message')) ? __($msg) : __('statamic::messages.form_closed_message');
- }
-
- if ($this->get('require_login') && ! User::current()) {
- return ($msg = $this->get('require_login_message')) ? __($msg) : __('statamic::messages.form_require_login_message');
- }
-
- return null;
- }
-
- private function closingDateHasPassed(): bool
+ public function instance(?string $entry = null): Instance
{
- if (! $date = $this->get('close_date')) {
- return false;
- }
-
- return Carbon::parse($date, config('app.timezone'))->isPast();
+ return new Instance($this, $entry && $this->hasUniqueInstances() ? $entry : null);
}
- private function submissionLimitReached(): bool
+ public function status(): string
{
- if (! $limit = (int) $this->get('submission_limit')) {
- return false;
- }
-
- return $this->submissionCount() >= $limit;
+ return $this->instance()->status();
}
- private function submissionCount(): int
+ public function restricted(): bool
{
- $query = $this->querySubmissions()->whereNull('partial');
-
- if ($start = $this->submissionLimitPeriodStart()) {
- $query->where('date', '>=', $start);
- }
-
- return $query->count();
+ return $this->instance()->restricted();
}
- private function submissionLimitPeriodStart(): ?Carbon
+ public function restrictionMessage(): ?string
{
- return match ($this->get('submission_limit_period', 'total')) {
- 'day' => now()->startOfDay(),
- 'week' => now()->startOfWeek(),
- 'month' => now()->startOfMonth(),
- default => null,
- };
+ return $this->instance()->restrictionMessage();
}
/**
diff --git a/src/Forms/FormFieldValues.php b/src/Forms/FormFieldValues.php
new file mode 100644
index 00000000000..025e18c468e
--- /dev/null
+++ b/src/Forms/FormFieldValues.php
@@ -0,0 +1,125 @@
+fromFields($this->entry->blueprint()->fields(), $this->entry->values()->all());
+ }
+
+ public function referencing(string $form): Collection
+ {
+ return $this->all()
+ ->filter(fn ($value) => in_array($form, $this->handles($value), true))
+ ->values();
+ }
+
+ private function fromFields(Fields $fields, array $values): Collection
+ {
+ return $fields->all()->values()->flatMap(
+ fn (Field $field) => $this->fromField($field, Arr::get($values, $field->handle()))
+ );
+ }
+
+ private function fromField(Field $field, $value): Collection
+ {
+ if ($value === null) {
+ return collect();
+ }
+
+ return match ($field->type()) {
+ 'form' => collect([$value]),
+ 'group' => $this->fromGroup($field, $value),
+ 'grid' => $this->fromGrid($field, $value),
+ 'replicator' => $this->fromReplicator($field, $value),
+ 'bard' => $this->fromBard($field, $value),
+ default => collect(),
+ };
+ }
+
+ private function fromGroup(Field $field, $value): Collection
+ {
+ if (! is_array($value) || ! $fields = $field->get('fields')) {
+ return collect();
+ }
+
+ return $this->fromFields(new Fields($fields), $value);
+ }
+
+ private function fromGrid(Field $field, $value): Collection
+ {
+ if (! is_array($value) || ! $fields = $field->get('fields')) {
+ return collect();
+ }
+
+ return collect($value)
+ ->filter(fn ($row) => is_array($row))
+ ->flatMap(fn ($row) => $this->fromFields(new Fields($fields), $row));
+ }
+
+ private function fromReplicator(Field $field, $value): Collection
+ {
+ if (! is_array($value)) {
+ return collect();
+ }
+
+ $sets = $field->fieldtype()->flattenedSetsConfig();
+
+ return collect($value)
+ ->filter(fn ($set) => is_array($set))
+ ->flatMap(function ($set) use ($sets) {
+ if (! $fields = Arr::get($sets, Arr::get($set, 'type').'.fields')) {
+ return collect();
+ }
+
+ return $this->fromFields(new Fields($fields), $set);
+ });
+ }
+
+ private function fromBard(Field $field, $value): Collection
+ {
+ if (! is_array($value)) {
+ return collect();
+ }
+
+ $sets = $field->fieldtype()->flattenedSetsConfig();
+
+ return collect($value)
+ ->filter(fn ($node) => is_array($node))
+ ->flatMap(function ($node) use ($sets) {
+ $values = Arr::get($node, 'attrs.values', []);
+
+ if (! $fields = Arr::get($sets, Arr::get($values, 'type').'.fields')) {
+ return collect();
+ }
+
+ return $this->fromFields(new Fields($fields), $values);
+ });
+ }
+
+ private function handles($value): array
+ {
+ if (is_array($value) && Arr::isAssoc($value)) {
+ $value = $value['form'] ?? null;
+ }
+
+ return array_values(array_filter(Arr::wrap($value)));
+ }
+}
diff --git a/src/Forms/Instance.php b/src/Forms/Instance.php
new file mode 100644
index 00000000000..b266370f85d
--- /dev/null
+++ b/src/Forms/Instance.php
@@ -0,0 +1,125 @@
+form;
+ }
+
+ public function entry(): ?string
+ {
+ return $this->entry;
+ }
+
+ public function status(): string
+ {
+ return Blink::once('form-status-'.$this->form->handle().'-'.$this->entry, fn () => match (true) {
+ $this->closingDateHasPassed() => 'closed',
+ $this->submissionLimitReached() => 'limit_reached',
+ default => 'open',
+ });
+ }
+
+ public function restricted(): bool
+ {
+ return $this->restrictionMessage() !== null;
+ }
+
+ public function restrictionMessage(): ?string
+ {
+ if ($this->closingDateHasPassed() || $this->submissionLimitReached()) {
+ return ($msg = $this->config('closed_message')) ? __($msg) : __('statamic::messages.form_closed_message');
+ }
+
+ if ($this->config('require_login') && ! User::current()) {
+ return ($msg = $this->config('require_login_message')) ? __($msg) : __('statamic::messages.form_require_login_message');
+ }
+
+ return null;
+ }
+
+ public function config(string $key): mixed
+ {
+ return $this->overrides()[$key] ?? $this->form->get($key);
+ }
+
+ private function overrides(): array
+ {
+ if (! $this->entry) {
+ return [];
+ }
+
+ return Blink::once('form-instance-overrides-'.$this->form->handle().'-'.$this->entry, function () {
+ if (! $entry = Entry::find($this->entry)) {
+ return [];
+ }
+
+ $value = FormFieldValues::on($entry)
+ ->referencing($this->form->handle())
+ ->first(fn ($value) => is_array($value) && Arr::isAssoc($value));
+
+ return Arr::get($value, 'config', []);
+ });
+ }
+
+ private function closingDateHasPassed(): bool
+ {
+ if (! $date = $this->config('close_date')) {
+ return false;
+ }
+
+ return Carbon::parse($date, config('app.timezone'))->isPast();
+ }
+
+ private function submissionLimitReached(): bool
+ {
+ if (! $limit = (int) $this->config('submission_limit')) {
+ return false;
+ }
+
+ return $this->submissionCount() >= $limit;
+ }
+
+ private function submissionCount(): int
+ {
+ $query = $this->form->querySubmissions()->whereNull('partial');
+
+ if ($this->entry) {
+ $query->where('entry', $this->entry);
+ }
+
+ if ($start = $this->submissionLimitPeriodStart()) {
+ $query->where('date', '>=', $start);
+ }
+
+ return $query->count();
+ }
+
+ private function submissionLimitPeriodStart(): ?Carbon
+ {
+ return match ($this->config('submission_limit_period')) {
+ 'day' => now()->startOfDay(),
+ 'week' => now()->startOfWeek(),
+ 'month' => now()->startOfMonth(),
+ default => null,
+ };
+ }
+}
diff --git a/src/Forms/Submission.php b/src/Forms/Submission.php
index e916d4a7a18..0f475978b33 100644
--- a/src/Forms/Submission.php
+++ b/src/Forms/Submission.php
@@ -17,6 +17,7 @@
use Statamic\Events\SubmissionFinalized;
use Statamic\Events\SubmissionSaved;
use Statamic\Events\SubmissionSaving;
+use Statamic\Facades\Entry;
use Statamic\Facades\File;
use Statamic\Facades\FormSubmission;
use Statamic\Facades\Site as Sites;
@@ -70,9 +71,9 @@ public function data($data = null)
$data = collect($data);
// A full data replacement would otherwise drop the internal lifecycle
- // keys, so carry over the existing partial and site values unless the
- // incoming payload provides its own.
- foreach (['partial', 'site'] as $key) {
+ // keys, so carry over the existing partial, site and entry values
+ // unless the incoming payload provides its own.
+ foreach (['partial', 'site', 'entry'] as $key) {
if ($this->has($key) && ! $data->has($key)) {
$data[$key] = $this->get($key);
}
@@ -125,6 +126,16 @@ public function site(Site|string|null $site = null): Site|static
return $this;
}
+ /**
+ * Get the entry this submission is attached to.
+ *
+ * @return \Statamic\Contracts\Entries\Entry|null
+ */
+ public function entry()
+ {
+ return Entry::find($this->get('entry'));
+ }
+
/**
* Get the form fields.
*
@@ -335,7 +346,7 @@ public function toArray()
return $this->form()->fields()->keys()->flip()
->reject(function ($field, $key) {
- return in_array($key, ['id', 'date', 'form']);
+ return in_array($key, ['id', 'date', 'form', 'entry']);
})
->map(function ($field, $key) use ($data) {
return $data[$key] ?? null;
@@ -344,14 +355,23 @@ public function toArray()
'id' => $this->id(),
'date' => $this->date(),
])
+ ->when($this->has('entry'), fn ($values) => $values->merge([
+ 'entry' => $this->get('entry'),
+ ]))
->all();
}
public function augmentedArrayData()
{
- return array_merge($this->toArray(), [
+ $data = array_merge($this->toArray(), [
'form' => $this->form,
]);
+
+ if ($this->has('entry')) {
+ $data['entry'] = $this->entry();
+ }
+
+ return $data;
}
public function blueprint()
diff --git a/src/Forms/SubmitForm.php b/src/Forms/SubmitForm.php
index 27fc1f443ca..7da9fa35680 100644
--- a/src/Forms/SubmitForm.php
+++ b/src/Forms/SubmitForm.php
@@ -5,6 +5,7 @@
use Facades\Statamic\Fields\Validator as FieldValidator;
use Illuminate\Support\Traits\Localizable;
use Illuminate\Validation\ValidationException;
+use Statamic\Contracts\Entries\Entry as EntryContract;
use Statamic\Contracts\Forms\Form;
use Statamic\Contracts\Forms\Submission;
use Statamic\Events\FormSubmitted;
@@ -12,6 +13,7 @@
use Statamic\Exceptions\SilentFormFailureException;
use Statamic\Facades\Asset;
use Statamic\Facades\AssetContainer;
+use Statamic\Facades\Entry;
use Statamic\Facades\Site;
use Statamic\Forms\Logic\PageLogic;
use Statamic\Rules\AllowedFile;
@@ -23,6 +25,7 @@ class SubmitForm
protected Form $form;
protected ?string $page = null;
+ protected ?string $entry = null;
protected ?Submission $submission = null;
public function form(Form $form): static
@@ -39,6 +42,13 @@ public function page(string $page): static
return $this;
}
+ public function entry(?string $entry): static
+ {
+ $this->entry = $entry;
+
+ return $this;
+ }
+
public function resume(Submission $submission): static
{
$this->submission = $submission;
@@ -53,7 +63,12 @@ public function submission(): ?Submission
public function submit(array $data, array $files = []): SubmissionResult
{
- throw_if($this->form->restricted(), new FormRestrictedException($this->form));
+ $entry = $this->getEntry();
+ $instance = $this->form->instance($entry?->id());
+
+ if ($instance->restricted()) {
+ throw new FormRestrictedException($instance);
+ }
$nextPage = null;
$uploadedAssets = [];
@@ -64,6 +79,10 @@ public function submit(array $data, array $files = []): SubmissionResult
$this->submission = $this->submission ?? $this->form->makeSubmission()->asPartial()->site($this->site());
+ if ($entry) {
+ $this->submission->set('entry', $entry->id());
+ }
+
try {
$uploadedAssets = $this->submission->uploadFiles($files);
@@ -99,6 +118,21 @@ public function submit(array $data, array $files = []): SubmissionResult
return new SubmissionResult($this->submission, $nextPage);
}
+ private function getEntry(): ?EntryContract
+ {
+ if (! $this->form->hasUniqueInstances()) {
+ return null;
+ }
+
+ $entry = $this->entry ? Entry::find($this->entry) : null;
+
+ if (! $entry || FormFieldValues::on($entry)->referencing($this->form->handle())->isEmpty()) {
+ throw ValidationException::withMessages(['*' => ['This form must be submitted from an entry.']]);
+ }
+
+ return $entry;
+ }
+
/**
* Normalize uploaded files to arrays.
*
diff --git a/src/Forms/Tags.php b/src/Forms/Tags.php
index f35a8f4d994..2640657fad9 100644
--- a/src/Forms/Tags.php
+++ b/src/Forms/Tags.php
@@ -99,9 +99,11 @@ public function create()
$data['previous_page_url'] = $this->previousPageUrl();
}
- $data['restricted'] = $form->restricted();
- $data['restriction_message'] = $form->restrictionMessage();
- $data['status'] = $form->status();
+ $instance = $form->instance($this->context->value('id'));
+
+ $data['restricted'] = $instance->restricted();
+ $data['restriction_message'] = $instance->restrictionMessage();
+ $data['status'] = $instance->status();
if ($jsDriver) {
$data['js_driver'] = $jsDriver->handle();
@@ -144,6 +146,10 @@ public function create()
$params['page'] = Arr::get($this->currentPage(), 'id');
}
+ if ($entry = $instance->entry()) {
+ $params['entry'] = $entry;
+ }
+
if (! $this->canParseContents()) {
return array_merge([
'attrs' => $this->formAttrs($action, $method, $knownParams, $attrs),
diff --git a/src/Http/Controllers/CP/Forms/FormSubmissionsController.php b/src/Http/Controllers/CP/Forms/FormSubmissionsController.php
index 321ed4730b5..cd0d08a1b89 100644
--- a/src/Http/Controllers/CP/Forms/FormSubmissionsController.php
+++ b/src/Http/Controllers/CP/Forms/FormSubmissionsController.php
@@ -5,6 +5,7 @@
use Illuminate\Http\Request;
use Inertia\Inertia;
use Statamic\CP\Column;
+use Statamic\CP\Columns;
use Statamic\Events\FormSubmitted;
use Statamic\Facades\Scope;
use Statamic\Facades\Site;
@@ -37,6 +38,16 @@ public function index(FilteredRequest $request, $form)
->blueprint()
->columns()
->prepend(Column::make('status'), 'status')
+ ->when(
+ $form->hasUniqueInstances(),
+ fn (Columns $columns) => $columns->prepend(
+ Column::make('entry')
+ ->label(__('Entry'))
+ ->fieldtype('relationship')
+ ->sortable(false),
+ 'entry'
+ )
+ )
->prepend(Column::make('datestamp'), 'datestamp')
->setPreferred("forms.{$form->handle()}.columns")
->rejectUnlisted()
@@ -91,7 +102,7 @@ protected function json(FilteredRequest $request, $form)
$submissions = $query->paginate(Statamic::cpPerPage(request('perPage')));
return (new Submissions($submissions))
- ->blueprint($form->blueprint())
+ ->form($form)
->columnPreferenceKey("forms.{$form->handle()}.columns")
->additional(['meta' => [
'activeFilterBadges' => $activeFilterBadges,
@@ -118,7 +129,7 @@ public function destroy($form, $id)
return response('', 204);
}
- public function show($form, $submission)
+ public function show(Request $request, $form, $submission)
{
if (! $submission = $form->submission($submission)) {
return $this->pageNotFound();
@@ -129,17 +140,36 @@ public function show($form, $submission)
$blueprint = $form->blueprint();
$fields = $blueprint->fields()->addValues($submission->data()->all())->preProcess();
- return Inertia::render('forms/Submission', [
- 'form' => $form,
- 'can' => $this->formAbilities($form),
+ $data = [
'id' => $submission->id(),
- 'formTitle' => $form->title(),
'status' => $submission->status(),
'date' => $submission->date()->toIso8601String(),
'blueprint' => $blueprint->toPublishArray(),
'values' => $fields->values(),
'meta' => $fields->meta(),
- ]);
+ ];
+
+ if ($request->wantsJson()) {
+ return $data;
+ }
+
+ if ($form->hasUniqueInstances() && ($id = $submission->get('entry'))) {
+ $entry = $submission->entry();
+
+ $entryData = [
+ 'id' => $id,
+ 'title' => $entry?->value('title'),
+ 'edit_url' => $entry?->editUrl(),
+ 'status' => $entry?->status(),
+ ];
+ }
+
+ return Inertia::render('forms/Submission', array_merge($data, [
+ 'form' => $form,
+ 'can' => $this->formAbilities($form),
+ 'formTitle' => $form->title(),
+ 'entry' => $entryData ?? null,
+ ]));
}
public function generateFake(Request $request, $form, FakeSubmissionGenerator $generator)
diff --git a/src/Http/Controllers/CP/Forms/FormsController.php b/src/Http/Controllers/CP/Forms/FormsController.php
index 2880654919d..33cc746ad13 100644
--- a/src/Http/Controllers/CP/Forms/FormsController.php
+++ b/src/Http/Controllers/CP/Forms/FormsController.php
@@ -6,9 +6,9 @@
use Inertia\Inertia;
use Statamic\Contracts\Forms\Form as FormContract;
use Statamic\CP\Column;
-use Statamic\Facades\Blueprint;
use Statamic\Facades\Form;
use Statamic\Facades\User;
+use Statamic\Forms\ConfigFields;
use Statamic\Http\Controllers\CP\CpController;
use Statamic\Http\Controllers\CP\Forms\Concerns\ProvidesFormAbilities;
use Statamic\Rules\Handle;
@@ -118,7 +118,7 @@ public function edit($form)
{
$this->authorize('edit', $form);
- $blueprint = $this->editFormBlueprint($form);
+ $blueprint = ConfigFields::blueprint($form);
$values = array_merge($form->data()->all(), [
'handle' => $form->handle(),
@@ -148,7 +148,7 @@ public function update($form, Request $request)
{
$this->authorize('edit', $form);
- $fields = $this->editFormBlueprint($form)->fields()->addValues($request->all());
+ $fields = ConfigFields::blueprint($form)->fields()->addValues($request->all());
$fields->validate();
@@ -181,243 +181,4 @@ private function canCreateAdditionalForms(): bool
|| Statamic::pro()
|| Statamic::formsProInstalled();
}
-
- protected function editFormBlueprint($form)
- {
- $fields = [
- 'name' => [
- 'display' => __('Name'),
- 'fields' => [
- 'title' => [
- 'type' => 'text',
- 'validate' => 'required',
- 'instructions' => __('statamic::messages.form_configure_title_instructions'),
- ],
- ],
- ],
- 'fields' => [
- 'display' => __('Fields'),
- 'fields' => [
- 'honeypot' => [
- 'type' => 'text',
- 'instructions' => __('statamic::messages.form_configure_honeypot_instructions'),
- ],
- ],
- ],
- 'submissions' => [
- 'display' => __('Submissions'),
- 'fields' => [
- 'store' => [
- 'display' => __('Store Submissions'),
- 'type' => 'toggle',
- 'instructions' => __('statamic::messages.form_configure_store_instructions'),
- ],
- 'generate_fake_submissions' => [
- 'display' => __('Enable Fake Submission Generator'),
- 'type' => 'toggle',
- 'default' => true,
- 'instructions' => __('statamic::messages.form_configure_generate_fake_submissions_instructions'),
- ],
- ],
- ],
- 'access' => [
- 'display' => __('Access'),
- 'fields' => [
- 'close_date' => [
- 'display' => __('Close Date'),
- 'type' => 'date',
- 'time_enabled' => true,
- 'instructions' => __('statamic::messages.form_configure_close_date_instructions'),
- ],
- 'submission_limit' => [
- 'display' => __('Submission Limit'),
- 'type' => 'integer',
- 'instructions' => __('statamic::messages.form_configure_submission_limit_instructions'),
- ],
- 'submission_limit_period' => [
- 'display' => __('Submission Limit Period'),
- 'type' => 'button_group',
- 'default' => 'total',
- 'options' => [
- 'total' => __('Total'),
- 'day' => __('Per Day'),
- 'week' => __('Per Week'),
- 'month' => __('Per Month'),
- ],
- 'if' => [
- 'submission_limit' => 'not empty',
- ],
- 'instructions' => __('statamic::messages.form_configure_submission_limit_period_instructions'),
- ],
- 'closed_message' => [
- 'display' => __('Closed Message'),
- 'type' => 'textarea',
- 'if_any' => [
- 'close_date' => 'not empty',
- 'submission_limit' => 'not empty',
- ],
- 'placeholder' => __('statamic::messages.form_closed_message'),
- 'instructions' => __('statamic::messages.form_configure_closed_message_instructions'),
- ],
- 'require_login' => [
- 'display' => __('Require Login'),
- 'type' => 'toggle',
- 'instructions' => __('statamic::messages.form_configure_require_login_instructions'),
- ],
- 'require_login_message' => [
- 'display' => __('Require Login Message'),
- 'type' => 'textarea',
- 'if' => [
- 'require_login' => 'equals true',
- ],
- 'placeholder' => __('statamic::messages.form_require_login_message'),
- 'instructions' => __('statamic::messages.form_configure_require_login_message_instructions'),
- ],
- ],
- ],
- 'email' => [
- 'display' => __('Email'),
- 'fields' => [
- 'email' => [
- 'type' => 'grid',
- 'mode' => 'stacked',
- 'full_width_setting' => true,
- 'add_row' => __('Add Email'),
- 'instructions' => __('statamic::messages.form_configure_email_instructions'),
- 'fields' => [
- [
- 'handle' => 'to',
- 'field' => [
- 'type' => 'text',
- 'display' => __('Recipient(s)'),
- 'validate' => [
- 'required',
- ],
- 'instructions' => __('statamic::messages.form_configure_email_to_instructions'),
- ],
- ],
- [
- 'handle' => 'cc',
- 'field' => [
- 'type' => 'text',
- 'display' => __('CC Recipient(s)'),
- 'instructions' => __('statamic::messages.form_configure_email_cc_instructions'),
- ],
- ],
- [
- 'handle' => 'bcc',
- 'field' => [
- 'type' => 'text',
- 'display' => __('BCC Recipient(s)'),
- 'instructions' => __('statamic::messages.form_configure_email_bcc_instructions'),
- ],
- ],
- [
- 'handle' => 'from',
- 'field' => [
- 'type' => 'text',
- 'display' => __('Sender'),
- 'instructions' => __('statamic::messages.form_configure_email_from_instructions').' ('.config('mail.from.address').').',
- ],
- ],
- [
- 'handle' => 'reply_to',
- 'field' => [
- 'type' => 'text',
- 'display' => __('Reply To'),
- 'instructions' => __('statamic::messages.form_configure_email_reply_to_instructions'),
- ],
- ],
- [
- 'handle' => 'subject',
- 'field' => [
- 'type' => 'text',
- 'display' => __('Subject'),
- 'instructions' => __('statamic::messages.form_configure_email_subject_instructions'),
- ],
- ],
- [
- 'handle' => 'html',
- 'field' => [
- 'type' => 'template',
- 'display' => __('HTML view'),
- 'instructions' => __('statamic::messages.form_configure_email_html_instructions'),
- 'folder' => config('statamic.forms.email_view_folder'),
- 'clearable' => true,
- ],
- ],
- [
- 'handle' => 'text',
- 'field' => [
- 'type' => 'template',
- 'display' => __('Text view'),
- 'instructions' => __('statamic::messages.form_configure_email_text_instructions'),
- 'folder' => config('statamic.forms.email_view_folder'),
- 'clearable' => true,
- ],
- ],
- [
- 'handle' => 'markdown',
- 'field' => [
- 'type' => 'toggle',
- 'display' => __('Markdown'),
- 'instructions' => __('statamic::messages.form_configure_email_markdown_instructions'),
- ],
- ],
- [
- 'handle' => 'attachments',
- 'field' => [
- 'type' => 'toggle',
- 'display' => __('Attachments'),
- 'instructions' => __('statamic::messages.form_configure_email_attachments_instructions'),
- ],
- ],
- [
- 'handle' => 'mailer',
- 'field' => [
- 'type' => 'select',
- 'instructions' => __('statamic::messages.form_configure_mailer_instructions'),
- 'options' => array_keys(config('mail.mailers')),
- 'clearable' => true,
- ],
- ],
- ],
- ],
- ],
- ],
- ];
-
- foreach (Form::extraConfigFor($form->handle()) as $handle => $config) {
- $merged = false;
- foreach ($fields as $sectionHandle => $section) {
- if ($section['display'] == __($config['display'])) {
- $fields[$sectionHandle]['fields'] += $config['fields'];
- $merged = true;
- }
- }
-
- if (! $merged) {
- $fields[$handle] = $config;
- }
- }
-
- return Blueprint::make()->setContents(collect([
- 'tabs' => [
- 'main' => [
- 'sections' => collect($fields)->map(function ($section) {
- return [
- 'display' => $section['display'],
- 'fields' => collect($section['fields'])->map(function ($field, $handle) {
- return [
- 'handle' => $handle,
- 'field' => $field,
- ];
- })->values()->all(),
- ];
- })->values()->all(),
- ],
- ],
- ])->all());
-
- }
}
diff --git a/src/Http/Controllers/FormController.php b/src/Http/Controllers/FormController.php
index 975290875f6..34dd7be6d52 100644
--- a/src/Http/Controllers/FormController.php
+++ b/src/Http/Controllers/FormController.php
@@ -31,6 +31,10 @@ public function submit(Request $request, $form, SubmitForm $action)
$action->form($form);
+ if (is_string($entry = $request->input('_entry'))) {
+ $action->entry($entry);
+ }
+
if ($form->hasMultiplePages()) {
$action->page($form->formFields()->pages()->first()['id']);
diff --git a/src/Http/Resources/CP/Submissions/ListedSubmission.php b/src/Http/Resources/CP/Submissions/ListedSubmission.php
index 323b6995e2c..ad95661fd16 100644
--- a/src/Http/Resources/CP/Submissions/ListedSubmission.php
+++ b/src/Http/Resources/CP/Submissions/ListedSubmission.php
@@ -4,6 +4,7 @@
use Illuminate\Http\Resources\Json\JsonResource;
use Statamic\Facades\User;
+use Statamic\Fields\Field;
class ListedSubmission extends JsonResource
{
@@ -48,6 +49,16 @@ protected function values($extra = [])
return ['status' => $this->resource->status()];
}
+ if ($key === 'entry') {
+ $entry = (new Field('entry', ['type' => 'entries']))
+ ->setValue($this->resource->get('entry'))
+ ->setParent($this->resource)
+ ->preProcessIndex()
+ ->value();
+
+ return ['entry' => $entry];
+ }
+
$value = $extra[$key] ?? $this->resource->get($key);
if (! $field = $this->blueprint->field($key)) {
diff --git a/src/Http/Resources/CP/Submissions/Submissions.php b/src/Http/Resources/CP/Submissions/Submissions.php
index fd9e89386f6..01d26366a25 100644
--- a/src/Http/Resources/CP/Submissions/Submissions.php
+++ b/src/Http/Resources/CP/Submissions/Submissions.php
@@ -13,13 +13,13 @@ class Submissions extends ResourceCollection
use HasRequestedColumns;
public $collects = ListedSubmission::class;
- protected $blueprint;
+ protected $form;
protected $columnPreferenceKey;
protected $columns;
- public function blueprint($blueprint)
+ public function form($form)
{
- $this->blueprint = $blueprint;
+ $this->form = $form;
return $this;
}
@@ -33,8 +33,12 @@ public function columnPreferenceKey($key)
private function setColumns()
{
- $columns = $this->blueprint
+ $columns = $this->form
+ ->blueprint()
->columns()
+ ->when($this->form->hasUniqueInstances(), fn ($columns) => $columns->ensurePrepended(
+ Column::make('entry')->label(__('Entry'))->fieldtype('relationship')->sortable(false)
+ ))
->ensurePrepended(Column::make('datestamp')->label('Date'));
$status = Column::make('status')
@@ -60,7 +64,7 @@ public function toArray($request)
return $this->collection->each(function ($collection) {
$collection
- ->blueprint($this->blueprint)
+ ->blueprint($this->form->blueprint())
->columns($this->requestedColumns());
});
}
diff --git a/src/Providers/ExtensionServiceProvider.php b/src/Providers/ExtensionServiceProvider.php
index d0f524d1433..e6bef989509 100644
--- a/src/Providers/ExtensionServiceProvider.php
+++ b/src/Providers/ExtensionServiceProvider.php
@@ -201,6 +201,7 @@ class ExtensionServiceProvider extends ServiceProvider
Scopes\Filters\Fields::class,
Scopes\Filters\Blueprint::class,
Scopes\Filters\Status::class,
+ Scopes\Filters\SubmissionEntry::class,
Scopes\Filters\SubmissionSite::class,
Scopes\Filters\SubmissionStatus::class,
Scopes\Filters\Site::class,
diff --git a/src/Query/Scopes/Filters/SubmissionEntry.php b/src/Query/Scopes/Filters/SubmissionEntry.php
new file mode 100644
index 00000000000..7ac6a3c17ca
--- /dev/null
+++ b/src/Query/Scopes/Filters/SubmissionEntry.php
@@ -0,0 +1,74 @@
+ [
+ 'display' => __('Entry'),
+ 'type' => 'select',
+ 'options' => $this->options()->all(),
+ ],
+ ];
+ }
+
+ public function autoApply()
+ {
+ if ($entry = $this->context['entry'] ?? null) {
+ return ['entry' => $entry];
+ }
+
+ return [];
+ }
+
+ public function apply($query, $values)
+ {
+ $query->where('entry', $values['entry']);
+ }
+
+ public function badge($values)
+ {
+ $title = Entry::find($values['entry'])?->value('title');
+
+ return __('Entry').': '.($title ?? $values['entry']);
+ }
+
+ public function visibleTo($key)
+ {
+ return $key === 'form-submissions' && $this->form()?->hasUniqueInstances();
+ }
+
+ private function form()
+ {
+ return Form::find($this->context['form'] ?? null);
+ }
+
+ private function options()
+ {
+ $ids = $this->form()
+ ->querySubmissions()
+ ->whereNotNull('entry')
+ ->pluck('entry')
+ ->unique()
+ ->values();
+
+ return Entry::query()
+ ->whereIn('id', $ids->all())
+ ->get(['id', 'title'])
+ ->mapWithKeys(fn ($entry) => [$entry->id() => $entry->value('title')]);
+ }
+}
diff --git a/src/Stache/Stores/FormSubmissionsStore.php b/src/Stache/Stores/FormSubmissionsStore.php
index 4660f869168..88c910f764d 100644
--- a/src/Stache/Stores/FormSubmissionsStore.php
+++ b/src/Stache/Stores/FormSubmissionsStore.php
@@ -18,6 +18,7 @@ class FormSubmissionsStore extends ChildStore
protected $storeIndexes = [
'form',
'date',
+ 'entry',
];
public function getItemKey($item)
diff --git a/tests/Feature/Forms/EditFormTest.php b/tests/Feature/Forms/EditFormTest.php
index dac8ecaf928..c51ed565321 100644
--- a/tests/Feature/Forms/EditFormTest.php
+++ b/tests/Feature/Forms/EditFormTest.php
@@ -2,6 +2,7 @@
namespace Tests\Feature\Forms;
+use Facades\Statamic\Console\Processes\Composer;
use PHPUnit\Framework\Attributes\Test;
use Statamic\Facades\Form;
use Statamic\Facades\User;
@@ -21,6 +22,13 @@ protected function resolveApplicationConfiguration($app)
$app['config']['statamic.forms.forms'] = $this->fakeStacheDirectory.'/forms';
}
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ Composer::shouldReceive('isInstalled')->with('statamic/forms-pro')->andReturn(false)->byDefault();
+ }
+
#[Test]
public function it_shows_the_edit_page_if_you_have_permission()
{
diff --git a/tests/Feature/Forms/UpdateFormTest.php b/tests/Feature/Forms/UpdateFormTest.php
index 00b7063662c..fe40170a682 100644
--- a/tests/Feature/Forms/UpdateFormTest.php
+++ b/tests/Feature/Forms/UpdateFormTest.php
@@ -2,6 +2,7 @@
namespace Tests\Feature\Forms;
+use Facades\Statamic\Console\Processes\Composer;
use PHPUnit\Framework\Attributes\Test;
use Statamic\Facades\Form;
use Statamic\Facades\User;
@@ -21,6 +22,13 @@ protected function resolveApplicationConfiguration($app)
$app['config']['statamic.forms.forms'] = $this->fakeStacheDirectory.'/forms';
}
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ Composer::shouldReceive('isInstalled')->with('statamic/forms-pro')->andReturn(false)->byDefault();
+ }
+
#[Test]
public function it_denies_access_if_you_dont_have_permission()
{
diff --git a/tests/Feature/Forms/ViewSubmissionTest.php b/tests/Feature/Forms/ViewSubmissionTest.php
new file mode 100644
index 00000000000..d3936a5b163
--- /dev/null
+++ b/tests/Feature/Forms/ViewSubmissionTest.php
@@ -0,0 +1,104 @@
+fakeStacheDirectory.'/forms';
+ }
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ Composer::shouldReceive('isInstalled')->with('statamic/forms-pro')->andReturn(false)->byDefault();
+ }
+
+ #[Test]
+ public function it_shows_a_submission()
+ {
+ $user = tap(User::make()->makeSuper())->save();
+ $form = tap(Form::make('test'))->save();
+ $submission = tap(FormSubmission::make()->form($form)->data(['foo' => 'bar']))->save();
+
+ $this
+ ->actingAs($user)
+ ->get(cp_route('forms.submissions.show', [$form->handle(), $submission->id()]))
+ ->assertSuccessful()
+ ->assertInertia(fn ($page) => $page
+ ->component('forms/Submission')
+ ->where('entry', null));
+ }
+
+ #[Test]
+ public function it_shows_the_associated_entry()
+ {
+ Composer::shouldReceive('isInstalled')->with('statamic/forms-pro')->andReturn(true);
+
+ $entry = (new EntryFactory)->collection('events')->id('event-1')->slug('event-one')->data(['title' => 'Event One'])->create();
+
+ $user = tap(User::make()->makeSuper())->save();
+ $form = tap(Form::make('test')->set('unique_instances', true))->save();
+ $submission = tap(FormSubmission::make()->form($form)->data(['entry' => 'event-1']))->save();
+
+ $this
+ ->actingAs($user)
+ ->get(cp_route('forms.submissions.show', [$form->handle(), $submission->id()]))
+ ->assertSuccessful()
+ ->assertInertia(fn ($page) => $page
+ ->component('forms/Submission')
+ ->where('entry.id', 'event-1')
+ ->where('entry.title', 'Event One')
+ ->where('entry.edit_url', $entry->editUrl())
+ ->where('entry.status', 'published'));
+ }
+
+ #[Test]
+ public function it_doesnt_show_the_entry_without_forms_pro()
+ {
+ (new EntryFactory)->collection('events')->id('event-1')->slug('event-one')->create();
+
+ $user = tap(User::make()->makeSuper())->save();
+ $form = tap(Form::make('test')->set('unique_instances', true))->save();
+ $submission = tap(FormSubmission::make()->form($form)->data(['entry' => 'event-1']))->save();
+
+ $this
+ ->actingAs($user)
+ ->get(cp_route('forms.submissions.show', [$form->handle(), $submission->id()]))
+ ->assertSuccessful()
+ ->assertInertia(fn ($page) => $page
+ ->component('forms/Submission')
+ ->where('entry', null));
+ }
+
+ #[Test]
+ public function it_shows_a_submission_as_json()
+ {
+ $user = tap(User::make()->makeSuper())->save();
+ $form = tap(Form::make('test'))->save();
+ $submission = tap(FormSubmission::make()->form($form)->data(['foo' => 'bar']))->save();
+
+ $this
+ ->actingAs($user)
+ ->getJson(cp_route('forms.submissions.show', [$form->handle(), $submission->id()]))
+ ->assertSuccessful()
+ ->assertJsonStructure(['id', 'status', 'date', 'blueprint', 'values', 'meta'])
+ ->assertJsonPath('id', $submission->id())
+ ->assertJsonPath('status', 'finalized');
+ }
+}
diff --git a/tests/Feature/Forms/ViewSubmissionsListingTest.php b/tests/Feature/Forms/ViewSubmissionsListingTest.php
index f6f486bd897..e17ae5a4e30 100644
--- a/tests/Feature/Forms/ViewSubmissionsListingTest.php
+++ b/tests/Feature/Forms/ViewSubmissionsListingTest.php
@@ -2,10 +2,12 @@
namespace Tests\Feature\Forms;
+use Facades\Statamic\Console\Processes\Composer;
use PHPUnit\Framework\Attributes\Test;
use Statamic\Facades\Form;
use Statamic\Facades\FormSubmission;
use Statamic\Facades\User;
+use Tests\Factories\EntryFactory;
use Tests\FakesRoles;
use Tests\PreventSavingStacheItemsToDisk;
use Tests\TestCase;
@@ -22,6 +24,13 @@ protected function resolveApplicationConfiguration($app)
$app['config']['statamic.forms.forms'] = $this->fakeStacheDirectory.'/forms';
}
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ Composer::shouldReceive('isInstalled')->with('statamic/forms-pro')->andReturn(false)->byDefault();
+ }
+
#[Test]
public function it_shows_the_listing_with_the_view_form_submissions_permission()
{
@@ -79,4 +88,65 @@ public function it_does_not_eager_load_actions_in_submissions_listing()
->assertJsonCount(1, 'data')
->assertJsonMissingPath('data.0.actions');
}
+
+ #[Test]
+ public function it_includes_the_entry_column_when_unique_instances_is_enabled()
+ {
+ Composer::shouldReceive('isInstalled')->with('statamic/forms-pro')->andReturn(true);
+
+ $entry = (new EntryFactory)->collection('events')->id('event-1')->slug('event-one')->data(['title' => 'Event One'])->create();
+
+ $user = tap(User::make()->makeSuper())->save();
+ $form = tap(Form::make('test')->set('unique_instances', true))->save();
+ FormSubmission::make()->form($form)->data(['entry' => 'event-1'])->save();
+
+ $response = $this
+ ->actingAs($user)
+ ->getJson(cp_route('forms.submissions.index', $form->handle()))
+ ->assertSuccessful()
+ ->assertJsonPath('data.0.entry.0.id', 'event-1')
+ ->assertJsonPath('data.0.entry.0.title', 'Event One')
+ ->assertJsonPath('data.0.entry.0.status', 'published')
+ ->assertJsonPath('data.0.entry.0.edit_url', $entry->editUrl());
+
+ $this->assertContains('entry', collect($response->json('meta.columns'))->pluck('field')->all());
+ }
+
+ #[Test]
+ public function it_doesnt_include_the_entry_column_when_unique_instances_is_disabled()
+ {
+ $user = tap(User::make()->makeSuper())->save();
+ $form = tap(Form::make('test'))->save();
+ FormSubmission::make()->form($form)->data(['foo' => 'bar'])->save();
+
+ $response = $this
+ ->actingAs($user)
+ ->getJson(cp_route('forms.submissions.index', $form->handle()))
+ ->assertSuccessful();
+
+ $this->assertNotContains('entry', collect($response->json('meta.columns'))->pluck('field')->all());
+ }
+
+ #[Test]
+ public function it_filters_submissions_by_entry()
+ {
+ Composer::shouldReceive('isInstalled')->with('statamic/forms-pro')->andReturn(true);
+
+ (new EntryFactory)->collection('events')->id('event-1')->slug('event-one')->create();
+ (new EntryFactory)->collection('events')->id('event-2')->slug('event-two')->create();
+
+ $user = tap(User::make()->makeSuper())->save();
+ $form = tap(Form::make('test')->set('unique_instances', true))->save();
+ FormSubmission::make()->form($form)->data(['entry' => 'event-1'])->save();
+ FormSubmission::make()->form($form)->data(['entry' => 'event-2'])->save();
+
+ $filters = base64_encode(json_encode(['submission_entry' => ['entry' => 'event-2']]));
+
+ $this
+ ->actingAs($user)
+ ->getJson(cp_route('forms.submissions.index', $form->handle()).'?filters='.$filters)
+ ->assertSuccessful()
+ ->assertJsonCount(1, 'data')
+ ->assertJsonPath('data.0.entry.0.id', 'event-2');
+ }
}
diff --git a/tests/Fieldtypes/FormTest.php b/tests/Fieldtypes/FormTest.php
new file mode 100644
index 00000000000..61dffc93c35
--- /dev/null
+++ b/tests/Fieldtypes/FormTest.php
@@ -0,0 +1,184 @@
+fakeStacheDirectory.'/forms';
+ }
+
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ Composer::shouldReceive('isInstalled')->with('statamic/forms-pro')->andReturn(true)->byDefault();
+
+ Form::make('contact')->save();
+ }
+
+ private function fieldtype(array $config = [])
+ {
+ return (new Field('rsvp_form', array_merge(['type' => 'form', 'max_items' => 1], $config)))->fieldtype();
+ }
+
+ #[Test]
+ public function it_processes_to_a_plain_handle_when_nothing_is_configured()
+ {
+ $this->assertEquals('contact', $this->fieldtype()->process(['form' => ['contact'], 'config' => []]));
+ }
+
+ #[Test]
+ public function it_processes_to_an_array_when_config_is_present()
+ {
+ $value = $this->fieldtype()->process(['form' => ['contact'], 'config' => [
+ 'submission_limit' => 5,
+ 'submission_limit_period' => 'day',
+ 'closed_message' => null,
+ ]]);
+
+ $this->assertEquals(['form' => 'contact', 'config' => [
+ 'submission_limit' => 5,
+ 'submission_limit_period' => 'day',
+ ]], $value);
+ }
+
+ #[Test]
+ public function it_keeps_falsy_overrides()
+ {
+ $value = $this->fieldtype()->process(['form' => ['contact'], 'config' => [
+ 'require_login' => false,
+ ]]);
+
+ $this->assertEquals(['form' => 'contact', 'config' => ['require_login' => false]], $value);
+ }
+
+ #[Test]
+ public function it_drops_blank_overrides()
+ {
+ $value = $this->fieldtype()->process(['form' => ['contact'], 'config' => [
+ 'close_date' => null,
+ 'closed_message' => '',
+ ]]);
+
+ $this->assertEquals('contact', $value);
+ }
+
+ #[Test]
+ public function it_processes_to_null_without_a_form()
+ {
+ $this->assertNull($this->fieldtype()->process(['form' => [], 'config' => ['submission_limit' => 5]]));
+ }
+
+ #[Test]
+ public function it_processes_handles_normally_when_multiple_forms_are_allowed()
+ {
+ $this->assertEquals(['contact', 'other'], $this->fieldtype(['max_items' => 2])->process(['contact', 'other']));
+ }
+
+ #[Test]
+ public function it_processes_to_a_plain_handle_without_forms_pro()
+ {
+ Composer::shouldReceive('isInstalled')->with('statamic/forms-pro')->andReturn(false);
+
+ $value = $this->fieldtype()->process(['form' => ['contact'], 'config' => ['submission_limit' => 5]]);
+
+ $this->assertEquals('contact', $value);
+ }
+
+ #[Test]
+ public function it_validates_against_the_form_handles()
+ {
+ $fieldtype = $this->fieldtype();
+
+ $this->assertEquals([], $fieldtype->preProcessValidatable(['form' => [], 'config' => ['closed_message' => 'Closed.']]));
+ $this->assertEquals(['contact'], $fieldtype->preProcessValidatable(['form' => ['contact'], 'config' => []]));
+ $this->assertEquals(['array', 'max:1'], $fieldtype->rules());
+ }
+
+ #[Test]
+ public function it_pre_processes_both_stored_shapes()
+ {
+ $fromString = $this->fieldtype()->preProcess('contact');
+ $fromArray = $this->fieldtype()->preProcess(['form' => 'contact', 'config' => ['submission_limit' => 5]]);
+
+ $this->assertEquals(['contact'], $fromString['form']);
+ $this->assertEquals([], $fromString['config']);
+
+ $this->assertEquals(['contact'], $fromArray['form']);
+ $this->assertEquals(['submission_limit' => 5], $fromArray['config']);
+ }
+
+ #[Test]
+ public function it_pre_processes_to_handles_without_forms_pro()
+ {
+ Composer::shouldReceive('isInstalled')->with('statamic/forms-pro')->andReturn(false);
+
+ $this->assertEquals(['contact'], $this->fieldtype()->preProcess(['form' => 'contact', 'config' => ['submission_limit' => 5]]));
+ }
+
+ #[Test]
+ public function it_preloads_item_data_for_both_stored_shapes()
+ {
+ $this->actingAs(tap(User::make()->makeSuper())->save());
+
+ $field = new Field('rsvp_form', ['type' => 'form', 'max_items' => 1]);
+
+ $fromString = $field->setValue('contact')->fieldtype()->preload();
+ $fromArray = $field->setValue(['form' => 'contact', 'config' => ['submission_limit' => 1]])->fieldtype()->preload();
+
+ $this->assertEquals('contact', $fromString['data'][0]['id']);
+ $this->assertEquals('contact', $fromArray['data'][0]['id']);
+ }
+
+ #[Test]
+ public function it_preloads_the_forms_values_as_origin_values()
+ {
+ Composer::shouldReceive('isInstalled')->with('statamic/forms-pro')->andReturn(true);
+
+ $this->actingAs(tap(User::make()->makeSuper())->save());
+
+ Form::find('contact')->set('unique_instances', true)->set('submission_limit', 100)->save();
+
+ $preload = (new Field('rsvp_form', ['type' => 'form', 'max_items' => 1]))
+ ->setValue(['form' => 'contact', 'config' => ['submission_limit' => 25]])
+ ->fieldtype()
+ ->preload();
+
+ $configuration = $preload['configuration'];
+
+ $this->assertEquals(100, $configuration['originValues']['submission_limit']);
+ $this->assertArrayNotHasKey('values', $configuration);
+
+ $fields = collect($configuration['blueprint']['tabs'][0]['sections'][0]['fields']);
+
+ $this->assertTrue($fields->every(fn ($field) => $field['localizable']));
+ }
+
+ #[Test]
+ public function it_augments_both_stored_shapes_to_the_form()
+ {
+ $this->assertEquals('contact', $this->fieldtype()->augment('contact')->handle());
+ $this->assertEquals('contact', $this->fieldtype()->augment(['form' => 'contact', 'config' => []])->handle());
+ }
+
+ #[Test]
+ public function it_queries_both_stored_shapes_by_handle()
+ {
+ $this->assertEquals('contact', $this->fieldtype()->toQueryableValue('contact'));
+ $this->assertEquals('contact', $this->fieldtype()->toQueryableValue(['form' => 'contact', 'config' => []]));
+ }
+}
diff --git a/tests/Forms/AccessTest.php b/tests/Forms/AccessTest.php
index 2a9e283d76e..3c91d641991 100644
--- a/tests/Forms/AccessTest.php
+++ b/tests/Forms/AccessTest.php
@@ -6,9 +6,11 @@
use Facades\Statamic\Console\Processes\Composer;
use PHPUnit\Framework\Attributes\Test;
use Statamic\Facades\Blink;
+use Statamic\Facades\Blueprint;
use Statamic\Facades\Form;
use Statamic\Facades\Parse;
use Statamic\Facades\User;
+use Tests\Factories\EntryFactory;
use Tests\PreventSavingStacheItemsToDisk;
use Tests\TestCase;
@@ -50,7 +52,7 @@ protected function makeSubmittableForm(array $data = [])
)->save();
}
- protected function submit($form, $count = 1, $partial = false)
+ protected function submit($form, $count = 1, $partial = false, $entry = null)
{
for ($i = 0; $i < $count; $i++) {
$submission = $form->makeSubmission()->id(Carbon::now()->timestamp - $this->submissionId++);
@@ -59,10 +61,24 @@ protected function submit($form, $count = 1, $partial = false)
$submission->set('partial', true);
}
+ if ($entry) {
+ $submission->set('entry', $entry);
+ }
+
$submission->save();
}
}
+ private function makeEntry(string $id, array $formValue): void
+ {
+ Blueprint::make('event')->setNamespace('collections.events')->setContents(['fields' => [
+ ['handle' => 'title', 'field' => ['type' => 'text']],
+ ['handle' => 'rsvp_form', 'field' => ['type' => 'form', 'max_items' => 1]],
+ ]])->save();
+
+ (new EntryFactory)->collection('events')->id($id)->slug($id)->data(['rsvp_form' => $formValue])->create();
+ }
+
#[Test]
public function a_form_with_no_restrictions_is_not_restricted()
{
@@ -102,6 +118,70 @@ public function it_is_restricted_when_the_submission_limit_is_reached()
$this->assertEquals('This form is no longer accepting submissions.', $form->restrictionMessage());
}
+ #[Test]
+ public function the_submission_limit_is_scoped_per_entry_when_unique_instances_is_enabled()
+ {
+ $form = $this->makeForm(['submission_limit' => 2, 'unique_instances' => true]);
+
+ $this->submit($form, 2, entry: 'event-1');
+ $this->submit($form, 1, entry: 'event-2');
+
+ $this->assertTrue($form->instance('event-1')->restricted());
+ $this->assertEquals('limit_reached', $form->instance('event-1')->status());
+
+ $this->assertFalse($form->instance('event-2')->restricted());
+ $this->assertEquals('open', $form->instance('event-2')->status());
+ }
+
+ #[Test]
+ public function an_entry_can_override_the_submission_limit()
+ {
+ $form = $this->makeForm(['submission_limit' => 5, 'unique_instances' => true]);
+
+ $this->makeEntry('event-1', ['form' => 'contact', 'config' => ['submission_limit' => 1]]);
+
+ $this->submit($form, 1, entry: 'event-1');
+
+ $this->assertTrue($form->instance('event-1')->restricted());
+ $this->assertFalse($form->restricted());
+ }
+
+ #[Test]
+ public function an_entry_can_override_the_close_date_and_message()
+ {
+ $form = $this->makeForm(['unique_instances' => true]);
+
+ $this->makeEntry('event-1', ['form' => 'contact', 'config' => [
+ 'close_date' => '2026-07-01 09:00',
+ 'closed_message' => 'This event is full.',
+ ]]);
+
+ $this->assertTrue($form->instance('event-1')->restricted());
+ $this->assertEquals('This event is full.', $form->instance('event-1')->restrictionMessage());
+ $this->assertFalse($form->restricted());
+ }
+
+ #[Test]
+ public function an_entry_can_override_require_login_off()
+ {
+ $form = $this->makeForm(['require_login' => true, 'unique_instances' => true]);
+
+ $this->makeEntry('event-1', ['form' => 'contact', 'config' => ['require_login' => false]]);
+
+ $this->assertFalse($form->instance('event-1')->restricted());
+ $this->assertTrue($form->restricted());
+ }
+
+ #[Test]
+ public function overrides_from_an_entry_using_a_different_form_are_ignored()
+ {
+ $form = $this->makeForm(['unique_instances' => true]);
+
+ $this->makeEntry('event-1', ['form' => 'another_form', 'config' => ['close_date' => '2026-07-01 09:00']]);
+
+ $this->assertFalse($form->instance('event-1')->restricted());
+ }
+
#[Test]
public function partial_submissions_are_excluded_from_the_limit()
{
diff --git a/tests/Forms/FormFieldValuesTest.php b/tests/Forms/FormFieldValuesTest.php
new file mode 100644
index 00000000000..29551fc0cde
--- /dev/null
+++ b/tests/Forms/FormFieldValuesTest.php
@@ -0,0 +1,100 @@
+setNamespace('collections.events')->setContents(['fields' => [
+ ['handle' => 'title', 'field' => ['type' => 'text']],
+ ['handle' => 'top_form', 'field' => ['type' => 'form', 'max_items' => 1]],
+ ['handle' => 'other_forms', 'field' => ['type' => 'form', 'max_items' => 3]],
+ ['handle' => 'details', 'field' => ['type' => 'group', 'fields' => [
+ ['handle' => 'group_form', 'field' => ['type' => 'form', 'max_items' => 1]],
+ ]]],
+ ['handle' => 'schedule', 'field' => ['type' => 'grid', 'fields' => [
+ ['handle' => 'grid_form', 'field' => ['type' => 'form', 'max_items' => 1]],
+ ]]],
+ ['handle' => 'blocks', 'field' => ['type' => 'replicator', 'sets' => [
+ 'rsvp' => ['fields' => [
+ ['handle' => 'replicator_form', 'field' => ['type' => 'form', 'max_items' => 1]],
+ ['handle' => 'inner', 'field' => ['type' => 'group', 'fields' => [
+ ['handle' => 'inner_form', 'field' => ['type' => 'form', 'max_items' => 1]],
+ ]]],
+ ]],
+ ]]],
+ ['handle' => 'content', 'field' => ['type' => 'bard', 'sets' => [
+ 'rsvp' => ['fields' => [
+ ['handle' => 'bard_form', 'field' => ['type' => 'form', 'max_items' => 1]],
+ ]],
+ ]]],
+ ]])->save();
+
+ return (new EntryFactory)->collection('events')->id('event-1')->slug('event-one')->data($data)->create();
+ }
+
+ #[Test]
+ public function it_finds_form_values_at_every_level()
+ {
+ $entry = $this->makeEntry([
+ 'top_form' => ['form' => 'contact', 'config' => ['submission_limit' => 1]],
+ 'other_forms' => ['newsletter', 'feedback'],
+ 'details' => ['group_form' => 'group_contact'],
+ 'schedule' => [
+ ['grid_form' => 'grid_contact'],
+ ],
+ 'blocks' => [
+ ['type' => 'rsvp', 'replicator_form' => 'replicator_contact', 'inner' => ['inner_form' => 'inner_contact']],
+ ['type' => 'unknown_set', 'replicator_form' => 'ignored'],
+ ],
+ 'content' => [
+ ['type' => 'paragraph', 'content' => []],
+ ['type' => 'set', 'attrs' => ['id' => '1', 'values' => ['type' => 'rsvp', 'bard_form' => 'bard_contact']]],
+ ],
+ ]);
+
+ $this->assertEquals([
+ ['form' => 'contact', 'config' => ['submission_limit' => 1]],
+ ['newsletter', 'feedback'],
+ 'group_contact',
+ 'grid_contact',
+ 'replicator_contact',
+ 'inner_contact',
+ 'bard_contact',
+ ], FormFieldValues::on($entry)->all()->values()->all());
+ }
+
+ #[Test]
+ public function it_finds_values_referencing_a_form()
+ {
+ $entry = $this->makeEntry([
+ 'top_form' => ['form' => 'contact', 'config' => ['submission_limit' => 1]],
+ 'other_forms' => ['newsletter', 'contact'],
+ 'blocks' => [
+ ['type' => 'rsvp', 'replicator_form' => 'feedback'],
+ ],
+ ]);
+
+ $this->assertCount(2, FormFieldValues::on($entry)->referencing('contact'));
+ $this->assertCount(1, FormFieldValues::on($entry)->referencing('feedback'));
+ $this->assertCount(0, FormFieldValues::on($entry)->referencing('missing'));
+ }
+
+ #[Test]
+ public function it_ignores_fields_without_values()
+ {
+ $entry = $this->makeEntry(['title' => 'Event One']);
+
+ $this->assertEquals([], FormFieldValues::on($entry)->all()->all());
+ }
+}
diff --git a/tests/Forms/InstanceTest.php b/tests/Forms/InstanceTest.php
new file mode 100644
index 00000000000..d241c872c6b
--- /dev/null
+++ b/tests/Forms/InstanceTest.php
@@ -0,0 +1,171 @@
+fakeStacheDirectory.'/forms';
+ }
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ Composer::shouldReceive('isInstalled')->with('statamic/forms-pro')->andReturn(true)->byDefault();
+ }
+
+ private function makeForm(array $data = [])
+ {
+ return tap(Form::make('contact')->data(array_merge(['unique_instances' => true], $data)))->save();
+ }
+
+ private function makeEntry(string $id, array $formValue): void
+ {
+ Blueprint::make('event')->setNamespace('collections.events')->setContents(['fields' => [
+ ['handle' => 'title', 'field' => ['type' => 'text']],
+ ['handle' => 'rsvp_form', 'field' => ['type' => 'form', 'max_items' => 1]],
+ ]])->save();
+
+ (new EntryFactory)->collection('events')->id($id)->slug($id)->data(['rsvp_form' => $formValue])->create();
+ }
+
+ #[Test]
+ public function a_form_makes_instances()
+ {
+ $form = $this->makeForm();
+
+ $instance = $form->instance('event-1');
+
+ $this->assertInstanceOf(Instance::class, $instance);
+ $this->assertEquals($form->handle(), $instance->form()->handle());
+ $this->assertEquals('event-1', $instance->entry());
+
+ $this->assertNull($form->instance()->entry());
+ }
+
+ #[Test]
+ public function the_entry_is_dropped_without_unique_instances()
+ {
+ $form = $this->makeForm(['unique_instances' => false]);
+
+ $this->assertNull($form->instance('event-1')->entry());
+ }
+
+ #[Test]
+ public function the_entry_is_dropped_without_forms_pro()
+ {
+ Composer::shouldReceive('isInstalled')->with('statamic/forms-pro')->andReturn(false);
+
+ $form = $this->makeForm();
+
+ $this->assertNull($form->instance('event-1')->entry());
+ }
+
+ #[Test]
+ public function the_default_instance_reads_the_forms_config()
+ {
+ $form = $this->makeForm(['submission_limit' => 5]);
+
+ $this->assertEquals(5, $form->instance()->config('submission_limit'));
+ $this->assertNull($form->instance()->config('close_date'));
+ }
+
+ #[Test]
+ public function an_entry_instance_prefers_the_entrys_overrides()
+ {
+ $form = $this->makeForm(['submission_limit' => 5, 'closed_message' => 'Closed.']);
+
+ $this->makeEntry('event-1', ['form' => 'contact', 'config' => ['submission_limit' => 1]]);
+
+ $instance = $form->instance('event-1');
+
+ $this->assertEquals(1, $instance->config('submission_limit'));
+ $this->assertEquals('Closed.', $instance->config('closed_message'));
+ }
+
+ #[Test]
+ public function a_localization_inherits_overrides_from_its_origin()
+ {
+ $this->setSites([
+ 'en' => ['url' => '/', 'locale' => 'en'],
+ 'fr' => ['url' => '/fr/', 'locale' => 'fr'],
+ ]);
+
+ $form = $this->makeForm(['submission_limit' => 5]);
+
+ $this->makeEntry('event-1', ['form' => 'contact', 'config' => ['submission_limit' => 1]]);
+
+ Collection::findByHandle('events')->sites(['en', 'fr'])->save();
+
+ tap(Entry::find('event-1')->makeLocalization('fr')->id('event-1-fr'))->save();
+
+ $this->assertEquals(1, $form->instance('event-1-fr')->config('submission_limit'));
+ }
+
+ #[Test]
+ public function overrides_are_found_in_nested_fields()
+ {
+ $form = $this->makeForm(['submission_limit' => 5]);
+
+ Blueprint::make('event')->setNamespace('collections.events')->setContents(['fields' => [
+ ['handle' => 'blocks', 'field' => ['type' => 'replicator', 'sets' => [
+ 'rsvp' => ['fields' => [
+ ['handle' => 'rsvp_form', 'field' => ['type' => 'form', 'max_items' => 1]],
+ ]],
+ ]]],
+ ]])->save();
+
+ (new EntryFactory)->collection('events')->id('event-1')->slug('event-1')->data(['blocks' => [
+ ['type' => 'rsvp', 'rsvp_form' => ['form' => 'contact', 'config' => ['submission_limit' => 1]]],
+ ]])->create();
+
+ $this->assertEquals(1, $form->instance('event-1')->config('submission_limit'));
+ }
+
+ #[Test]
+ public function overrides_from_an_entry_using_a_different_form_are_ignored()
+ {
+ $form = $this->makeForm(['submission_limit' => 5]);
+
+ $this->makeEntry('event-1', ['form' => 'another_form', 'config' => ['submission_limit' => 1]]);
+
+ $this->assertEquals(5, $form->instance('event-1')->config('submission_limit'));
+ }
+
+ #[Test]
+ public function an_unconfigured_entry_falls_back_to_the_forms_config()
+ {
+ $form = $this->makeForm(['submission_limit' => 5]);
+
+ $this->makeEntry('event-1', ['form' => 'contact', 'config' => []]);
+
+ $this->assertEquals(5, $form->instance('event-1')->config('submission_limit'));
+ }
+
+ #[Test]
+ public function the_form_delegates_to_its_default_instance()
+ {
+ $form = $this->makeForm(['close_date' => '2020-01-01 09:00']);
+
+ $this->assertEquals('closed', $form->status());
+ $this->assertTrue($form->restricted());
+ $this->assertEquals('This form is no longer accepting submissions.', $form->restrictionMessage());
+ }
+}
diff --git a/tests/Forms/SubmissionTest.php b/tests/Forms/SubmissionTest.php
index f70edf270b0..75f1eb4101b 100644
--- a/tests/Forms/SubmissionTest.php
+++ b/tests/Forms/SubmissionTest.php
@@ -19,6 +19,7 @@
use Statamic\Forms\CreateAssetsFromFileUploads;
use Statamic\Forms\DeleteTemporaryFiles;
use Statamic\Forms\SendEmails;
+use Tests\Factories\EntryFactory;
use Tests\PreventSavingStacheItemsToDisk;
use Tests\TestCase;
@@ -155,6 +156,54 @@ public function setting_data_with_partial_or_site_in_the_payload_overrides_them(
$this->assertEquals('de', $submission->get('site'));
}
+ #[Test]
+ public function setting_data_preserves_the_entry_key()
+ {
+ $form = tap(Form::make('contact_us'))->save();
+
+ $submission = $form->makeSubmission()->set('entry', 'event-1');
+
+ $submission->data(['foo' => 'bar']);
+
+ $this->assertEquals('bar', $submission->get('foo'));
+ $this->assertEquals('event-1', $submission->get('entry'));
+ }
+
+ #[Test]
+ public function the_entry_is_included_in_to_array()
+ {
+ $form = tap(Form::make('contact_us')->formFields([
+ 'sections' => [['fields' => [
+ ['handle' => 'name', 'field' => ['type' => 'text']],
+ ]]],
+ ]))->save();
+
+ $submission = $form->makeSubmission()->data(['name' => 'San Holo']);
+
+ $this->assertArrayNotHasKey('entry', $submission->toArray());
+
+ $submission->set('entry', 'event-1');
+
+ $this->assertEquals('event-1', $submission->toArray()['entry']);
+ }
+
+ #[Test]
+ public function the_entry_is_augmented_to_the_entry_object()
+ {
+ $entry = (new EntryFactory)->collection('events')->id('event-1')->slug('event-one')->create();
+
+ $form = tap(Form::make('contact_us')->formFields([
+ 'sections' => [['fields' => [
+ ['handle' => 'name', 'field' => ['type' => 'text']],
+ ]]],
+ ]))->save();
+
+ $submission = $form->makeSubmission()->data(['name' => 'San Holo'])->set('entry', 'event-1');
+
+ $this->assertEquals($entry->id(), $submission->entry()->id());
+ $this->assertEquals($entry->id(), $submission->augmentedArrayData()['entry']->id());
+ }
+
#[Test]
public function it_saves_a_submission()
{
diff --git a/tests/Forms/SubmitFormTest.php b/tests/Forms/SubmitFormTest.php
index 033f7745d8e..1b49dbafece 100644
--- a/tests/Forms/SubmitFormTest.php
+++ b/tests/Forms/SubmitFormTest.php
@@ -16,12 +16,14 @@
use Statamic\Exceptions\SilentFormFailureException;
use Statamic\Facades\Asset;
use Statamic\Facades\AssetContainer;
+use Statamic\Facades\Blueprint;
use Statamic\Facades\Fieldset;
use Statamic\Facades\Form;
use Statamic\Forms\CreateAssetsFromFileUploads;
use Statamic\Forms\SendEmails;
use Statamic\Forms\SubmissionResult;
use Statamic\Forms\SubmitForm;
+use Tests\Factories\EntryFactory;
use Tests\PreventSavingStacheItemsToDisk;
use Tests\TestCase;
@@ -1166,6 +1168,115 @@ public function it_finalizes_when_page_logic_legitimately_skips_a_page()
$form->submissions()->each->delete();
}
+ #[Test]
+ public function it_attaches_the_entry_when_unique_instances_is_enabled()
+ {
+ $this->makeEntry('event-1', ['form' => 'contact', 'config' => []]);
+
+ $this->form->set('unique_instances', true)->save();
+
+ $result = $this->action()->entry('event-1')->submit(['email' => 'san@holo.com']);
+
+ $this->assertEquals('event-1', $result->submission->get('entry'));
+ }
+
+ #[Test]
+ public function it_rejects_the_submission_when_unique_instances_is_enabled_and_no_entry_is_provided()
+ {
+ $this->form->set('unique_instances', true)->save();
+
+ $this->expectException(ValidationException::class);
+
+ $this->action()->submit(['email' => 'san@holo.com']);
+ }
+
+ #[Test]
+ public function it_rejects_the_submission_when_the_entry_does_not_exist()
+ {
+ $this->form->set('unique_instances', true)->save();
+
+ $this->expectException(ValidationException::class);
+
+ $this->action()->entry('missing')->submit(['email' => 'san@holo.com']);
+ }
+
+ #[Test]
+ public function it_rejects_the_submission_when_the_entry_doesnt_use_the_form()
+ {
+ $this->makeEntry('event-1', ['form' => 'another_form', 'config' => []]);
+
+ $this->form->set('unique_instances', true)->save();
+
+ $this->expectException(ValidationException::class);
+
+ $this->action()->entry('event-1')->submit(['email' => 'san@holo.com']);
+ }
+
+ #[Test]
+ public function it_accepts_an_entry_referencing_the_form_from_a_nested_field()
+ {
+ Blueprint::make('event')->setNamespace('collections.events')->setContents(['fields' => [
+ ['handle' => 'blocks', 'field' => ['type' => 'replicator', 'sets' => [
+ 'rsvp' => ['fields' => [
+ ['handle' => 'rsvp_form', 'field' => ['type' => 'form', 'max_items' => 1]],
+ ]],
+ ]]],
+ ]])->save();
+
+ (new EntryFactory)->collection('events')->id('event-1')->slug('event-one')->data(['blocks' => [
+ ['type' => 'rsvp', 'rsvp_form' => ['form' => 'contact', 'config' => []]],
+ ]])->create();
+
+ $this->form->set('unique_instances', true)->save();
+
+ $result = $this->action()->entry('event-1')->submit(['email' => 'san@holo.com']);
+
+ $this->assertEquals('event-1', $result->submission->get('entry'));
+ }
+
+ #[Test]
+ public function it_ignores_the_entry_when_unique_instances_is_disabled()
+ {
+ (new EntryFactory)->collection('events')->id('event-1')->slug('event-one')->create();
+
+ $result = $this->action()->entry('event-1')->submit(['email' => 'san@holo.com']);
+
+ $this->assertFalse($result->submission->has('entry'));
+ }
+
+ #[Test]
+ public function it_rejects_the_submission_when_an_entry_override_restricts_the_form()
+ {
+ $this->makeEntry('event-1', ['form' => 'contact', 'config' => ['close_date' => '2020-01-01 09:00']]);
+
+ $this->form->set('unique_instances', true)->save();
+
+ $this->expectException(FormRestrictedException::class);
+
+ $this->action()->entry('event-1')->submit(['email' => 'san@holo.com']);
+ }
+
+ #[Test]
+ public function it_ignores_unique_instances_when_forms_pro_is_not_installed()
+ {
+ Composer::shouldReceive('isInstalled')->with('statamic/forms-pro')->andReturnFalse();
+
+ $this->form->set('unique_instances', true)->save();
+
+ $result = $this->action()->submit(['email' => 'san@holo.com']);
+
+ $this->assertFalse($result->submission->has('entry'));
+ }
+
+ private function makeEntry(string $id, array $formValue): void
+ {
+ Blueprint::make('event')->setNamespace('collections.events')->setContents(['fields' => [
+ ['handle' => 'rsvp_form', 'field' => ['type' => 'form', 'max_items' => 1]],
+ ]])->save();
+
+ (new EntryFactory)->collection('events')->id($id)->slug($id)->data(['rsvp_form' => $formValue])->create();
+ }
+
private function uploadForm(bool $honeypot = false)
{
$form = Form::make('uploads');
diff --git a/tests/Query/SubmissionEntryFilterTest.php b/tests/Query/SubmissionEntryFilterTest.php
new file mode 100644
index 00000000000..4cd2605531f
--- /dev/null
+++ b/tests/Query/SubmissionEntryFilterTest.php
@@ -0,0 +1,85 @@
+fakeStacheDirectory.'/forms';
+ }
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ Composer::shouldReceive('isInstalled')->with('statamic/forms-pro')->andReturn(true);
+
+ Form::make('test')->set('unique_instances', true)->save();
+ }
+
+ private function filter(array $context = [])
+ {
+ return Scope::find('submission_entry', array_merge(['form' => 'test'], $context));
+ }
+
+ #[Test]
+ public function it_is_only_visible_when_the_form_has_unique_instances()
+ {
+ $this->assertTrue($this->filter()->visibleTo('form-submissions'));
+ $this->assertFalse($this->filter()->visibleTo('entries'));
+
+ Form::find('test')->set('unique_instances', false)->save();
+
+ $this->assertFalse($this->filter()->visibleTo('form-submissions'));
+ }
+
+ #[Test]
+ public function it_auto_applies_when_the_context_provides_an_entry()
+ {
+ $this->assertEquals(['entry' => 'event-1'], $this->filter(['entry' => 'event-1'])->autoApply());
+ $this->assertEquals([], $this->filter()->autoApply());
+ }
+
+ #[Test]
+ public function it_offers_the_entries_with_submissions_as_options()
+ {
+ (new EntryFactory)->collection('events')->id('event-1')->slug('event-one')->data(['title' => 'Event One'])->create();
+ (new EntryFactory)->collection('events')->id('event-2')->slug('event-two')->data(['title' => 'Event Two'])->create();
+ (new EntryFactory)->collection('events')->id('event-3')->slug('event-three')->data(['title' => 'Event Three'])->create();
+
+ $form = Form::find('test');
+
+ FormSubmission::make()->form($form)->id('1')->data(['entry' => 'event-1'])->save();
+ FormSubmission::make()->form($form)->id('2')->data(['entry' => 'event-1'])->save();
+ FormSubmission::make()->form($form)->id('3')->data(['entry' => 'event-2'])->save();
+ FormSubmission::make()->form($form)->id('4')->data([])->save();
+
+ $this->assertEquals([
+ 'event-1' => 'Event One',
+ 'event-2' => 'Event Two',
+ ], $this->filter()->fieldItems()['entry']['options']);
+ }
+
+ #[Test]
+ public function it_renders_a_badge_with_the_entrys_title()
+ {
+ (new EntryFactory)->collection('events')->id('event-1')->slug('event-one')->data(['title' => 'Event One'])->create();
+
+ $this->assertEquals('Entry: Event One', $this->filter()->badge(['entry' => 'event-1']));
+ $this->assertEquals('Entry: missing', $this->filter()->badge(['entry' => 'missing']));
+ }
+}
diff --git a/tests/Tags/Form/FormCreateTest.php b/tests/Tags/Form/FormCreateTest.php
index 3ce6e072589..c5b4a7b63f5 100644
--- a/tests/Tags/Form/FormCreateTest.php
+++ b/tests/Tags/Form/FormCreateTest.php
@@ -58,6 +58,38 @@ public function it_renders_form_with_redirects_to_anchor()
$this->assertStringContainsString(' ', $output);
}
+ #[Test]
+ public function it_renders_a_hidden_entry_input_when_unique_instances_is_enabled()
+ {
+ Composer::shouldReceive('isInstalled')->with('statamic/forms-pro')->andReturn(true);
+
+ Form::find('contact')->set('unique_instances', true);
+
+ $output = $this->tag('{{ form:contact }}{{ /form:contact }}', ['id' => 'event-1']);
+
+ $this->assertStringContainsString(' ', $output);
+ }
+
+ #[Test]
+ public function it_doesnt_render_a_hidden_entry_input_when_unique_instances_is_disabled()
+ {
+ $output = $this->tag('{{ form:contact }}{{ /form:contact }}', ['id' => 'event-1']);
+
+ $this->assertStringNotContainsString('name="_entry"', $output);
+ }
+
+ #[Test]
+ public function it_doesnt_render_a_hidden_entry_input_outside_an_entry_context()
+ {
+ Composer::shouldReceive('isInstalled')->with('statamic/forms-pro')->andReturn(true);
+
+ Form::find('contact')->set('unique_instances', true);
+
+ $output = $this->tag('{{ form:contact }}{{ /form:contact }}');
+
+ $this->assertStringNotContainsString('name="_entry"', $output);
+ }
+
#[Test]
public function it_dynamically_renders_fields()
{