From e8c885f6491a031c599c62a9d0b0174cc9f2ec8d Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Wed, 2 Sep 2026 14:46:30 +0100 Subject: [PATCH 1/6] expose form `sections` and `pages` in the rest and graphql apis Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Qe1g6yd25kh7oTxtWfzB95 --- src/Forms/AugmentedForm.php | 30 +++++++- src/Forms/Form.php | 19 +++-- src/GraphQL/TypeRegistrar.php | 2 + src/GraphQL/Types/FormPageType.php | 44 +++++++++++ src/GraphQL/Types/FormType.php | 8 +- tests/API/APITest.php | 115 +++++++++++++++++++++++++++++ tests/Feature/GraphQL/FormTest.php | 101 +++++++++++++++++++++++++ 7 files changed, 311 insertions(+), 8 deletions(-) create mode 100644 src/GraphQL/Types/FormPageType.php diff --git a/src/Forms/AugmentedForm.php b/src/Forms/AugmentedForm.php index ed72dcfa11c..aabaf3c91e7 100644 --- a/src/Forms/AugmentedForm.php +++ b/src/Forms/AugmentedForm.php @@ -3,13 +3,15 @@ namespace Statamic\Forms; use Statamic\Data\AbstractAugmented; +use Statamic\Fields\Section; +use Statamic\Fields\Tab; use Statamic\Statamic; class AugmentedForm extends AbstractAugmented { public function keys() { - $keys = ['handle', 'title', 'fields', 'status', 'api_url']; + $keys = ['handle', 'title', 'fields', 'sections', 'pages', 'status', 'api_url']; if (! Statamic::isApiRoute()) { $keys[] = 'honeypot'; @@ -17,4 +19,30 @@ public function keys() return $keys; } + + public function sections(): array + { + return $this->data->sections() + ->map(fn (Section $section) => $this->sectionToArray($section)) + ->all(); + } + + public function pages(): array + { + return $this->data->pages()->map(fn (Tab $page) => [ + 'id' => $page->handle(), + 'display' => $page->display(), + 'instructions' => $page->instructions(), + 'sections' => $page->sections()->map(fn (Section $section) => $this->sectionToArray($section))->all(), + ])->all(); + } + + private function sectionToArray(Section $section): array + { + return [ + 'display' => $section->display(), + 'instructions' => $section->instructions(), + 'fields' => $section->fields()->all()->map->toArray()->all(), + ]; + } } diff --git a/src/Forms/Form.php b/src/Forms/Form.php index 0bbdfb152e3..d59afbdfcbc 100644 --- a/src/Forms/Form.php +++ b/src/Forms/Form.php @@ -4,6 +4,7 @@ use Carbon\Carbon; use Illuminate\Contracts\Support\Arrayable; +use Illuminate\Support\Collection; use Statamic\Contracts\Data\Augmentable; use Statamic\Contracts\Data\Augmented; use Statamic\Contracts\Forms\Form as FormContract; @@ -27,6 +28,7 @@ use Statamic\Facades\User; use Statamic\Facades\YAML; use Statamic\Fields\Blueprint; +use Statamic\Fields\Tab; use Statamic\Forms\Exporters\Exporter; use Statamic\Forms\Fields\FormFields; use Statamic\Statamic; @@ -302,16 +304,21 @@ public function email($emails = null) return $this->fluentlyGetOrSet('email')->args(func_get_args()); } - /** - * Get the form fields off the blueprint. - * - * @return \Illuminate\Support\Collection - */ - public function fields() + public function fields(): Collection { return $this->blueprint()->fields()->all(); } + public function sections(): Collection + { + return $this->pages()->flatMap(fn (Tab $page) => $page->sections()); + } + + public function pages(): Collection + { + return $this->blueprint()->tabs()->values(); + } + /** * Get path. * diff --git a/src/GraphQL/TypeRegistrar.php b/src/GraphQL/TypeRegistrar.php index f176fd0f605..986add386d7 100644 --- a/src/GraphQL/TypeRegistrar.php +++ b/src/GraphQL/TypeRegistrar.php @@ -13,6 +13,7 @@ use Statamic\GraphQL\Types\DateRangeType; use Statamic\GraphQL\Types\EntryInterface; use Statamic\GraphQL\Types\FieldType; +use Statamic\GraphQL\Types\FormPageType; use Statamic\GraphQL\Types\FormType; use Statamic\GraphQL\Types\GlobalSetInterface; use Statamic\GraphQL\Types\JsonArgument; @@ -66,6 +67,7 @@ public function register() GraphQL::addType(GlobalSetInterface::class); GraphQL::addType(FieldType::class); GraphQL::addType(SectionType::class); + GraphQL::addType(FormPageType::class); GraphQL::addType(LinkValueType::class); PageInterface::addTypes(); diff --git a/src/GraphQL/Types/FormPageType.php b/src/GraphQL/Types/FormPageType.php new file mode 100644 index 00000000000..e6ca4b981e8 --- /dev/null +++ b/src/GraphQL/Types/FormPageType.php @@ -0,0 +1,44 @@ + self::NAME, + ]; + + public function fields(): array + { + return [ + 'id' => [ + 'type' => GraphQL::nonNull(GraphQL::string()), + 'resolve' => function ($page) { + return $page->handle(); + }, + ], + 'display' => [ + 'type' => GraphQL::string(), + 'resolve' => function ($page) { + return $page->display(); + }, + ], + 'instructions' => [ + 'type' => GraphQL::string(), + 'resolve' => function ($page) { + return $page->instructions(); + }, + ], + 'sections' => [ + 'type' => GraphQL::listOf(GraphQL::type(SectionType::NAME)), + 'resolve' => function ($page) { + return $page->sections()->all(); + }, + ], + ]; + } +} diff --git a/src/GraphQL/Types/FormType.php b/src/GraphQL/Types/FormType.php index acc924bbc5f..febcded8d47 100644 --- a/src/GraphQL/Types/FormType.php +++ b/src/GraphQL/Types/FormType.php @@ -59,7 +59,13 @@ public function fields(): array 'sections' => [ 'type' => GraphQL::listOf(GraphQL::type(SectionType::NAME)), 'resolve' => function ($form, $args, $context, $info) { - return $form->blueprint()->tabs()->first()->sections()->all(); + return $form->sections()->all(); + }, + ], + 'pages' => [ + 'type' => GraphQL::listOf(GraphQL::type(FormPageType::NAME)), + 'resolve' => function ($form, $args, $context, $info) { + return $form->pages()->all(); }, ], ])->map(function (array $arr) { diff --git a/tests/API/APITest.php b/tests/API/APITest.php index e48647caf0d..d2c2de6284b 100644 --- a/tests/API/APITest.php +++ b/tests/API/APITest.php @@ -2,6 +2,7 @@ namespace Tests\API; +use Facades\Statamic\Console\Processes\Composer; use Facades\Statamic\CP\LivePreview; use Facades\Statamic\Fields\BlueprintRepository; use Illuminate\Support\Facades\Storage; @@ -282,6 +283,120 @@ public function it_only_queries_allowed_asset_containers() $this->assertEndpointSuccessful('/api/asset-containers/avatars'); } + #[Test] + public function it_queries_form_sections() + { + Facades\Config::set('statamic.api.resources.forms', true); + + Facades\Form::make('contact')->title('Contact Us')->formFields([ + 'sections' => [ + [ + 'display' => 'Your Details', + 'instructions' => 'Tell us who you are', + 'fields' => [ + ['handle' => 'name', 'field' => ['type' => 'short_answer', 'display' => 'Your Name']], + ], + ], + [ + 'fields' => [ + ['handle' => 'message', 'field' => ['type' => 'long_answer', 'display' => 'Message', 'width' => 50]], + ], + ], + ], + ])->save(); + + $this + ->get('/api/forms/contact') + ->assertSuccessful() + ->assertJsonPath('data.sections', [ + [ + 'display' => 'Your Details', + 'instructions' => 'Tell us who you are', + 'fields' => [ + 'name' => ['type' => 'text', 'display' => 'Your Name', 'handle' => 'name', 'width' => 100], + ], + ], + [ + 'display' => null, + 'instructions' => null, + 'fields' => [ + 'message' => ['type' => 'textarea', 'display' => 'Message', 'width' => 50, 'handle' => 'message'], + ], + ], + ]); + } + + #[Test] + public function it_queries_form_pages() + { + Composer::shouldReceive('isInstalled')->with('statamic/forms-pro')->andReturn(true); + + Facades\Config::set('statamic.api.resources.forms', true); + + Facades\Form::make('contact')->title('Contact Us')->formFields([ + 'pages' => [ + [ + 'id' => 'about_you', + 'display' => 'About You', + 'instructions' => 'Tell us who you are', + 'sections' => [ + [ + 'display' => 'Your Details', + 'fields' => [ + ['handle' => 'name', 'field' => ['type' => 'short_answer', 'display' => 'Your Name']], + ], + ], + ], + ], + [ + 'id' => 'your_message', + 'sections' => [ + [ + 'fields' => [ + ['handle' => 'message', 'field' => ['type' => 'long_answer', 'display' => 'Message']], + ], + ], + ], + ], + ], + ])->save(); + + $this + ->get('/api/forms/contact') + ->assertSuccessful() + ->assertJsonPath('data.pages', [ + [ + 'id' => 'about_you', + 'display' => 'About You', + 'instructions' => 'Tell us who you are', + 'sections' => [ + [ + 'display' => 'Your Details', + 'instructions' => null, + 'fields' => [ + 'name' => ['type' => 'text', 'display' => 'Your Name', 'handle' => 'name', 'width' => 100], + ], + ], + ], + ], + [ + 'id' => 'your_message', + 'display' => 'Page 2 of 2', + 'instructions' => null, + 'sections' => [ + [ + 'display' => null, + 'instructions' => null, + 'fields' => [ + 'message' => ['type' => 'textarea', 'display' => 'Message', 'handle' => 'message', 'width' => 100], + ], + ], + ], + ], + ]) + ->assertJsonPath('data.sections.*.display', ['Your Details', null]); + } + #[Test] public function it_selects_entry_fields() { diff --git a/tests/Feature/GraphQL/FormTest.php b/tests/Feature/GraphQL/FormTest.php index 7ab49259800..8f3db247a91 100644 --- a/tests/Feature/GraphQL/FormTest.php +++ b/tests/Feature/GraphQL/FormTest.php @@ -3,6 +3,7 @@ namespace Tests\Feature\GraphQL; use Facades\Statamic\API\ResourceAuthorizer; +use Facades\Statamic\Console\Processes\Composer; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\Test; use Statamic\Contracts\GraphQL\CastableToValidationString; @@ -316,6 +317,106 @@ public function it_queries_the_sections() ]]); } + #[Test] + public function it_queries_the_pages() + { + Composer::shouldReceive('isInstalled')->with('statamic/forms-pro')->andReturn(true); + + Form::make('contact')->title('Contact Us')->formFields([ + 'pages' => [ + [ + 'id' => 'about_you', + 'display' => 'About You', + 'instructions' => 'Tell us who you are', + 'sections' => [ + [ + 'display' => 'Your Details', + 'fields' => [ + ['handle' => 'name', 'field' => ['type' => 'short_answer', 'display' => 'Your Name']], + ], + ], + ], + ], + [ + 'id' => 'your_message', + 'sections' => [ + [ + 'fields' => [ + ['handle' => 'message', 'field' => ['type' => 'long_answer', 'display' => 'Message']], + ], + ], + ], + ], + ], + ])->save(); + + $query = <<<'GQL' +{ + form(handle: "contact") { + sections { + display + } + pages { + id + display + instructions + sections { + display + instructions + fields { + handle + type + } + } + } + } +} +GQL; + + $this + ->withoutExceptionHandling() + ->post('/graphql', ['query' => $query]) + ->assertGqlOk() + ->assertExactJson(['data' => [ + 'form' => [ + 'sections' => [ + ['display' => 'Your Details'], + ['display' => null], + ], + 'pages' => [ + [ + 'id' => 'about_you', + 'display' => 'About You', + 'instructions' => 'Tell us who you are', + 'sections' => [ + [ + 'display' => 'Your Details', + 'instructions' => null, + 'fields' => [ + ['handle' => 'name', 'type' => 'text'], + ], + ], + ], + ], + [ + 'id' => 'your_message', + 'display' => 'Page 2 of 2', + 'instructions' => null, + 'sections' => [ + [ + 'display' => null, + 'instructions' => null, + 'fields' => [ + ['handle' => 'message', 'type' => 'textarea'], + ], + ], + ], + ], + ], + ], + ]]); + } + #[Test] public function it_returns_string_based_validation_rules_for_mimes_mimetypes_dimension_size_and_image() { From a9b084a4740201a09c0d9ad64a1589d0820a63df Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Wed, 2 Sep 2026 14:46:31 +0100 Subject: [PATCH 2/6] expose form `require_login` and `restriction_message` in the rest and graphql apis Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Qe1g6yd25kh7oTxtWfzB95 --- src/Forms/AugmentedForm.php | 12 +++++++++- src/GraphQL/Types/FormType.php | 6 +++++ tests/API/APITest.php | 35 ++++++++++++++++++++++++++++++ tests/Feature/GraphQL/FormTest.php | 35 ++++++++++++++++++++++++++++++ 4 files changed, 87 insertions(+), 1 deletion(-) diff --git a/src/Forms/AugmentedForm.php b/src/Forms/AugmentedForm.php index aabaf3c91e7..36541d47366 100644 --- a/src/Forms/AugmentedForm.php +++ b/src/Forms/AugmentedForm.php @@ -11,7 +11,7 @@ class AugmentedForm extends AbstractAugmented { public function keys() { - $keys = ['handle', 'title', 'fields', 'sections', 'pages', 'status', 'api_url']; + $keys = ['handle', 'title', 'fields', 'sections', 'pages', 'status', 'require_login', 'restriction_message', 'api_url']; if (! Statamic::isApiRoute()) { $keys[] = 'honeypot'; @@ -45,4 +45,14 @@ private function sectionToArray(Section $section): array 'fields' => $section->fields()->all()->map->toArray()->all(), ]; } + + public function requireLogin(): bool + { + return (bool) $this->data->get('require_login'); + } + + public function restrictionMessage(): ?string + { + return $this->data->status() === 'open' ? null : $this->data->restrictionMessage(); + } } diff --git a/src/GraphQL/Types/FormType.php b/src/GraphQL/Types/FormType.php index febcded8d47..10f07538825 100644 --- a/src/GraphQL/Types/FormType.php +++ b/src/GraphQL/Types/FormType.php @@ -30,6 +30,12 @@ public function fields(): array 'status' => [ 'type' => GraphQL::nonNull(GraphQL::string()), ], + 'require_login' => [ + 'type' => GraphQL::nonNull(GraphQL::boolean()), + ], + 'restriction_message' => [ + 'type' => GraphQL::string(), + ], 'fields' => [ 'type' => GraphQL::listOf(GraphQL::type(FieldType::NAME)), 'resolve' => function ($form, $args, $context, $info) { diff --git a/tests/API/APITest.php b/tests/API/APITest.php index d2c2de6284b..bb315f2ebb7 100644 --- a/tests/API/APITest.php +++ b/tests/API/APITest.php @@ -5,6 +5,7 @@ use Facades\Statamic\Console\Processes\Composer; use Facades\Statamic\CP\LivePreview; use Facades\Statamic\Fields\BlueprintRepository; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Storage; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; @@ -397,6 +398,40 @@ public function it_queries_form_pages() ->assertJsonPath('data.sections.*.display', ['Your Details', null]); } + #[Test] + public function it_queries_form_access_restrictions() + { + Composer::shouldReceive('isInstalled')->with('statamic/forms-pro')->andReturn(true); + Carbon::setTestNow('2026-07-06 12:00:00'); + + Facades\Config::set('statamic.api.resources.forms', true); + + Facades\Form::make('contact')->save(); + Facades\Form::make('members')->data(['require_login' => true])->save(); + Facades\Form::make('applications')->data(['close_date' => '2026-07-01 09:00', 'closed_message' => 'Applications have closed.'])->save(); + + $this + ->get('/api/forms/contact') + ->assertSuccessful() + ->assertJsonPath('data.status', 'open') + ->assertJsonPath('data.require_login', false) + ->assertJsonPath('data.restriction_message', null); + + $this + ->get('/api/forms/members') + ->assertSuccessful() + ->assertJsonPath('data.status', 'open') + ->assertJsonPath('data.require_login', true) + ->assertJsonPath('data.restriction_message', null); + + $this + ->get('/api/forms/applications') + ->assertSuccessful() + ->assertJsonPath('data.status', 'closed') + ->assertJsonPath('data.require_login', false) + ->assertJsonPath('data.restriction_message', 'Applications have closed.'); + } + #[Test] public function it_selects_entry_fields() { diff --git a/tests/Feature/GraphQL/FormTest.php b/tests/Feature/GraphQL/FormTest.php index 8f3db247a91..7ead2c867cd 100644 --- a/tests/Feature/GraphQL/FormTest.php +++ b/tests/Feature/GraphQL/FormTest.php @@ -4,6 +4,7 @@ use Facades\Statamic\API\ResourceAuthorizer; use Facades\Statamic\Console\Processes\Composer; +use Illuminate\Support\Carbon; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\Test; use Statamic\Contracts\GraphQL\CastableToValidationString; @@ -417,6 +418,40 @@ public function it_queries_the_pages() ]]); } + #[Test] + public function it_queries_the_access_restrictions() + { + Composer::shouldReceive('isInstalled')->with('statamic/forms-pro')->andReturn(true); + Carbon::setTestNow('2026-07-06 12:00:00'); + + Form::make('contact')->save(); + Form::make('members')->data(['require_login' => true])->save(); + Form::make('applications')->data(['close_date' => '2026-07-01 09:00', 'closed_message' => 'Applications have closed.'])->save(); + + $query = <<<'GQL' +{ + forms { + handle + status + require_login + restriction_message + } +} +GQL; + + $this + ->withoutExceptionHandling() + ->post('/graphql', ['query' => $query]) + ->assertGqlOk() + ->assertExactJson(['data' => [ + 'forms' => [ + ['handle' => 'applications', 'status' => 'closed', 'require_login' => false, 'restriction_message' => 'Applications have closed.'], + ['handle' => 'contact', 'status' => 'open', 'require_login' => false, 'restriction_message' => null], + ['handle' => 'members', 'status' => 'open', 'require_login' => true, 'restriction_message' => null], + ], + ]]); + } + #[Test] public function it_returns_string_based_validation_rules_for_mimes_mimetypes_dimension_size_and_image() { From 440a4f42c646012cc3fc1e256167e4240adfb06f Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Wed, 21 Oct 2026 14:03:19 -0400 Subject: [PATCH 3/6] Expose closed and login messages in the APIs instead of the restriction message The restriction message depends on who is logged in, which an API request can't know. Expose the configured closed_message and require_login_message so headless frontends can decide what to show from status and require_login. Co-Authored-By: Claude Opus 5.5 --- src/Forms/AugmentedForm.php | 7 +----- src/Forms/Form.php | 14 ++++++++++-- src/GraphQL/Types/FormType.php | 7 ++++-- tests/API/APITest.php | 24 ++++++++++++++++---- tests/Feature/GraphQL/FormTest.php | 35 +++++++++++++++++++++++++----- 5 files changed, 68 insertions(+), 19 deletions(-) diff --git a/src/Forms/AugmentedForm.php b/src/Forms/AugmentedForm.php index 36541d47366..3f0ec1e37b4 100644 --- a/src/Forms/AugmentedForm.php +++ b/src/Forms/AugmentedForm.php @@ -11,7 +11,7 @@ class AugmentedForm extends AbstractAugmented { public function keys() { - $keys = ['handle', 'title', 'fields', 'sections', 'pages', 'status', 'require_login', 'restriction_message', 'api_url']; + $keys = ['handle', 'title', 'fields', 'sections', 'pages', 'status', 'require_login', 'closed_message', 'require_login_message', 'api_url']; if (! Statamic::isApiRoute()) { $keys[] = 'honeypot'; @@ -50,9 +50,4 @@ public function requireLogin(): bool { return (bool) $this->data->get('require_login'); } - - public function restrictionMessage(): ?string - { - return $this->data->status() === 'open' ? null : $this->data->restrictionMessage(); - } } diff --git a/src/Forms/Form.php b/src/Forms/Form.php index 7284005c5d9..33bda836f7c 100644 --- a/src/Forms/Form.php +++ b/src/Forms/Form.php @@ -562,16 +562,26 @@ public function restricted(): bool public function restrictionMessage(): ?string { if ($this->closingDateHasPassed() || $this->submissionLimitReached()) { - return ($msg = $this->get('closed_message')) ? __($msg) : __('statamic::messages.form_closed_message'); + return $this->closedMessage(); } if ($this->get('require_login') && ! User::current()) { - return ($msg = $this->get('require_login_message')) ? __($msg) : __('statamic::messages.form_require_login_message'); + return $this->requireLoginMessage(); } return null; } + public function closedMessage(): string + { + return ($msg = $this->get('closed_message')) ? __($msg) : __('statamic::messages.form_closed_message'); + } + + public function requireLoginMessage(): string + { + return ($msg = $this->get('require_login_message')) ? __($msg) : __('statamic::messages.form_require_login_message'); + } + private function closingDateHasPassed(): bool { if (! $date = $this->get('close_date')) { diff --git a/src/GraphQL/Types/FormType.php b/src/GraphQL/Types/FormType.php index 10f07538825..cb69b0dd3b4 100644 --- a/src/GraphQL/Types/FormType.php +++ b/src/GraphQL/Types/FormType.php @@ -33,8 +33,11 @@ public function fields(): array 'require_login' => [ 'type' => GraphQL::nonNull(GraphQL::boolean()), ], - 'restriction_message' => [ - 'type' => GraphQL::string(), + 'closed_message' => [ + 'type' => GraphQL::nonNull(GraphQL::string()), + ], + 'require_login_message' => [ + 'type' => GraphQL::nonNull(GraphQL::string()), ], 'fields' => [ 'type' => GraphQL::listOf(GraphQL::type(FieldType::NAME)), diff --git a/tests/API/APITest.php b/tests/API/APITest.php index bb315f2ebb7..cac2e4f9b7f 100644 --- a/tests/API/APITest.php +++ b/tests/API/APITest.php @@ -407,29 +407,45 @@ public function it_queries_form_access_restrictions() Facades\Config::set('statamic.api.resources.forms', true); Facades\Form::make('contact')->save(); - Facades\Form::make('members')->data(['require_login' => true])->save(); + Facades\Form::make('members')->data(['require_login' => true, 'require_login_message' => 'Members only.'])->save(); Facades\Form::make('applications')->data(['close_date' => '2026-07-01 09:00', 'closed_message' => 'Applications have closed.'])->save(); + $limited = tap(Facades\Form::make('limited')->data(['submission_limit' => 1]))->save(); + $limited->makeSubmission()->save(); $this ->get('/api/forms/contact') ->assertSuccessful() ->assertJsonPath('data.status', 'open') ->assertJsonPath('data.require_login', false) - ->assertJsonPath('data.restriction_message', null); + ->assertJsonPath('data.closed_message', 'This form is no longer accepting submissions.') + ->assertJsonPath('data.require_login_message', 'You must be logged in to submit this form.') + ->assertJsonMissingPath('data.restriction_message'); $this ->get('/api/forms/members') ->assertSuccessful() ->assertJsonPath('data.status', 'open') ->assertJsonPath('data.require_login', true) - ->assertJsonPath('data.restriction_message', null); + ->assertJsonPath('data.closed_message', 'This form is no longer accepting submissions.') + ->assertJsonPath('data.require_login_message', 'Members only.') + ->assertJsonMissingPath('data.restriction_message'); $this ->get('/api/forms/applications') ->assertSuccessful() ->assertJsonPath('data.status', 'closed') ->assertJsonPath('data.require_login', false) - ->assertJsonPath('data.restriction_message', 'Applications have closed.'); + ->assertJsonPath('data.closed_message', 'Applications have closed.') + ->assertJsonPath('data.require_login_message', 'You must be logged in to submit this form.') + ->assertJsonMissingPath('data.restriction_message'); + + $this + ->get('/api/forms/limited') + ->assertSuccessful() + ->assertJsonPath('data.status', 'limit_reached') + ->assertJsonPath('data.require_login', false) + ->assertJsonPath('data.closed_message', 'This form is no longer accepting submissions.') + ->assertJsonMissingPath('data.restriction_message'); } #[Test] diff --git a/tests/Feature/GraphQL/FormTest.php b/tests/Feature/GraphQL/FormTest.php index b83723421f0..a739d1ad839 100644 --- a/tests/Feature/GraphQL/FormTest.php +++ b/tests/Feature/GraphQL/FormTest.php @@ -427,8 +427,10 @@ public function it_queries_the_access_restrictions() Carbon::setTestNow('2026-07-06 12:00:00'); Form::make('contact')->save(); - Form::make('members')->data(['require_login' => true])->save(); + Form::make('members')->data(['require_login' => true, 'require_login_message' => 'Members only.'])->save(); Form::make('applications')->data(['close_date' => '2026-07-01 09:00', 'closed_message' => 'Applications have closed.'])->save(); + $limited = tap(Form::make('limited')->data(['submission_limit' => 1]))->save(); + $limited->makeSubmission()->save(); $query = <<<'GQL' { @@ -436,24 +438,47 @@ public function it_queries_the_access_restrictions() handle status require_login - restriction_message + closed_message + require_login_message } } GQL; + $closed = 'This form is no longer accepting submissions.'; + $login = 'You must be logged in to submit this form.'; + $this ->withoutExceptionHandling() ->post('/graphql', ['query' => $query]) ->assertGqlOk() ->assertExactJson(['data' => [ 'forms' => [ - ['handle' => 'applications', 'status' => 'closed', 'require_login' => false, 'restriction_message' => 'Applications have closed.'], - ['handle' => 'contact', 'status' => 'open', 'require_login' => false, 'restriction_message' => null], - ['handle' => 'members', 'status' => 'open', 'require_login' => true, 'restriction_message' => null], + ['handle' => 'applications', 'status' => 'closed', 'require_login' => false, 'closed_message' => 'Applications have closed.', 'require_login_message' => $login], + ['handle' => 'contact', 'status' => 'open', 'require_login' => false, 'closed_message' => $closed, 'require_login_message' => $login], + ['handle' => 'limited', 'status' => 'limit_reached', 'require_login' => false, 'closed_message' => $closed, 'require_login_message' => $login], + ['handle' => 'members', 'status' => 'open', 'require_login' => true, 'closed_message' => $closed, 'require_login_message' => 'Members only.'], ], ]]); } + #[Test] + public function it_cannot_query_the_restriction_message() + { + Form::make('contact')->save(); + + $query = <<<'GQL' +{ + form(handle: "contact") { + restriction_message + } +} +GQL; + + $this + ->post('/graphql', ['query' => $query]) + ->assertSee('Cannot query field \"restriction_message\" on type \"Form\"', false); + } + #[Test] public function it_returns_string_based_validation_rules_for_mimes_mimetypes_dimension_size_and_image() { From 61023c70e27c19bc154e114daa422b3b8680ca82 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Wed, 21 Oct 2026 14:03:22 -0400 Subject: [PATCH 4/6] Return configured page titles in the APIs Untitled pages were returned with the control panel's "Page X of Y" placeholder. Return null instead, and cover the single-page shape. Co-Authored-By: Claude Opus 5.5 --- src/Forms/Form.php | 7 +++- tests/API/APITest.php | 8 +++-- tests/Feature/GraphQL/FormTest.php | 52 +++++++++++++++++++++++++++++- 3 files changed, 63 insertions(+), 4 deletions(-) diff --git a/src/Forms/Form.php b/src/Forms/Form.php index 33bda836f7c..82e0b76af60 100644 --- a/src/Forms/Form.php +++ b/src/Forms/Form.php @@ -365,7 +365,12 @@ public function sections(): Collection public function pages(): Collection { - return $this->blueprint()->tabs()->values(); + $pages = $this->formFields()->pages()->keyBy('id'); + + return $this->blueprint()->tabs()->values()->map(fn (Tab $tab) => (new Tab($tab->handle()))->setContents([ + ...$tab->contents(), + 'display' => $pages->has($tab->handle()) ? ($pages->get($tab->handle())['display'] ?? null) : $tab->display(), + ])); } /** diff --git a/tests/API/APITest.php b/tests/API/APITest.php index cac2e4f9b7f..6a6b9d1fb4b 100644 --- a/tests/API/APITest.php +++ b/tests/API/APITest.php @@ -324,7 +324,11 @@ public function it_queries_form_sections() 'message' => ['type' => 'textarea', 'display' => 'Message', 'width' => 50, 'handle' => 'message'], ], ], - ]); + ]) + ->assertJsonCount(1, 'data.pages') + ->assertJsonPath('data.pages.0.id', 'main') + ->assertJsonPath('data.pages.0.display', null) + ->assertJsonPath('data.pages.0.sections.*.display', ['Your Details', null]); } #[Test] @@ -382,7 +386,7 @@ public function it_queries_form_pages() ], [ 'id' => 'your_message', - 'display' => 'Page 2 of 2', + 'display' => null, 'instructions' => null, 'sections' => [ [ diff --git a/tests/Feature/GraphQL/FormTest.php b/tests/Feature/GraphQL/FormTest.php index a739d1ad839..32e51a3f4d3 100644 --- a/tests/Feature/GraphQL/FormTest.php +++ b/tests/Feature/GraphQL/FormTest.php @@ -403,7 +403,7 @@ public function it_queries_the_pages() ], [ 'id' => 'your_message', - 'display' => 'Page 2 of 2', + 'display' => null, 'instructions' => null, 'sections' => [ [ @@ -420,6 +420,56 @@ public function it_queries_the_pages() ]]); } + #[Test] + public function it_queries_the_pages_of_a_single_page_form() + { + Form::make('contact')->formFields([ + 'sections' => [ + [ + 'display' => 'Your Details', + 'fields' => [ + ['handle' => 'name', 'field' => ['type' => 'short_answer']], + ], + ], + ], + ])->save(); + + $query = <<<'GQL' +{ + form(handle: "contact") { + pages { + id + display + sections { + display + fields { + handle + } + } + } + } +} +GQL; + + $this + ->withoutExceptionHandling() + ->post('/graphql', ['query' => $query]) + ->assertGqlOk() + ->assertExactJson(['data' => [ + 'form' => [ + 'pages' => [ + [ + 'id' => 'main', + 'display' => null, + 'sections' => [ + ['display' => 'Your Details', 'fields' => [['handle' => 'name']]], + ], + ], + ], + ], + ]]); + } + #[Test] public function it_queries_the_access_restrictions() { From 2db5cb9553035b775a73ebb10fe008829b793ce7 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Wed, 21 Oct 2026 14:03:26 -0400 Subject: [PATCH 5/6] Remove the return type from Form::fields() Co-Authored-By: Claude Opus 5.5 --- src/Forms/Form.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Forms/Form.php b/src/Forms/Form.php index 82e0b76af60..43d9d478fca 100644 --- a/src/Forms/Form.php +++ b/src/Forms/Form.php @@ -353,7 +353,12 @@ public function charts($charts = null) return $this->fluentlyGetOrSet('charts')->args(func_get_args()); } - public function fields(): Collection + /** + * Get the form fields off the blueprint. + * + * @return \Illuminate\Support\Collection + */ + public function fields() { return $this->blueprint()->fields()->all(); } From ecff4ba0dba051e80a8f3d76bbb5ebeb170a68d3 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Wed, 21 Oct 2026 17:48:12 -0400 Subject: [PATCH 6/6] Expand imported fieldset sections in the API form sections and pages Co-Authored-By: Claude Opus 5.5 --- src/Forms/AugmentedForm.php | 2 +- src/Forms/Form.php | 2 +- src/GraphQL/Types/FormPageType.php | 2 +- tests/API/APITest.php | 74 ++++++++++++++++++++++++++++++ tests/Feature/GraphQL/FormTest.php | 74 ++++++++++++++++++++++++++++++ 5 files changed, 151 insertions(+), 3 deletions(-) diff --git a/src/Forms/AugmentedForm.php b/src/Forms/AugmentedForm.php index 3f0ec1e37b4..d1b3563b9b6 100644 --- a/src/Forms/AugmentedForm.php +++ b/src/Forms/AugmentedForm.php @@ -33,7 +33,7 @@ public function pages(): array 'id' => $page->handle(), 'display' => $page->display(), 'instructions' => $page->instructions(), - 'sections' => $page->sections()->map(fn (Section $section) => $this->sectionToArray($section))->all(), + 'sections' => $page->expandedSections()->map(fn (Section $section) => $this->sectionToArray($section))->all(), ])->all(); } diff --git a/src/Forms/Form.php b/src/Forms/Form.php index 43d9d478fca..d7cb72311f0 100644 --- a/src/Forms/Form.php +++ b/src/Forms/Form.php @@ -365,7 +365,7 @@ public function fields() public function sections(): Collection { - return $this->pages()->flatMap(fn (Tab $page) => $page->sections()); + return $this->pages()->flatMap(fn (Tab $page) => $page->expandedSections()); } public function pages(): Collection diff --git a/src/GraphQL/Types/FormPageType.php b/src/GraphQL/Types/FormPageType.php index e6ca4b981e8..4108e937918 100644 --- a/src/GraphQL/Types/FormPageType.php +++ b/src/GraphQL/Types/FormPageType.php @@ -36,7 +36,7 @@ public function fields(): array 'sections' => [ 'type' => GraphQL::listOf(GraphQL::type(SectionType::NAME)), 'resolve' => function ($page) { - return $page->sections()->all(); + return $page->expandedSections()->all(); }, ], ]; diff --git a/tests/API/APITest.php b/tests/API/APITest.php index 6a6b9d1fb4b..3b690f52fa9 100644 --- a/tests/API/APITest.php +++ b/tests/API/APITest.php @@ -331,6 +331,80 @@ public function it_queries_form_sections() ->assertJsonPath('data.pages.0.sections.*.display', ['Your Details', null]); } + #[Test] + public function it_expands_imported_fieldset_sections_in_form_sections_and_pages() + { + Facades\Config::set('statamic.api.resources.forms', true); + + Facades\Fieldset::make('address')->setContents([ + 'sections' => [ + [ + 'display' => 'Street', + 'fields' => [ + ['handle' => 'street', 'field' => ['type' => 'text']], + ], + ], + [ + 'display' => 'Location', + 'instructions' => 'Where are you?', + 'fields' => [ + ['handle' => 'city', 'field' => ['type' => 'text']], + ], + ], + ], + ])->save(); + + Facades\Form::make('contact')->formFields([ + 'sections' => [ + [ + 'display' => 'Your Details', + 'fields' => [ + ['handle' => 'name', 'field' => ['type' => 'short_answer']], + ['import' => 'address', 'prefix' => 'home_', 'config' => ['city' => ['display' => 'Home City']]], + ['handle' => 'email', 'field' => ['type' => 'short_answer']], + ], + ], + ], + ])->save(); + + $expected = [ + [ + 'display' => 'Your Details', + 'instructions' => null, + 'fields' => [ + 'name' => ['type' => 'text', 'handle' => 'name', 'width' => 100], + ], + ], + [ + 'display' => 'Street', + 'instructions' => null, + 'fields' => [ + 'home_street' => ['type' => 'text', 'handle' => 'home_street', 'width' => 100], + ], + ], + [ + 'display' => 'Location', + 'instructions' => 'Where are you?', + 'fields' => [ + 'home_city' => ['type' => 'text', 'display' => 'Home City', 'handle' => 'home_city', 'width' => 100], + ], + ], + [ + 'display' => 'Your Details', + 'instructions' => null, + 'fields' => [ + 'email' => ['type' => 'text', 'handle' => 'email', 'width' => 100], + ], + ], + ]; + + $this + ->get('/api/forms/contact') + ->assertSuccessful() + ->assertJsonPath('data.sections', $expected) + ->assertJsonPath('data.pages.0.sections', $expected); + } + #[Test] public function it_queries_form_pages() { diff --git a/tests/Feature/GraphQL/FormTest.php b/tests/Feature/GraphQL/FormTest.php index 32e51a3f4d3..38273b7b7be 100644 --- a/tests/Feature/GraphQL/FormTest.php +++ b/tests/Feature/GraphQL/FormTest.php @@ -10,6 +10,7 @@ use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\Test; use Statamic\Contracts\GraphQL\CastableToValidationString; +use Statamic\Facades\Fieldset; use Statamic\Facades\Form; use Tests\PreventSavingStacheItemsToDisk; use Tests\TestCase; @@ -470,6 +471,79 @@ public function it_queries_the_pages_of_a_single_page_form() ]]); } + #[Test] + public function it_expands_imported_fieldset_sections() + { + Fieldset::make('address')->setContents([ + 'sections' => [ + [ + 'display' => 'Street', + 'fields' => [ + ['handle' => 'street', 'field' => ['type' => 'text']], + ], + ], + [ + 'display' => 'Location', + 'fields' => [ + ['handle' => 'city', 'field' => ['type' => 'text']], + ], + ], + ], + ])->save(); + + Form::make('contact')->formFields([ + 'sections' => [ + [ + 'display' => 'Your Details', + 'fields' => [ + ['handle' => 'name', 'field' => ['type' => 'short_answer']], + ['import' => 'address', 'prefix' => 'home_'], + ], + ], + ], + ])->save(); + + $query = <<<'GQL' +{ + form(handle: "contact") { + sections { + display + fields { + handle + } + } + pages { + sections { + display + fields { + handle + } + } + } + } +} +GQL; + + $expected = [ + ['display' => 'Your Details', 'fields' => [['handle' => 'name']]], + ['display' => 'Street', 'fields' => [['handle' => 'home_street']]], + ['display' => 'Location', 'fields' => [['handle' => 'home_city']]], + ]; + + $this + ->withoutExceptionHandling() + ->post('/graphql', ['query' => $query]) + ->assertGqlOk() + ->assertExactJson(['data' => [ + 'form' => [ + 'sections' => $expected, + 'pages' => [ + ['sections' => $expected], + ], + ], + ]]); + } + #[Test] public function it_queries_the_access_restrictions() {