diff --git a/src/Forms/AugmentedForm.php b/src/Forms/AugmentedForm.php index ed72dcfa11c..36541d47366 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', 'require_login', 'restriction_message', 'api_url']; if (! Statamic::isApiRoute()) { $keys[] = 'honeypot'; @@ -17,4 +19,40 @@ 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(), + ]; + } + + 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 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..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) { @@ -59,7 +65,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..bb315f2ebb7 100644 --- a/tests/API/APITest.php +++ b/tests/API/APITest.php @@ -2,8 +2,10 @@ namespace Tests\API; +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; @@ -282,6 +284,154 @@ 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_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 7ab49259800..7ead2c867cd 100644 --- a/tests/Feature/GraphQL/FormTest.php +++ b/tests/Feature/GraphQL/FormTest.php @@ -3,6 +3,8 @@ namespace Tests\Feature\GraphQL; 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; @@ -316,6 +318,140 @@ 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_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() {