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" > + + 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 @@ 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() {