diff --git a/src/Console/Commands/FieldtypesList.php b/src/Console/Commands/FieldtypesList.php new file mode 100644 index 00000000000..5a92f281ad4 --- /dev/null +++ b/src/Console/Commands/FieldtypesList.php @@ -0,0 +1,42 @@ +all(); + + if ($this->option('json')) { + $this->output->writeln( + json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR), + OutputInterface::OUTPUT_RAW + ); + + return self::SUCCESS; + } + + $this->components->info('Statamic '.$data['statamic_version']); + + $this->table(['Handle', 'Title', 'Selectable', 'Class'], array_map(fn ($fieldtype) => [ + $fieldtype['handle'], + $fieldtype['title'], + $fieldtype['selectable'] ? 'Yes' : 'No', + $fieldtype['class'], + ], $data['fieldtypes'])); + + return self::SUCCESS; + } +} diff --git a/src/Console/Commands/FieldtypesShow.php b/src/Console/Commands/FieldtypesShow.php new file mode 100644 index 00000000000..2cf20494a5c --- /dev/null +++ b/src/Console/Commands/FieldtypesShow.php @@ -0,0 +1,54 @@ +show($this->argument('handle')); + } catch (FieldtypeNotFoundException $exception) { + if ($this->option('json')) { + $this->output->writeln( + json_encode(['error' => $exception->getMessage()], JSON_THROW_ON_ERROR), + OutputInterface::OUTPUT_RAW + ); + } else { + $this->components->error($exception->getMessage()); + } + + return self::FAILURE; + } + + $this->output->writeln( + $this->option('json') + ? json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) + : Yaml::dump($data, 10, 2), + OutputInterface::OUTPUT_RAW + ); + + return self::SUCCESS; + } +} diff --git a/src/Fields/FieldtypeInspector.php b/src/Fields/FieldtypeInspector.php new file mode 100644 index 00000000000..2ba8978f736 --- /dev/null +++ b/src/Fields/FieldtypeInspector.php @@ -0,0 +1,111 @@ + Statamic::version(), + 'fieldtypes' => $this->fieldtypes->classes() + ->sortKeys() + ->map(fn ($class, $handle) => $this->metadata($handle, app($class))) + ->values()->all(), + ]; + } + + public function show(string $handle): array + { + $fieldtype = $this->fieldtypes->find($handle); + + $this->unresolved = []; + $config = $this->definitions($fieldtype->configFields(), ['config']); + $common = $this->definitions(Field::commonFieldOptions(), ['common']); + + return [ + 'statamic_version' => Statamic::version(), + 'fieldtype' => $this->metadata($handle, $fieldtype), + 'config' => $config, + 'common' => $common, + 'unresolved' => $this->unresolved, + ]; + } + + private function metadata(string $handle, Fieldtype $fieldtype): array + { + return [ + 'handle' => $handle, + 'class' => $fieldtype::class, + 'title' => $fieldtype->title(), + 'selectable' => $fieldtype->selectable(), + 'selectable_in_forms' => $fieldtype->selectableInForms(), + 'localizable' => $fieldtype->localizable(), + 'validatable' => $fieldtype->validatable(), + 'defaultable' => $fieldtype->defaultable(), + ]; + } + + private function definitions(Fields $fields, array $path): array + { + return $fields->all() + ->map(fn (Field $field, $handle) => $this->definition($field, [...$path, $handle])) + ->all(); + } + + private function definition(Field $field, array $path): array + { + $definition = $field->config(); + $editorType = $definition['type'] ?? null; + unset($definition['type']); + + $definition = ['editor_type' => $editorType] + $definition; + + if ( + ! array_key_exists('options', $definition) + && in_array($editorType, ['bard_buttons_setting', 'markdown_buttons_setting'], true) + ) { + $this->unresolved[] = ['path' => [...$path, 'options'], 'source' => 'editor']; + } + + return $this->normalize($definition, $path); + } + + private function normalize(mixed $value, array $path): mixed + { + if ($value instanceof BackedEnum) { + return $value->value; + } + + if ($value instanceof Collection) { + $value = $value->all(); + } + + if (is_array($value)) { + return collect($value) + ->map(fn ($item, $key) => $this->normalize($item, [...$path, $key])) + ->all(); + } + + if (is_object($value) || is_resource($value)) { + $this->unresolved[] = ['path' => $path, 'source' => 'php', 'php_type' => get_debug_type($value)]; + + return null; + } + + return $value; + } +} diff --git a/src/Providers/ConsoleServiceProvider.php b/src/Providers/ConsoleServiceProvider.php index 8d058c405ed..a7cdbc8fb70 100644 --- a/src/Providers/ConsoleServiceProvider.php +++ b/src/Providers/ConsoleServiceProvider.php @@ -15,6 +15,8 @@ class ConsoleServiceProvider extends ServiceProvider Commands\AssetsGeneratePresets::class, Commands\AssetsMeta::class, Commands\AssetsMetaClean::class, + Commands\FieldtypesList::class, + Commands\FieldtypesShow::class, Commands\GlideClear::class, Commands\Install::class, Commands\InstallCollaboration::class, diff --git a/tests/Console/Commands/FieldtypesListTest.php b/tests/Console/Commands/FieldtypesListTest.php new file mode 100644 index 00000000000..eba4c62c865 --- /dev/null +++ b/tests/Console/Commands/FieldtypesListTest.php @@ -0,0 +1,62 @@ +put('test_listed', ListedFieldtype::class); + app('statamic.fieldtypes')->put('test_unconfigured', UnconfiguredFieldtype::class); + + $this->assertSame(0, Artisan::call('statamic:fieldtypes:list', ['--json' => true])); + + $data = json_decode(Artisan::output(), true, 512, JSON_THROW_ON_ERROR); + $handles = array_column($data['fieldtypes'], 'handle'); + + $this->assertSame('3.0.0-testing', $data['statamic_version']); + $this->assertContains('bard', $handles); + $this->assertContains('markdown', $handles); + $this->assertContains('test_listed', $handles); + $this->assertContains('test_unconfigured', $handles); + + $sorted = $handles; + sort($sorted); + $this->assertSame($sorted, $handles); + + $fieldtype = collect($data['fieldtypes'])->firstWhere('handle', 'test_listed'); + $this->assertSame(ListedFieldtype::class, $fieldtype['class']); + $this->assertSame('Listed', $fieldtype['title']); + $this->assertFalse($fieldtype['selectable']); + } + + #[Test] + public function it_can_list_fieldtypes_without_json() + { + $this->artisan('statamic:fieldtypes:list') + ->expectsOutputToContain('bard') + ->expectsOutputToContain('markdown') + ->assertSuccessful(); + } +} + +class ListedFieldtype extends Fieldtype +{ + protected static $title = 'Listed'; + + protected $selectable = false; +} + +class UnconfiguredFieldtype extends Fieldtype +{ + protected function configFieldItems(): array + { + throw new \RuntimeException('Listing must not evaluate configuration.'); + } +} diff --git a/tests/Console/Commands/FieldtypesShowTest.php b/tests/Console/Commands/FieldtypesShowTest.php new file mode 100644 index 00000000000..9af024dbbcd --- /dev/null +++ b/tests/Console/Commands/FieldtypesShowTest.php @@ -0,0 +1,164 @@ +commandJson(['handle' => 'grid']); + + $this->assertSame('3.0.0-testing', $data['statamic_version']); + $this->assertSame('grid', $data['fieldtype']['handle']); + $this->assertSame('Statamic\\Fieldtypes\\Grid', $data['fieldtype']['class']); + $this->assertSame('table', $data['config']['mode']['default']); + $this->assertSame(['table' => 'Table', 'stacked' => 'Stacked'], $data['config']['mode']['options']); + $this->assertSame('select', $data['config']['mode']['editor_type']); + $this->assertArrayNotHasKey('type', $data['config']['mode']); + $this->assertArrayNotHasKey('notes', $data); + $this->assertArrayHasKey('fields', $data['config']); + $this->assertSame('visible', $data['common']['visibility']['default']); + $this->assertArrayHasKey('instructions', $data['common']); + $this->assertArrayNotHasKey('mode', $data['common']); + $this->assertSame([ + ['path' => ['common', 'handle', 'validate', 1], 'source' => 'php', 'php_type' => 'Statamic\\Rules\\Handle'], + ], $data['unresolved']); + } + + #[Test] + public function it_includes_bard_toolbar_defaults_and_conditional_configuration() + { + $data = $this->commandJson(['handle' => 'bard']); + + $this->assertContains('bold', $data['config']['buttons']['default']); + $this->assertSame('bard_buttons_setting', $data['config']['buttons']['editor_type']); + $this->assertArrayNotHasKey('options', $data['config']['buttons']); + $this->assertContains(['path' => ['config', 'buttons', 'options'], 'source' => 'editor'], $data['unresolved']); + $this->assertSame(['buttons' => 'contains_any anchor, image'], $data['config']['container']['if']); + $this->assertSame('fixed', $data['config']['toolbar_mode']['default']); + + $data = $this->commandJson(['handle' => 'markdown']); + + $this->assertSame('markdown_buttons_setting', $data['config']['buttons']['editor_type']); + $this->assertArrayNotHasKey('options', $data['config']['buttons']); + $this->assertContains(['path' => ['config', 'buttons', 'options'], 'source' => 'editor'], $data['unresolved']); + } + + #[Test] + public function it_includes_addon_configuration_and_marks_unresolved_values_without_running_them() + { + app('statamic.fieldtypes')->put('test_inspection', InspectionFieldtype::class); + InspectionFieldtype::appendConfigField('extra', ['type' => 'toggle', 'default' => false]); + + $data = $this->commandJson(['handle' => 'test_inspection']); + + $this->assertSame(['editor_type' => 'toggle', 'default' => false], $data['config']['extra']); + $this->assertSame(['enabled' => 'Enabled'], $data['config']['mode']['options']); + $this->assertSame(['mode' => 'enabled'], $data['config']['limit']['if']); + $this->assertSame(0, $data['config']['limit']['default']); + $this->assertNull($data['config']['optional']['default']); + $this->assertNull($data['config']['dynamic']['options']); + $this->assertNull($data['config']['nested']['default']['value']); + $this->assertSame('Inspection', $data['fieldtype']['title']); + $this->assertSame('text', $data['config']['nested']['editor_type']); + $this->assertSame('content_block', $data['config']['nested']['default']['type']); + $this->assertContains(['path' => ['config', 'dynamic', 'options'], 'source' => 'php', 'php_type' => 'Closure'], $data['unresolved']); + $this->assertContains(['path' => ['config', 'nested', 'default', 'value'], 'source' => 'php', 'php_type' => 'stdClass'], $data['unresolved']); + } + + #[Test] + public function it_preserves_registered_aliases() + { + app('statamic.fieldtypes')->put('test_alias', InspectionFieldtype::class); + + $data = $this->commandJson(['handle' => 'test_alias']); + + $this->assertSame('test_alias', $data['fieldtype']['handle']); + $this->assertSame(InspectionFieldtype::class, $data['fieldtype']['class']); + } + + #[Test] + public function it_can_inspect_every_registered_core_fieldtype() + { + $inspector = app(FieldtypeInspector::class); + + foreach (app('statamic.fieldtypes') as $handle => $class) { + if (! str_starts_with($class, 'Statamic\\Fieldtypes\\')) { + continue; + } + + $data = $inspector->show($handle); + + $this->assertSame($handle, $data['fieldtype']['handle']); + $this->assertJson(json_encode($data, JSON_THROW_ON_ERROR)); + } + } + + #[Test] + public function it_can_show_human_readable_configuration() + { + $this->assertSame(0, Artisan::call('statamic:fieldtypes:show', ['handle' => 'grid'])); + + $data = Yaml::parse(Artisan::output()); + + $this->assertSame('grid', $data['fieldtype']['handle']); + $this->assertSame('table', $data['config']['mode']['default']); + } + + #[Test] + public function an_unknown_fieldtype_returns_a_nonzero_exit_code_and_json_error() + { + $data = $this->commandJson(['handle' => 'does_not_exist'], 1); + + $this->assertSame(['error' => 'Fieldtype [does_not_exist] not found'], $data); + } + + private function commandJson(array $arguments, int $exitCode = 0): array + { + $this->assertSame($exitCode, Artisan::call('statamic:fieldtypes:show', $arguments + ['--json' => true])); + + return json_decode(Artisan::output(), true, 512, JSON_THROW_ON_ERROR); + } +} + +class InspectionFieldtype extends Fieldtype +{ + protected static $title = 'Inspection'; + + protected function configFieldItems(): array + { + return [ + 'mode' => [ + 'type' => 'select', + 'options' => collect(['enabled' => 'Enabled']), + ], + 'limit' => [ + 'type' => 'integer', + 'default' => 0, + 'if' => ['mode' => 'enabled'], + ], + 'optional' => [ + 'type' => 'text', + 'default' => null, + ], + 'dynamic' => [ + 'type' => 'select', + 'options' => function () { + throw new \RuntimeException('This callback must not run during inspection.'); + }, + ], + 'nested' => [ + 'type' => 'text', + 'default' => ['type' => 'content_block', 'value' => new \stdClass], + ], + ]; + } +}