diff --git a/CHANGELOG.md b/CHANGELOG.md index 48d345c..9a321d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## 0.1.3 Under development +- feat(panel-view): add typed readouts, pills, manifests, facts, and sections with factory methods. + ## 0.1.2 September 13, 2026 - fix(security): reject link targets a browser would normalize, and move exception messages to the `Exception\Message` enum. diff --git a/src/Exception/PanelViewMessage.php b/src/Exception/PanelViewMessage.php index a8cf989..400d818 100644 --- a/src/Exception/PanelViewMessage.php +++ b/src/Exception/PanelViewMessage.php @@ -27,6 +27,13 @@ enum PanelViewMessage: string */ case COLUMN_STYLE_KEY_INVALID = 'Debug panel column styles must be keyed by an existing column index.'; + /** + * Indicates that an entry passed to a composite block was not built by the matching factory. + * + * Format: "Debug panel %s entries must be built with PanelView::%s()." + */ + case ENTRY_INVALID = 'Debug panel %s entries must be built with PanelView::%s().'; + /** * Indicates that an inline value is neither a scalar, `null`, nor a shape this class produces. * diff --git a/src/PanelView.php b/src/PanelView.php index 279bd00..5d49e79 100644 --- a/src/PanelView.php +++ b/src/PanelView.php @@ -8,9 +8,11 @@ use JsonSerializable; use PHPForge\Debug\Exception\PanelViewMessage; +use function array_key_exists; use function count; use function in_array; use function is_array; +use function is_bool; use function is_float; use function is_int; use function is_string; @@ -29,6 +31,8 @@ * The host reads the result through {@see self::summaryMetrics()}, {@see self::toolbarMetrics()}, {@see self::blocks()}, * and {@see self::isActive()}. Nothing outside this class can build or alter a shape. * + * Inline values. + * * @phpstan-type BadgeInline array{kind: 'badge', label: string, tone: Tone} * @phpstan-type LinkInline array{kind: 'link', label: string, href: string, external: bool} * @phpstan-type TextInline array{kind: 'text', value: string, style: 'code'|'plain'|'preview'|'sql'|'strong'} @@ -37,10 +41,27 @@ * @phpstan-type Inline BadgeInline|LinkInline|TextInline|TraceInline|ValueInline * @phpstan-type Pair array{label: string, value: Inline} * @phpstan-type TextPair array{label: string, value: TextInline} + * + * Entries of a composite block. + * + * @phpstan-type FactEntry array{kind: 'fact', label: string, value: string} + * @phpstan-type PackageEntry array{kind: 'package', name: string, version: string} + * @phpstan-type PillEntry array{kind: 'pill', label: string, state: string, enabled: bool} + * @phpstan-type ReadoutEntry array{kind: 'readout', label: string, value: string, caption: string} + * + * Content blocks. + * + * @phpstan-type DisclosureBlock array{kind: 'disclosure', title: string, content: string} * @phpstan-type EmptyStateBlock array{kind: 'emptyState', title: string, paragraphs: list} + * @phpstan-type FactsBlock array{kind: 'facts', facts: list} * @phpstan-type GroupBlock array{kind: 'group', label: string, content: PanelView} + * @phpstan-type HeadingBlock array{kind: 'heading', title: string, section: bool} + * @phpstan-type ManifestBlock array{kind: 'manifest', label: string, packages: list} * @phpstan-type OverviewBlock array{kind: 'overview', fields: list, compact: bool} * @phpstan-type ParagraphBlock array{kind: 'paragraph', content: list, tone: Tone|null} + * @phpstan-type PillsBlock array{kind: 'pills', pills: list} + * @phpstan-type ReadoutsBlock array{kind: 'readouts', readouts: list} + * @phpstan-type SectionBlock array{kind: 'section', mark: string, title: string, count: int|null, content: PanelView} * @phpstan-type TableBlock array{ * kind: 'table', * headers: list, @@ -49,11 +70,8 @@ * collapsible: bool, * filterable: bool * } - * @phpstan-type Block array{ - * kind: 'disclosure', - * title: string, - * content: string - * }|array{kind: 'heading', title: string, section: bool}|EmptyStateBlock|GroupBlock|OverviewBlock|ParagraphBlock|TableBlock + * @phpstan-type Block DisclosureBlock|EmptyStateBlock|FactsBlock|GroupBlock|HeadingBlock|ManifestBlock|OverviewBlock + * |ParagraphBlock|PillsBlock|ReadoutsBlock|SectionBlock|TableBlock */ final readonly class PanelView implements JsonSerializable { @@ -79,12 +97,7 @@ private function __construct( */ public function active(bool $active): self { - return new self( - $this->summary, - $this->blocks, - $this->toolbar, - $active, - ); + return new self($this->summary, $this->blocks, $this->toolbar, $active); } /** @@ -152,12 +165,7 @@ public static function code(string $value): array */ public static function create(): self { - return new self( - [], - [], - [], - true, - ); + return new self([], [], [], true); } /** @@ -195,6 +203,45 @@ public function emptyState(string $title, mixed ...$paragraphs): self return $this->append(['kind' => 'emptyState', 'title' => $title, 'paragraphs' => $content]); } + /** + * Creates one compact label and value pair of a fact strip. + * + * @param string $label Name of the fact; the host escapes it. + * @param string $value Recorded value; the host escapes it. + * + * @return FactEntry Fact entry accepted by {@see self::facts()}. + */ + public static function fact(string $label, string $value): array + { + return [ + 'kind' => 'fact', + 'label' => $label, + 'value' => $value, + ]; + } + + /** + * Appends a compact strip of label and value pairs. + * + * @param array ...$facts Entries produced by {@see self::fact()}. + * + * @throws InvalidArgumentException if an argument was not built by {@see self::fact()}. + * + * @return self New view with the fact strip appended. + */ + public function facts(array ...$facts): self + { + $entries = []; + + foreach ($facts as $fact) { + self::assertFactEntry($fact); + + $entries[] = $fact; + } + + return $this->append(['kind' => 'facts', 'facts' => $entries]); + } + /** * Groups only the child's content; metrics and activity belong to the root view. * @@ -270,6 +317,29 @@ public static function link(string $label, string $href, bool $external = false) ]; } + /** + * Appends a vendor-grouped package manifest. + * + * @param string $label Vendor prefix the packages share, shown as the group heading. + * @param array ...$packages Entries produced by {@see self::package()}. + * + * @throws InvalidArgumentException if an argument was not built by {@see self::package()}. + * + * @return self New view with the manifest appended. + */ + public function manifest(string $label, array ...$packages): self + { + $entries = []; + + foreach ($packages as $package) { + self::assertPackageEntry($package); + + $entries[] = $package; + } + + return $this->append(['kind' => 'manifest', 'label' => $label, 'packages' => $entries]); + } + /** * Appends labeled overview fields, using array keys as labels. * @@ -293,6 +363,23 @@ public function overview(array $values, bool $compact = false): self return $this->append(['kind' => 'overview', 'fields' => $fields, 'compact' => $compact]); } + /** + * Creates one package entry of a manifest. + * + * @param string $name Package name; the host escapes it. + * @param string $version Resolved version; the host escapes it. + * + * @return PackageEntry Package entry accepted by {@see self::manifest()}. + */ + public static function package(string $name, string $version): array + { + return [ + 'kind' => 'package', + 'name' => $name, + 'version' => $version, + ]; + } + /** * Appends an ordinary paragraph, converting plain values to text. * @@ -307,6 +394,47 @@ public function paragraph(mixed ...$content): self return $this->append(self::paragraphBlock($content, null)); } + /** + * Creates one status pill. + * + * @param string $label Subject of the pill, such as an extension name; the host escapes it. + * @param string $state Short state text shown after the label, such as `on` or a version. + * @param bool $enabled Whether the subject is active, selecting the on or off presentation. + * + * @return PillEntry Pill entry accepted by {@see self::pills()}. + */ + public static function pill(string $label, string $state, bool $enabled): array + { + return [ + 'kind' => 'pill', + 'label' => $label, + 'state' => $state, + 'enabled' => $enabled, + ]; + } + + /** + * Appends a strip of status pills. + * + * @param array ...$pills Entries produced by {@see self::pill()}. + * + * @throws InvalidArgumentException if an argument was not built by {@see self::pill()}. + * + * @return self New view with the pill strip appended. + */ + public function pills(array ...$pills): self + { + $entries = []; + + foreach ($pills as $pill) { + self::assertPillEntry($pill); + + $entries[] = $pill; + } + + return $this->append(['kind' => 'pills', 'pills' => $entries]); + } + /** * Creates inline text the host may clamp behind its standard expand control. * @@ -323,6 +451,70 @@ public static function preview(string $value): array ]; } + /** + * Creates one headline readout card. + * + * @param string $label Metric name shown above the value; the host escapes it. + * @param string $value Headline value; the host escapes it. + * @param string $caption Qualifier shown under the value, or `''` to omit it. + * + * @return ReadoutEntry Readout entry accepted by {@see self::readouts()}. + */ + public static function readout(string $label, string $value, string $caption = ''): array + { + return [ + 'kind' => 'readout', + 'label' => $label, + 'value' => $value, + 'caption' => $caption, + ]; + } + + /** + * Appends a row of headline readout cards. + * + * @param array ...$readouts Entries produced by {@see self::readout()}. + * + * @throws InvalidArgumentException if an argument was not built by {@see self::readout()}. + * + * @return self New view with the readout row appended. + */ + public function readouts(array ...$readouts): self + { + $entries = []; + + foreach ($readouts as $readout) { + self::assertReadoutEntry($readout); + + $entries[] = $readout; + } + + return $this->append(['kind' => 'readouts', 'readouts' => $entries]); + } + + /** + * Appends a titled section wrapping its own content. + * + * @param string $mark Short glyph shown before the title, such as `::` or `//`. + * @param string $title Section title announced as its accessible name. + * @param self $content Blocks the section wraps. + * @param int|null $count Optional tally shown at the end of the title, or `null` to omit it. + * + * @return self New view with the section appended. + */ + public function section(string $mark, string $title, self $content, int|null $count = null): self + { + return $this->append( + [ + 'kind' => 'section', + 'mark' => $mark, + 'title' => $title, + 'count' => $count, + 'content' => $content, + ], + ); + } + /** * Creates inline text the host highlights as an SQL statement. * @@ -371,12 +563,7 @@ public function summary(string $label, string|int|float $value, bool $emphasized 'value' => $emphasized ? self::strong((string) $value) : self::text((string) $value), ]; - return new self( - [...$this->summary, $metric], - $this->blocks, - $this->toolbar, - $this->active, - ); + return new self([...$this->summary, $metric], $this->blocks, $this->toolbar, $this->active); } /** @@ -455,12 +642,7 @@ public function toolbar(string $label, string|int|float $value): self 'value' => self::text((string) $value), ]; - return new self( - $this->summary, - $this->blocks, - [...$this->toolbar, $metric], - $this->active, - ); + return new self($this->summary, $this->blocks, [...$this->toolbar, $metric], $this->active); } /** @@ -536,12 +718,97 @@ public static function value(mixed $value, bool $typeOnly = false): array */ private function append(array $block): self { - return new self( - $this->summary, - [...$this->blocks, $block], - $this->toolbar, - $this->active, - ); + return new self($this->summary, [...$this->blocks, $block], $this->toolbar, $this->active); + } + + /** + * Asserts that an entry was built by {@see self::fact()}. + * + * @param array $entry Entry passed to {@see self::facts()}. + * + * @throws InvalidArgumentException if the entry does not carry the shape the factory produces. + * + * @phpstan-assert FactEntry $entry + */ + private static function assertFactEntry(array $entry): void + { + if ( + ($entry['kind'] ?? null) !== 'fact' + || is_string($entry['label'] ?? null) === false + || is_string($entry['value'] ?? null) === false + ) { + throw new InvalidArgumentException( + PanelViewMessage::ENTRY_INVALID->getMessage('fact', 'fact'), + ); + } + } + + /** + * Asserts that an entry was built by {@see self::package()}. + * + * @param array $entry Entry passed to {@see self::manifest()}. + * + * @throws InvalidArgumentException if the entry does not carry the shape the factory produces. + * + * @phpstan-assert PackageEntry $entry + */ + private static function assertPackageEntry(array $entry): void + { + if ( + ($entry['kind'] ?? null) !== 'package' + || is_string($entry['name'] ?? null) === false + || is_string($entry['version'] ?? null) === false + ) { + throw new InvalidArgumentException( + PanelViewMessage::ENTRY_INVALID->getMessage('package', 'package'), + ); + } + } + + /** + * Asserts that an entry was built by {@see self::pill()}. + * + * @param array $entry Entry passed to {@see self::pills()}. + * + * @throws InvalidArgumentException if the entry does not carry the shape the factory produces. + * + * @phpstan-assert PillEntry $entry + */ + private static function assertPillEntry(array $entry): void + { + if ( + ($entry['kind'] ?? null) !== 'pill' + || is_string($entry['label'] ?? null) === false + || is_string($entry['state'] ?? null) === false + || is_bool($entry['enabled'] ?? null) === false + ) { + throw new InvalidArgumentException( + PanelViewMessage::ENTRY_INVALID->getMessage('pill', 'pill'), + ); + } + } + + /** + * Asserts that an entry was built by {@see self::readout()}. + * + * @param array $entry Entry passed to {@see self::readouts()}. + * + * @throws InvalidArgumentException if the entry does not carry the shape the factory produces. + * + * @phpstan-assert ReadoutEntry $entry + */ + private static function assertReadoutEntry(array $entry): void + { + if ( + ($entry['kind'] ?? null) !== 'readout' + || is_string($entry['label'] ?? null) === false + || is_string($entry['value'] ?? null) === false + || is_string($entry['caption'] ?? null) === false + ) { + throw new InvalidArgumentException( + PanelViewMessage::ENTRY_INVALID->getMessage('readout', 'readout'), + ); + } } /** diff --git a/tests/CompositeBlockTest.php b/tests/CompositeBlockTest.php new file mode 100644 index 0000000..f448376 --- /dev/null +++ b/tests/CompositeBlockTest.php @@ -0,0 +1,135 @@ + 'facts', + 'facts' => [ + ['kind' => 'fact', 'label' => 'Charset', 'value' => 'UTF-8'], + ['kind' => 'fact', 'label' => 'Language', 'value' => 'en'], + ], + ], + ], + PanelView::create() + ->facts(PanelView::fact('Charset', 'UTF-8'), PanelView::fact('Language', 'en')) + ->blocks(), + 'A fact strip keeps every pair in declaration order.', + ); + } + + public function testManifestGroupsPackagesUnderOneVendorLabel(): void + { + $view = PanelView::create()->manifest( + 'yiisoft/', + PanelView::package('aliases', 'v3.1.1'), + PanelView::package('arrays', 'v3.2.1'), + ); + + self::assertSame( + [ + [ + 'kind' => 'manifest', + 'label' => 'yiisoft/', + 'packages' => [ + ['kind' => 'package', 'name' => 'aliases', 'version' => 'v3.1.1'], + ['kind' => 'package', 'name' => 'arrays', 'version' => 'v3.2.1'], + ], + ], + ], + $view->blocks(), + 'A manifest keeps its packages in declaration order under the vendor label.', + ); + } + + public function testPillsKeepTheirStateAndOrder(): void + { + $view = PanelView::create()->pills( + PanelView::pill('APCu', 'on', true), + PanelView::pill('Memcache', 'off', false), + ); + + self::assertSame( + [ + [ + 'kind' => 'pills', + 'pills' => [ + ['kind' => 'pill', 'label' => 'APCu', 'state' => 'on', 'enabled' => true], + ['kind' => 'pill', 'label' => 'Memcache', 'state' => 'off', 'enabled' => false], + ], + ], + ], + $view->blocks(), + 'A pill strip keeps every subject with its own state.', + ); + } + + public function testReadoutCaptionIsOptional(): void + { + self::assertSame( + ['kind' => 'readout', 'label' => 'Yii', 'value' => '3', 'caption' => ''], + PanelView::readout('Yii', '3'), + 'A readout without a qualifier carries an empty caption.', + ); + self::assertSame( + [['kind' => 'readouts', 'readouts' => [['kind' => 'readout', 'label' => 'PHP', 'value' => '8.5.9', 'caption' => 'runtime']]]], + PanelView::create()->readouts(PanelView::readout('PHP', '8.5.9', 'runtime'))->blocks(), + 'A readout row carries its cards in declaration order.', + ); + } + + public function testSectionCountIsOptionalAndWrapsItsOwnBlocks(): void + { + $content = PanelView::create()->paragraph('Nothing captured.'); + $blocks = PanelView::create() + ->section('::', 'Extensions', $content) + ->section('//', 'Details', $content, 47) + ->blocks(); + + self::assertSame( + ['kind' => 'section', 'mark' => '::', 'title' => 'Extensions', 'count' => null, 'content' => $content], + $blocks[0] ?? [], + 'A section without a tally reports `null`.', + ); + self::assertSame( + ['kind' => 'section', 'mark' => '//', 'title' => 'Details', 'count' => 47, 'content' => $content], + $blocks[1] ?? [], + 'A section keeps the tally it was given.', + ); + } + /** + * @param Closure(): PanelView $build Composition that must reject the malformed entry. + * @param string $kind Entry kind the rejected argument was meant to carry. + */ + #[DataProviderExternal(MalformedEntryProvider::class, 'entries')] + public function testThrowInvalidArgumentExceptionForAnEntryTheFactoryDidNotBuild(Closure $build, string $kind): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + PanelViewMessage::ENTRY_INVALID->getMessage($kind, $kind), + ); + + $build(); + } +} diff --git a/tests/Provider/MalformedEntryProvider.php b/tests/Provider/MalformedEntryProvider.php new file mode 100644 index 0000000..0f4b364 --- /dev/null +++ b/tests/Provider/MalformedEntryProvider.php @@ -0,0 +1,117 @@ + + */ + public static function entries(): iterable + { + yield 'fact built by another factory' => [ + static fn(): PanelView => PanelView::create()->facts( + ['kind' => 'pill', 'label' => 'Charset', 'value' => 'UTF-8'], + ), + 'fact', + ]; + yield 'fact without a label' => [ + static fn(): PanelView => PanelView::create()->facts( + ['kind' => 'fact', 'label' => 8, 'value' => 'UTF-8'], + ), + 'fact', + ]; + yield 'fact without a value' => [ + static fn(): PanelView => PanelView::create()->facts( + ['kind' => 'fact', 'label' => 'Charset', 'value' => 8], + ), + 'fact', + ]; + yield 'empty fact' => [ + static fn(): PanelView => PanelView::create()->facts([]), + 'fact', + ]; + yield 'package built by another factory' => [ + static fn(): PanelView => PanelView::create()->manifest( + 'yiisoft/', + ['kind' => 'fact', 'name' => 'aliases', 'version' => 'v3.1.1'], + ), + 'package', + ]; + yield 'package without a name' => [ + static fn(): PanelView => PanelView::create()->manifest( + 'yiisoft/', + ['kind' => 'package', 'name' => 1, 'version' => 'v3.1.1'], + ), + 'package', + ]; + yield 'package without a version' => [ + static fn(): PanelView => PanelView::create()->manifest( + 'yiisoft/', + ['kind' => 'package', 'name' => 'aliases', 'version' => 3], + ), + 'package', + ]; + yield 'empty package' => [ + static fn(): PanelView => PanelView::create()->manifest('yiisoft/', []), + 'package', + ]; + yield 'pill built by another factory' => [ + static fn(): PanelView => PanelView::create()->pills( + ['kind' => 'fact', 'label' => 'APCu', 'state' => 'on', 'enabled' => true], + ), + 'pill', + ]; + yield 'pill without a label' => [ + static fn(): PanelView => PanelView::create()->pills( + ['kind' => 'pill', 'label' => 1, 'state' => 'on', 'enabled' => true], + ), + 'pill', + ]; + yield 'pill without a state' => [ + static fn(): PanelView => PanelView::create()->pills( + ['kind' => 'pill', 'label' => 'APCu', 'state' => 1, 'enabled' => true], + ), + 'pill', + ]; + yield 'pill without a boolean flag' => [ + static fn(): PanelView => PanelView::create()->pills( + ['kind' => 'pill', 'label' => 'APCu', 'state' => 'on', 'enabled' => 'yes'], + ), + 'pill', + ]; + yield 'readout built by another factory' => [ + static fn(): PanelView => PanelView::create()->readouts( + ['kind' => 'fact', 'label' => 'Yii', 'value' => '3', 'caption' => 'framework'], + ), + 'readout', + ]; + yield 'readout without a label' => [ + static fn(): PanelView => PanelView::create()->readouts( + ['kind' => 'readout', 'label' => 1, 'value' => '3', 'caption' => 'framework'], + ), + 'readout', + ]; + yield 'readout without a value' => [ + static fn(): PanelView => PanelView::create()->readouts( + ['kind' => 'readout', 'label' => 'Yii', 'value' => 3, 'caption' => 'framework'], + ), + 'readout', + ]; + yield 'readout without a caption' => [ + static fn(): PanelView => PanelView::create()->readouts( + ['kind' => 'readout', 'label' => 'Yii', 'value' => '3', 'caption' => 0], + ), + 'readout', + ]; + } +}