diff --git a/README.md b/README.md index e07d7dd..878f2de 100644 --- a/README.md +++ b/README.md @@ -215,42 +215,47 @@ $message = Message::fromArray($decodedMessageJson); $message = Message::fromJson($messageJson); ``` -## Supported Elements - -The following are supported elements from the Block Kit documentation: - -| **Type** | **Element** | **Supported?** | -|----------|--------------------|----------------| -| Surface | App Home | ✅ | -| Surface | Message | ✅ | -| Surface | Model | ✅ | -| Block | Actions | ✅ | -| Block | Checkboxes | ✅ | -| Block | Context | ✅ | -| Block | Divider | ✅ | -| Block | File | ✅ | -| Block | Header | ✅ | -| Block | Image | ✅ | -| Block | Input | ✅ | -| Block | Section | ✅ | -| Input | Button | ✅️ | -| Input | Date Picker | ✅ | -| Input | Multi-select Menus | ✅✅✅✅✅ | -| Input | Overflow Menu | ✅ | -| Input | Plain Text Input | ✅ | -| Input | Radio Buttons | ✅ | -| Input | Select Menus | ✅✅✅✅✅ | -| Input | Time Picker | ✅ | -| Partial | Confirm Dialog | ✅ | -| Partial | Mrkdwn Text | ✅ | -| Partial | Fields | ✅ | -| Partial | Option | ✅ | -| Partial | Option Group | ✅ | -| Partial | Plain Text | ✅ | - -### Virtual Elements - -The following are virtual/custom elements composed of one or more blocks: +### Message Formatting + +The `Formatter` class exists to provide helpers for formatting "mrkdwn" text. These helpers can be used so that you +don't have to have the Slack mrkdwn syntax memorized. Also, these functions will properly escape `<`, `>`, and `&` +characters automatically, if it's needed. + +Example: +```php +// Note: $event is meant to represent some kind of DTO from your own application. +$fmt = Kit::formatter(); +$msg = Kit::newMessage()->text($fmt->sub( + 'Hello, {audience}! On {date}, {host} will be hosting an AMA in the {channel} channel at {time}.', + [ + 'audience' => $fmt->atHere(), + 'date' => $fmt->date($event->timestamp), + 'host' => $fmt->user($event->hostId), + 'channel' => $fmt->channel($event->channelId), + 'time' => $fmt->time($event->timestamp), + ] +)); +``` + +Example Result: +```json +{ + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "Hello, ! On , <@U12345678> will be hosting an AMA in the <#C12345678> channel at ." + } + } + ] +} +``` + +## Virtual Elements + +In addition to the standard Block Kit elements, the following are virtual/custom elements composed of one or +more blocks: * `TwoColumnTable` - Uses Sections with Fields to create a two-column table with an optional header. diff --git a/composer.json b/composer.json index d35e083..ca65a8a 100644 --- a/composer.json +++ b/composer.json @@ -26,17 +26,22 @@ } }, "scripts": { - "gen-test": "php bin/gen-test.php", "stan": "phpstan analyse -c phpstan.neon", "style-fix": "phpcbf --standard=PSR12 src tests", "style-lint": "phpcs --standard=PSR12 src tests", "test": "phpunit --bootstrap=vendor/autoload.php --no-coverage tests", + "test-all": [ + "@style-lint", + "@stan", + "@test-coverage" + ], "test-ci": "phpunit --bootstrap=vendor/autoload.php --coverage-text --whitelist=src --do-not-cache-result tests", "test-coverage": [ "phpunit --bootstrap=vendor/autoload.php --coverage-html=build/coverage --whitelist=src tests", "open build/coverage/index.html" ], "test-debug": "phpunit --bootstrap=vendor/autoload.php --no-coverage --debug tests", - "test-dox": "phpunit --bootstrap=vendor/autoload.php --no-coverage --testdox tests" + "test-dox": "phpunit --bootstrap=vendor/autoload.php --no-coverage --testdox tests", + "test-gen": "php bin/gen-test.php" } } diff --git a/src/Blocks/Context.php b/src/Blocks/Context.php index 0e2bd25..b46de25 100644 --- a/src/Blocks/Context.php +++ b/src/Blocks/Context.php @@ -50,7 +50,7 @@ public function plainText(string $text, ?bool $emoji = null): self return $this->add(new PlainText($text, $emoji)); } - public function mrkdwnText(string $text, bool $verbatim = false): self + public function mrkdwnText(string $text, ?bool $verbatim = null): self { return $this->add(new MrkdwnText($text, $verbatim)); } diff --git a/src/Blocks/Section.php b/src/Blocks/Section.php index da2c389..0fa89c6 100644 --- a/src/Blocks/Section.php +++ b/src/Blocks/Section.php @@ -4,7 +4,15 @@ namespace Jeremeamia\Slack\BlockKit\Blocks; -use Jeremeamia\Slack\BlockKit\{Element, Exception, HydrationData, Inputs, Partials, Type}; +use Jeremeamia\Slack\BlockKit\{ + Element, + Exception, + HydrationData, + Inputs, + Kit, + Partials, + Type, +}; class Section extends BlockElement { @@ -83,10 +91,10 @@ public function plainText(string $text, ?bool $emoji = null): self /** * @param string $text - * @param bool $verbatim + * @param bool|null $verbatim * @return self */ - public function mrkdwnText(string $text, bool $verbatim = false): self + public function mrkdwnText(string $text, ?bool $verbatim = null): self { return $this->setText(new Partials\MrkdwnText($text, $verbatim)); } @@ -97,7 +105,7 @@ public function mrkdwnText(string $text, bool $verbatim = false): self */ public function code(string $code): self { - return $this->setText(new Partials\MrkdwnText("```\n{$code}\n```")); + return $this->setText(new Partials\MrkdwnText(Kit::formatter()->codeBlock($code), true)); } /** diff --git a/src/Config.php b/src/Config.php new file mode 100644 index 0000000..dcb61af --- /dev/null +++ b/src/Config.php @@ -0,0 +1,46 @@ +defaultVerbatimSetting; + } + + public function setDefaultVerbatimSetting(?bool $verbatim): self + { + $this->defaultVerbatimSetting = $verbatim; + + return $this; + } + + public function getDefaultEmojiSetting(): ?bool + { + return $this->defaultEmojiSetting; + } + + public function setDefaultEmojiSetting(?bool $emoji): self + { + $this->defaultEmojiSetting = $emoji; + + return $this; + } +} diff --git a/src/Exception.php b/src/Exception.php index 97d1bf1..6be0362 100644 --- a/src/Exception.php +++ b/src/Exception.php @@ -7,6 +7,8 @@ use RuntimeException; use Throwable; +use function vsprintf; + class Exception extends RuntimeException { public function __construct(string $message, array $args = [], Throwable $previous = null) diff --git a/src/Formatter.php b/src/Formatter.php new file mode 100644 index 0000000..6612fc9 --- /dev/null +++ b/src/Formatter.php @@ -0,0 +1,280 @@ +", "&") should be escaped when they are not a part of formatting that requires those + * characters. The formatting helpers in this class automatically escape input in situations where the text is place + * within angle brackets, such as with dates and links. + * + * Notes: + * - `Formatter::new()` returns a singleton instance. + * - You can also call the methods using static notation (e.g., `Formatter::atHere()`). + */ +final class Formatter +{ + public const DATE = '{date}'; + public const DATE_LONG = '{date_long}'; + public const DATE_LONG_PRETTY = '{date_long_pretty}'; + public const DATE_NUM = '{date_num}'; + public const DATE_PRETTY = '{date_pretty}'; + public const DATE_SHORT = '{date_short}'; + public const DATE_SHORT_PRETTY = '{date_short_pretty}'; + public const TIME = '{time}'; + public const TIME_SECS = '{time_secs}'; + + public static function new(): self + { + return new self(); + } + + /** + * Escapes ambiguous characters to their HTML entities. + * + * @param string $text + * @return string + */ + public function escape(string $text): string + { + return strtr($text, [ + '&' => '&', + '<' => '<', + '>' => '>', + ]); + } + + /** + * Performs a string interpolation by substituting keys (in curly braces) for their values. + * + * @param string $text + * @param array $values + * @return string + */ + public function sub(string $text, array $values): string + { + $replacements = []; + foreach ($values as $key => $value) { + $replacements["{{$key}}"] = $value; + } + + return strtr($text, $replacements); + } + + //region Helpers for @here, @channel, and @everyone mentions. + public function atChannel(): string + { + return ''; + } + + public function atEveryone(): string + { + return ''; + } + + public function atHere(): string + { + return ''; + } + //endregion + + //region Helpers for mentioning/linking specific channels, users, or user groups. + public function channel(string $id): string + { + return "<#{$id}>"; + } + + public function user(string $id): string + { + return "<@{$id}>"; + } + + public function userGroup(string $id): string + { + return ""; + } + //endregion + + //region Helpers for basic text formatting (B/I/S) and links. + public function bold(string $text): string + { + return "*{$text}*"; + } + + public function code(string $text): string + { + return "`{$this->escape($text)}`"; + } + + public function italic(string $text): string + { + return "_{$text}_"; + } + + public function strike(string $text): string + { + return "~{$text}~"; + } + + public function link(string $url, ?string $text = null): string + { + return isset($text) ? "<{$this->escape($url)}|{$this->escape($text)}>" : "<{$this->escape($url)}>"; + } + + public function emailLink(string $email, ?string $text = null): string + { + return $this->link("mailto:{$email}", $text); + } + //endregion + + //region Helpers for multi-line content blocks like lists and quotes. + /** + * @param array|string $lines + * @return string + */ + public function blockQuote($lines): string + { + return $this->lines($this->explode($lines), '> ', false); + } + + /** + * @param array|string $items + * @param string $bullet + * @return string + */ + public function bulletedList($items, string $bullet = '•'): string + { + return $this->lines($this->explode($items), "{$bullet} "); + } + + public function codeBlock(string $text): string + { + return "```\n{$this->escape($text)}\n```"; + } + + /** + * @param array|string $items + * @return string + */ + public function numberedList($items): string + { + $index = 0; + return $this->lines($this->explode($items), function (string $item) use (&$index) { + $index++; + return "{$index}. {$item}"; + }); + } + + /** + * Takes a list of lines/strings, and concatenates them with newlines, filtering out any empty lines. + * + * Optionally applies a prefix to each line. You can use a closure if the prefix varies per line. + * + * @param array $lines + * @param string|callable|null $prefix + * @param bool $filter + * @return string + */ + public function lines(array $lines, $prefix = null, bool $filter = true): string + { + if (is_string($prefix)) { + $prefix = function (string $value) use ($prefix) { + return "{$prefix}{$value}"; + }; + } + + if (is_callable($prefix)) { + $lines = array_map($prefix, $lines); + } elseif (!is_null($prefix)) { + throw new Exception('Formatter::lines given invalid prefix argument'); + } + + if ($filter) { + $lines = array_filter($lines, static function ($line) { + return $line !== null && $line !== ''; + }); + } + + return implode("\n", $lines) . "\n"; + } + //endregion + + //region Helpers for formatting dates and times. + /** + * Formats a timestamp as a date in mrkdwn. + * + * @param int|null $timestamp Timestamp to format. Defaults to now. + * @param string $format Format name supported by Slack. Defaults to "{date}". + * @param string|null $fallback Fallback text for old Slack clients. Defaults to an ISO-formatted timestamp. + * @param string|null $link URL, if the date is to act as a link. + * @return string + * @see https://api.slack.com/reference/surfaces/formatting#date-formatting + */ + public function date( + ?int $timestamp = null, + string $format = self::DATE, + ?string $fallback = null, + ?string $link = null + ): string { + $timestamp = $timestamp ?? time(); + $fallback = $this->escape($fallback ?? date('c', $timestamp)); + $link = $link ? "^{$this->escape($link)}" : ''; + + return "escape($format)}{$link}|{$fallback}>"; + } + + /** + * Formats a timestamp as a time in mrkdwn. + * + * Equivalent to Formatter::date(), but uses the TIME format as default. + * + * @param int|null $timestamp Timestamp to format. Defaults to now. + * @param string $format Format name supported by Slack. Defaults to "{time}". + * @param string|null $fallback Fallback text for old Slack clients. Defaults to an ISO-formatted timestamp. + * @param string|null $link URL, if the time is to act as a link. + * @return string + */ + public function time( + ?int $timestamp = null, + string $format = self::TIME, + ?string $fallback = null, + ?string $link = null + ): string { + return $this->date($timestamp, $format, $fallback, $link); + } + //endregion + + /** + * Ensures the provided items are an array. + * + * Explodes strings on "\n" if a string is provided. + * + * @param array|string $items + * @return array + */ + private function explode($items): array + { + if (is_string($items)) { + return explode("\n", $items); + } elseif (is_array($items)) { + return $items; + } + + throw new Exception('Formatter::explode given invalid items argument'); + } +} diff --git a/src/Kit.php b/src/Kit.php index d1388a7..92ba8af 100644 --- a/src/Kit.php +++ b/src/Kit.php @@ -6,10 +6,24 @@ use Jeremeamia\Slack\BlockKit\Surfaces; -use function rawurlencode; - +/** + * Kit act as a static façade to the whole block kit library. + * + * It provides methods to instantiate each type of surface, preview a surface using Slack's Block Kit Builder, and + * access the singleton Config and Formatter instances. The Kit's instances of Config and Formatter are used throughout + * the rest of the library. + */ abstract class Kit { + /** @var Config */ + private static $config; + + /** @var Formatter */ + private static $formatter; + + /** @var Previewer */ + private static $previewer; + public static function newAppHome(): Surfaces\AppHome { return new Surfaces\AppHome(); @@ -25,20 +39,30 @@ public static function newModal(): Surfaces\Modal return new Surfaces\Modal(); } - public static function preview(Surfaces\Surface $surface): string + public static function config(): Config { - if ($surface instanceof Surfaces\Message) { - // Block Kit Builder doesn't support message directives. - $surface->directives([]); - } elseif ($surface instanceof Surfaces\Attachment) { - // Block Kit Builder can only show an attachment within a message. - $surface = self::newMessage()->addAttachment($surface); - } elseif ($surface instanceof Surfaces\WorkflowStep) { - throw new Exception('The "workflow_step" surface is not compatible with Block Kit Builder'); + if (!isset(self::$config)) { + self::$config = Config::new(); } - $encoded = str_replace(['%22', '%3A'], ['"', ':'], rawurlencode($surface->toJson())); + return self::$config; + } + + public static function formatter(): Formatter + { + if (!isset(self::$formatter)) { + self::$formatter = Formatter::new(); + } + + return self::$formatter; + } + + public static function preview(Surfaces\Surface $surface): string + { + if (!isset(self::$previewer)) { + self::$previewer = Previewer::new(); + } - return "https://app.slack.com/block-kit-builder#{$encoded}"; + return self::$previewer->preview($surface); } } diff --git a/src/Partials/MrkdwnText.php b/src/Partials/MrkdwnText.php index adba15d..b91f79f 100644 --- a/src/Partials/MrkdwnText.php +++ b/src/Partials/MrkdwnText.php @@ -5,6 +5,7 @@ namespace Jeremeamia\Slack\BlockKit\Partials; use Jeremeamia\Slack\BlockKit\HydrationData; +use Jeremeamia\Slack\BlockKit\Kit; class MrkdwnText extends Text { @@ -13,22 +14,23 @@ class MrkdwnText extends Text /** * @param string|null $text - * @param bool $verbatim + * @param bool|null $verbatim */ - public function __construct(?string $text = null, bool $verbatim = false) + public function __construct(?string $text = null, ?bool $verbatim = null) { if ($text !== null) { $this->text($text); } + $verbatim = $verbatim ?? Kit::config()->getDefaultVerbatimSetting(); $this->verbatim($verbatim); } /** - * @param bool $verbatim + * @param bool|null $verbatim * @return static */ - public function verbatim(bool $verbatim): self + public function verbatim(?bool $verbatim): self { $this->verbatim = $verbatim; @@ -42,7 +44,7 @@ public function toArray(): array { $data = parent::toArray(); - if (!empty($this->verbatim)) { + if (isset($this->verbatim)) { $data['verbatim'] = $this->verbatim; } @@ -51,7 +53,7 @@ public function toArray(): array protected function hydrate(HydrationData $data): void { - $this->verbatim($data->useValue('verbatim', false)); + $this->verbatim($data->useValue('verbatim')); parent::hydrate($data); } diff --git a/src/Partials/PlainText.php b/src/Partials/PlainText.php index b645e8f..d48d515 100644 --- a/src/Partials/PlainText.php +++ b/src/Partials/PlainText.php @@ -5,6 +5,7 @@ namespace Jeremeamia\Slack\BlockKit\Partials; use Jeremeamia\Slack\BlockKit\HydrationData; +use Jeremeamia\Slack\BlockKit\Kit; class PlainText extends Text { @@ -21,16 +22,15 @@ public function __construct(?string $text = null, ?bool $emoji = null) $this->text($text); } - if ($emoji !== null) { - $this->emoji($emoji); - } + $emoji = $emoji ?? Kit::config()->getDefaultEmojiSetting(); + $this->emoji($emoji); } /** - * @param bool $emoji + * @param bool|null $emoji * @return static */ - public function emoji(bool $emoji): self + public function emoji(?bool $emoji): self { $this->emoji = $emoji; @@ -53,9 +53,7 @@ public function toArray(): array protected function hydrate(HydrationData $data): void { - if ($data->has('emoji')) { - $this->emoji($data->useValue('emoji', true)); - } + $this->emoji($data->useValue('emoji')); parent::hydrate($data); } diff --git a/src/Partials/Text.php b/src/Partials/Text.php index 68f4165..df0e62a 100644 --- a/src/Partials/Text.php +++ b/src/Partials/Text.php @@ -4,7 +4,7 @@ namespace Jeremeamia\Slack\BlockKit\Partials; -use Jeremeamia\Slack\BlockKit\{Element, Exception, HydrationData, Type}; +use Jeremeamia\Slack\BlockKit\{Element, Exception, HydrationData}; abstract class Text extends Element { diff --git a/src/Previewer.php b/src/Previewer.php new file mode 100644 index 0000000..f61d652 --- /dev/null +++ b/src/Previewer.php @@ -0,0 +1,54 @@ +directives([]); + } elseif ($surface instanceof Surfaces\Attachment) { + // Block Kit Builder can only show an attachment within a message. + $surface = $surface->asMessage(); + } elseif ($surface instanceof Surfaces\WorkflowStep) { + throw new Exception('The "workflow_step" surface is not compatible with Block Kit Builder'); + } + + // Generate the Block Kit Builder URL. + return self::BUILDER_URL . '#' . $this->encode($surface); + } + + /** + * Encodes a surface into a format understood by Slack and capable of being transmitted in a URL fragment. + * + * 1. Encode the surface as JSON. + * 2. URL encode the JSON. + * 3. Convert encoded entities for double quotes and colons back to their original characters. + * + * @param Surfaces\Surface $surface + * @return string + */ + private function encode(Surfaces\Surface $surface): string + { + return strtr(rawurlencode($surface->toJson()), ['%22' => '"', '%3A' => ':']); + } +} diff --git a/src/Surfaces/Attachment.php b/src/Surfaces/Attachment.php index 3b22cd3..258de22 100644 --- a/src/Surfaces/Attachment.php +++ b/src/Surfaces/Attachment.php @@ -22,6 +22,16 @@ class Attachment extends Surface /** @var string */ private $color; + /** + * Returns the attachment as a new Message with the attachment attached. + * + * @return Message + */ + public function asMessage(): Message + { + return Message::new()->addAttachment($this); + } + /** * Sets the hex color of the attachment. It Appears as a border along the left side. * diff --git a/src/Type.php b/src/Type.php index db7b7b3..df75208 100644 --- a/src/Type.php +++ b/src/Type.php @@ -27,13 +27,13 @@ abstract class Type public const SECTION = 'section'; // Inputs - public const BUTTON = 'button'; - public const CHECKBOXES = 'checkboxes'; - public const DATEPICKER = 'datepicker'; - public const TEXT_INPUT = 'plain_text_input'; - public const TIMEPICKER = 'timepicker'; - public const OVERFLOW_MENU = 'overflow'; - public const RADIO_BUTTONS = 'radio_buttons'; + public const BUTTON = 'button'; + public const CHECKBOXES = 'checkboxes'; + public const DATEPICKER = 'datepicker'; + public const TEXT_INPUT = 'plain_text_input'; + public const TIMEPICKER = 'timepicker'; + public const OVERFLOW_MENU = 'overflow'; + public const RADIO_BUTTONS = 'radio_buttons'; // Select Menus public const MULTI_SELECT_MENU_CHANNELS = 'multi_channels_select'; @@ -182,7 +182,7 @@ abstract class Type Blocks\Actions::class => self::ACTIONS, Blocks\Context::class => self::CONTEXT, Blocks\Divider::class => self::DIVIDER, - Blocks\File::class => self::FILE, + Blocks\File::class => self::FILE, Blocks\Header::class => self::HEADER, Blocks\Image::class => self::IMAGE, Blocks\Input::class => self::INPUT, @@ -192,13 +192,13 @@ abstract class Type Blocks\Virtual\TwoColumnTable::class => self::SECTION, // Composed of Sections // Inputs - Inputs\Button::class => self::BUTTON, - Inputs\Checkboxes::class => self::CHECKBOXES, - Inputs\DatePicker::class => self::DATEPICKER, - Inputs\OverflowMenu::class => self::OVERFLOW_MENU, - Inputs\RadioButtons::class => self::RADIO_BUTTONS, - Inputs\TextInput::class => self::TEXT_INPUT, - Inputs\TimePicker::class => self::TIMEPICKER, + Inputs\Button::class => self::BUTTON, + Inputs\Checkboxes::class => self::CHECKBOXES, + Inputs\DatePicker::class => self::DATEPICKER, + Inputs\OverflowMenu::class => self::OVERFLOW_MENU, + Inputs\RadioButtons::class => self::RADIO_BUTTONS, + Inputs\TextInput::class => self::TEXT_INPUT, + Inputs\TimePicker::class => self::TIMEPICKER, // Select Menus SelectMenus\MultiChannelSelectMenu::class => self::MULTI_SELECT_MENU_CHANNELS, diff --git a/tests/ConfigTest.php b/tests/ConfigTest.php new file mode 100644 index 0000000..558d576 --- /dev/null +++ b/tests/ConfigTest.php @@ -0,0 +1,29 @@ +setDefaultEmojiSetting(true) + ->setDefaultVerbatimSetting(true); + + $this->assertTrue($c->getDefaultEmojiSetting()); + $this->assertTrue($c->getDefaultVerbatimSetting()); + } + + public function testCanUseDefaultConfigValues() + { + $c = Config::new(); + + $this->assertNull($c->getDefaultEmojiSetting()); + $this->assertNull($c->getDefaultVerbatimSetting()); + } +} diff --git a/tests/FormatterTest.php b/tests/FormatterTest.php new file mode 100644 index 0000000..eb75147 --- /dev/null +++ b/tests/FormatterTest.php @@ -0,0 +1,38 @@ +assertEquals('*hello*', $f->bold('hello')); + $this->assertEquals('_hello_', $f->italic('hello')); + $this->assertEquals('~hello~', $f->strike('hello')); + $this->assertEquals('`hello`', $f->code('hello')); + } + + public function testCanDoEntityReferenceFormatting() + { + $f = Formatter::new(); + $this->assertEquals('', $f->atChannel()); + $this->assertEquals('', $f->atEveryone()); + $this->assertEquals('', $f->atHere()); + $this->assertEquals('<#C01>', $f->channel('C01')); + $this->assertEquals('<@U01>', $f->user('U01')); + $this->assertEquals('', $f->userGroup('G01')); + } + + public function testCanInterpolateAndEscapeText() + { + $f = Formatter::new(); + $text = $f->escape($f->sub('There is {name} & John.', ['name' => 'Jim'])); + $this->assertEquals('There is Jim & John.', $text); + } +} diff --git a/tests/Inputs/RadioButtonsTest.php b/tests/Inputs/RadioButtonsTest.php index 4e34b7c..2ecc71f 100644 --- a/tests/Inputs/RadioButtonsTest.php +++ b/tests/Inputs/RadioButtonsTest.php @@ -79,7 +79,6 @@ public function testRadioButtonsWithConfirm() public function testTooManyOptions() { - $this->expectException(Exception::class); $input = (new RadioButtons()) ->option('foo', 'foo') diff --git a/tests/KitTest.php b/tests/KitTest.php new file mode 100644 index 0000000..e57a3a5 --- /dev/null +++ b/tests/KitTest.php @@ -0,0 +1,59 @@ +setStaticProperties(Kit::class, [ + 'config' => null, + 'formatter' => null, + 'previewer' => null, + ]); + } + + public function testCanCreateSurfaces() + { + $this->assertInstanceOf(Message::class, Kit::newMessage()); + $this->assertInstanceOf(Modal::class, Kit::newModal()); + $this->assertInstanceOf(AppHome::class, Kit::newAppHome()); + } + + public function testStoresConfigAsSingleton() + { + $config1 = Kit::config(); + $config2 = Kit::config(); + $this->assertInstanceOf(Config::class, $config1); + $this->assertInstanceOf(Config::class, $config2); + $this->assertSame($config1, $config2); + } + + public function testStoresFormatterAsSingleton() + { + $formatter1 = Kit::formatter(); + $formatter2 = Kit::formatter(); + $this->assertInstanceOf(Formatter::class, $formatter1); + $this->assertInstanceOf(Formatter::class, $formatter2); + $this->assertSame($formatter1, $formatter2); + } + + public function testCanUsePreviewerToGenerateUrl() + { + $msg = Kit::newMessage()->text('foo'); + $url = Kit::preview($msg); + $this->assertStringStartsWith('https://', $url); + $this->assertStringContainsString('#%7B"blocks"', $url); + } +} diff --git a/tests/Surfaces/AttachmentTest.php b/tests/Surfaces/AttachmentTest.php index 5ba32fd..d14b468 100644 --- a/tests/Surfaces/AttachmentTest.php +++ b/tests/Surfaces/AttachmentTest.php @@ -4,7 +4,7 @@ namespace Jeremeamia\Slack\BlockKit\Tests\Surfaces; -use Jeremeamia\Slack\BlockKit\Surfaces\Attachment; +use Jeremeamia\Slack\BlockKit\Surfaces\{Attachment, Message}; use Jeremeamia\Slack\BlockKit\Tests\TestCase; use Jeremeamia\Slack\BlockKit\Type; @@ -15,7 +15,7 @@ class AttachmentTest extends TestCase { public function testCanCreateAttachment() { - $msg = Attachment::new()->color('00ff00')->text('foo'); + $att = Attachment::new()->color('00ff00')->text('foo'); $this->assertJsonData([ 'color' => '#00ff00', @@ -28,6 +28,14 @@ public function testCanCreateAttachment() ], ], ], - ], $msg); + ], $att); + } + + public function testCanCreateMessageFromAttachment() + { + $att = Attachment::new()->color('00ff00')->text('foo'); + $msg = $att->asMessage(); + + $this->assertInstanceOf(Message::class, $msg); } } diff --git a/tests/Surfaces/SurfaceTest.php b/tests/Surfaces/SurfaceTest.php index 1d14730..a941b83 100644 --- a/tests/Surfaces/SurfaceTest.php +++ b/tests/Surfaces/SurfaceTest.php @@ -6,10 +6,7 @@ use Jeremeamia\Slack\BlockKit\Blocks\Section; use Jeremeamia\Slack\BlockKit\Blocks\Virtual\TwoColumnTable; -use Jeremeamia\Slack\BlockKit\Blocks\Virtual\VirtualBlock; -use Jeremeamia\Slack\BlockKit\Surfaces\Surface; use Jeremeamia\Slack\BlockKit\Tests\TestCase; -use Jeremeamia\Slack\BlockKit\Type; /** * @covers \Jeremeamia\Slack\BlockKit\Surfaces\Surface diff --git a/tests/TestCase.php b/tests/TestCase.php index ff1fbd9..8570b5a 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -7,6 +7,8 @@ use Jeremeamia\Slack\BlockKit\Surfaces\Surface; use Jeremeamia\Slack\BlockKit\Type; use PHPUnit\Framework\TestCase as PhpUnitTestCase; +use ReflectionException; +use ReflectionProperty; class TestCase extends PhpUnitTestCase { @@ -74,4 +76,18 @@ public function getType(): string return $virtualBlock; } + + /** + * @param string $class + * @param array $properties + * @throws ReflectionException + */ + protected function setStaticProperties(string $class, array $properties): void + { + foreach ($properties as $property => $value) { + $reflection = new ReflectionProperty($class, $property); + $reflection->setAccessible(true); + $reflection->setValue(null, $value); + } + } } diff --git a/tests/manual/message-formatted.php b/tests/manual/message-formatted.php new file mode 100644 index 0000000..4d03618 --- /dev/null +++ b/tests/manual/message-formatted.php @@ -0,0 +1,50 @@ +tap(function (Message $msg) { + $f = Kit::formatter(); + $msg->text(<<escape('<&>')} + {$f->bold('hello')} world + {$f->italic('hello')} world + {$f->strike('hello')} world + {$f->code('hello')} world + {$f->blockQuote("this\nis\na\nblockquote")} + {$f->codeBlock("this\nis\na\ncode block")} + {$f->bulletedList(['this', 'is', 'a', 'bulleted list'])} + {$f->numberedList("this\nis\na\nnumbered list")} + Today is {$f->date(time(), '{date}')} + Link: {$f->link('http://google.com', 'Google')} + MailTo: {$f->emailLink('jeremy@example.org', 'Email Jeremy')} + Join {$f->channel('general')} + Talk to {$f->user('jeremy.lindblom')} + Talk to {$f->userGroup('devs')} + Hey {$f->atHere()} + Hey {$f->atChannel()} + Hey {$f->atEveryone()} + MRKDWN); + + $event = (object) [ + 'timestamp' => strtotime('+2 days'), + 'hostId' => 'U123456', + 'channelId' => 'C123456', + ]; + $msg->text($f->sub( + 'Hello, {audience}! On {date}, {host} will be hosting an AMA in the {channel} channel at {time}.', + [ + 'audience' => $f->atHere(), + 'date' => $f->date($event->timestamp), + 'host' => $f->user($event->hostId), + 'channel' => $f->channel($event->channelId), + 'time' => $f->time($event->timestamp), + ] + )); +}); + +view($msg);