Repository: masmerise/livewire-toaster Branch: master Commit: 694affbd0af0 Files: 65 Total size: 85.4 KB Directory structure: gitextract_4ngx_v20/ ├── .editorconfig ├── .gitattributes ├── .github/ │ └── workflows/ │ └── test.yml ├── .gitignore ├── .stubs.php ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── UPGRADING.md ├── composer.json ├── config/ │ └── toaster.php ├── phpstan.neon.dist ├── phpunit.xml.dist ├── pint.json ├── resources/ │ ├── js/ │ │ ├── config.js │ │ ├── hub.js │ │ ├── index.js │ │ ├── toast.js │ │ ├── toaster.js │ │ └── uuid41.js │ └── views/ │ ├── close-button.blade.php │ └── hub.blade.php ├── src/ │ ├── AccessibleCollector.php │ ├── Alignment.php │ ├── Assertable.php │ ├── Collector.php │ ├── Duration.php │ ├── LivewireRelay.php │ ├── Message.php │ ├── PendingToast.php │ ├── Position.php │ ├── QueuingCollector.php │ ├── SessionRelay.php │ ├── TestableCollector.php │ ├── Toast.php │ ├── ToastBuilder.php │ ├── ToastType.php │ ├── Toastable.php │ ├── ToastableMacros.php │ ├── Toaster.php │ ├── ToasterConfig.php │ ├── ToasterHub.php │ ├── ToasterServiceProvider.php │ └── TranslatingCollector.php └── tests/ ├── AccessibleCollectorTest.php ├── CollectorFactoryMethods.php ├── ControllerTest.php ├── DurationTest.php ├── LivewireRelayTest.php ├── LivewireTest.php ├── MessageTest.php ├── PendingToastTest.php ├── QueuingCollectorTest.php ├── SessionRelayTest.php ├── TestCase.php ├── TestableCollectorTest.php ├── ToastBuilderTest.php ├── ToastFactoryMethods.php ├── ToastTest.php ├── ToastableMacrosTest.php ├── ToastableTest.php ├── ToasterConfigTest.php ├── ToasterHubTest.php ├── ToasterServiceProviderTest.php └── TranslatingCollectorTest.php ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ root = true [*] charset = utf-8 end_of_line = lf insert_final_newline = true indent_style = space indent_size = 4 trim_trailing_whitespace = true [*.md] trim_trailing_whitespace = false [*.{yml,yaml}] indent_size = 2 [*.php] ij_continuation_indent_size = 4 ij_php_align_assignments = false ij_php_align_class_constants = false ij_php_align_group_field_declarations = false ij_php_align_inline_comments = false ij_php_align_key_value_pairs = false ij_php_align_multiline_array_initializer_expression = false ij_php_align_multiline_binary_operation = false ij_php_align_multiline_chained_methods = false ij_php_align_multiline_extends_list = true ij_php_align_multiline_for = true ij_php_align_multiline_parameters = false ij_php_align_multiline_parameters_in_calls = false ij_php_align_multiline_ternary_operation = false ij_php_align_phpdoc_comments = true ij_php_align_phpdoc_param_names = true ij_php_anonymous_brace_style = end_of_line ij_php_api_weight = 28 ij_php_array_initializer_new_line_after_left_brace = true ij_php_array_initializer_right_brace_on_new_line = true ij_php_array_initializer_wrap = on_every_item ij_php_assignment_wrap = off ij_php_attributes_wrap = split_into_lines ij_php_author_weight = 28 ij_php_binary_operation_sign_on_next_line = false ij_php_binary_operation_wrap = off ij_php_blank_lines_after_class_header = 0 ij_php_blank_lines_after_function = 1 ij_php_blank_lines_after_imports = 1 ij_php_blank_lines_after_opening_tag = 1 ij_php_blank_lines_after_package = 1 ij_php_blank_lines_around_class = 1 ij_php_blank_lines_around_constants = 0 ij_php_blank_lines_around_field = 1 ij_php_blank_lines_around_method = 1 ij_php_blank_lines_before_class_end = 0 ij_php_blank_lines_before_imports = 1 ij_php_blank_lines_before_method_body = 0 ij_php_blank_lines_before_package = 1 ij_php_blank_lines_before_return_statement = 1 ij_php_blank_lines_between_imports = 1 ij_php_block_brace_style = end_of_line ij_php_call_parameters_new_line_after_left_paren = false ij_php_call_parameters_right_paren_on_new_line = false ij_php_call_parameters_wrap = normal ij_php_catch_on_new_line = false ij_php_category_weight = 28 ij_php_class_brace_style = next_line ij_php_comma_after_last_array_element = true ij_php_concat_spaces = false ij_php_copyright_weight = 28 ij_php_deprecated_weight = 28 ij_php_do_while_brace_force = always ij_php_else_if_style = combine ij_php_else_on_new_line = false ij_php_example_weight = 28 ij_php_extends_keyword_wrap = off ij_php_extends_list_wrap = on_every_item ij_php_fields_default_visibility = private ij_php_filesource_weight = 28 ij_php_finally_on_new_line = false ij_php_for_brace_force = always ij_php_for_statement_new_line_after_left_paren = true ij_php_for_statement_right_paren_on_new_line = true ij_php_for_statement_wrap = off ij_php_force_short_declaration_array_style = true ij_php_getters_setters_naming_style = camel_case ij_php_getters_setters_order_style = getters_first ij_php_global_weight = 28 ij_php_group_use_wrap = on_every_item ij_php_if_brace_force = always ij_php_if_lparen_on_next_line = false ij_php_if_rparen_on_next_line = false ij_php_ignore_weight = 28 ij_php_import_sorting = alphabetic ij_php_indent_break_from_case = true ij_php_indent_case_from_switch = true ij_php_indent_code_in_php_tags = false ij_php_internal_weight = 28 ij_php_keep_blank_lines_after_lbrace = 0 ij_php_keep_blank_lines_before_right_brace = 0 ij_php_keep_blank_lines_in_code = 1 ij_php_keep_blank_lines_in_declarations = 0 ij_php_keep_control_statement_in_one_line = true ij_php_keep_first_column_comment = true ij_php_keep_indents_on_empty_lines = false ij_php_keep_line_breaks = true ij_php_keep_rparen_and_lbrace_on_one_line = true ij_php_keep_simple_classes_in_one_line = false ij_php_keep_simple_methods_in_one_line = false ij_php_lambda_brace_style = end_of_line ij_php_license_weight = 28 ij_php_line_comment_add_space = false ij_php_line_comment_at_first_column = true ij_php_link_weight = 28 ij_php_lower_case_boolean_const = true ij_php_lower_case_keywords = true ij_php_lower_case_null_const = true ij_php_method_brace_style = next_line ij_php_method_call_chain_wrap = off ij_php_method_parameters_new_line_after_left_paren = true ij_php_method_parameters_right_paren_on_new_line = true ij_php_method_parameters_wrap = on_every_item ij_php_method_weight = 28 ij_php_modifier_list_wrap = false ij_php_multiline_chained_calls_semicolon_on_new_line = false ij_php_namespace_brace_style = 1 ij_php_new_line_after_php_opening_tag = true ij_php_null_type_position = in_the_end ij_php_package_weight = 28 ij_php_param_weight = 0 ij_php_parameters_attributes_wrap = split_into_lines ij_php_parentheses_expression_new_line_after_left_paren = false ij_php_parentheses_expression_right_paren_on_new_line = false ij_php_phpdoc_blank_line_before_tags = true ij_php_phpdoc_blank_lines_around_parameters = true ij_php_phpdoc_keep_blank_lines = true ij_php_phpdoc_param_spaces_between_name_and_description = 1 ij_php_phpdoc_param_spaces_between_tag_and_type = 1 ij_php_phpdoc_param_spaces_between_type_and_name = 1 ij_php_phpdoc_use_fqcn = false ij_php_phpdoc_wrap_long_lines = false ij_php_place_assignment_sign_on_next_line = false ij_php_place_parens_for_constructor = 0 ij_php_property_read_weight = 28 ij_php_property_weight = 28 ij_php_property_write_weight = 28 ij_php_return_type_on_new_line = false ij_php_return_weight = 1 ij_php_see_weight = 28 ij_php_since_weight = 28 ij_php_sort_phpdoc_elements = true ij_php_space_after_colon = true ij_php_space_after_colon_in_named_argument = true ij_php_space_after_colon_in_return_type = true ij_php_space_after_comma = true ij_php_space_after_for_semicolon = true ij_php_space_after_quest = true ij_php_space_after_type_cast = true ij_php_space_after_unary_not = true ij_php_space_before_array_initializer_left_brace = false ij_php_space_before_catch_keyword = true ij_php_space_before_catch_left_brace = true ij_php_space_before_catch_parentheses = true ij_php_space_before_class_left_brace = true ij_php_space_before_closure_left_parenthesis = true ij_php_space_before_colon = true ij_php_space_before_colon_in_named_argument = false ij_php_space_before_colon_in_return_type = false ij_php_space_before_comma = false ij_php_space_before_do_left_brace = true ij_php_space_before_else_keyword = true ij_php_space_before_else_left_brace = true ij_php_space_before_finally_keyword = true ij_php_space_before_finally_left_brace = true ij_php_space_before_for_left_brace = true ij_php_space_before_for_parentheses = true ij_php_space_before_for_semicolon = false ij_php_space_before_if_left_brace = true ij_php_space_before_if_parentheses = true ij_php_space_before_method_call_parentheses = false ij_php_space_before_method_left_brace = true ij_php_space_before_method_parentheses = false ij_php_space_before_quest = true ij_php_space_before_short_closure_left_parenthesis = true ij_php_space_before_switch_left_brace = true ij_php_space_before_switch_parentheses = true ij_php_space_before_try_left_brace = true ij_php_space_before_unary_not = false ij_php_space_before_while_keyword = true ij_php_space_before_while_left_brace = true ij_php_space_before_while_parentheses = true ij_php_space_between_ternary_quest_and_colon = false ij_php_spaces_around_additive_operators = true ij_php_spaces_around_arrow = false ij_php_spaces_around_assignment_in_declare = false ij_php_spaces_around_assignment_operators = true ij_php_spaces_around_bitwise_operators = true ij_php_spaces_around_equality_operators = true ij_php_spaces_around_logical_operators = true ij_php_spaces_around_multiplicative_operators = true ij_php_spaces_around_null_coalesce_operator = true ij_php_spaces_around_relational_operators = true ij_php_spaces_around_shift_operators = true ij_php_spaces_around_unary_operator = false ij_php_spaces_around_var_within_brackets = false ij_php_spaces_within_array_initializer_braces = false ij_php_spaces_within_brackets = false ij_php_spaces_within_catch_parentheses = false ij_php_spaces_within_for_parentheses = false ij_php_spaces_within_if_parentheses = false ij_php_spaces_within_method_call_parentheses = false ij_php_spaces_within_method_parentheses = false ij_php_spaces_within_parentheses = false ij_php_spaces_within_short_echo_tags = true ij_php_spaces_within_switch_parentheses = false ij_php_spaces_within_while_parentheses = false ij_php_special_else_if_treatment = false ij_php_subpackage_weight = 28 ij_php_ternary_operation_signs_on_next_line = true ij_php_ternary_operation_wrap = normal ij_php_throws_weight = 2 ij_php_todo_weight = 28 ij_php_unknown_tag_weight = 28 ij_php_upper_case_boolean_const = false ij_php_upper_case_null_const = false ij_php_uses_weight = 28 ij_php_var_weight = 28 ij_php_variable_naming_style = camel_case ij_php_version_weight = 28 ij_php_while_brace_force = always ij_php_while_on_new_line = false ================================================ FILE: .gitattributes ================================================ /.editorconfig export-ignore /.gitattributes export-ignore /.github export-ignore /.gitignore export-ignore /art export-ignore /phpstan.neon.dist export-ignore /phpunit.xml.dist export-ignore /pint.json export-ignore /tests export-ignore /UPGRADING.md export-ignore ================================================ FILE: .github/workflows/test.yml ================================================ name: Automated testing on: - push - pull_request jobs: test: runs-on: ubuntu-latest strategy: fail-fast: true matrix: php: [8.2, 8.3, 8.4, 8.5] laravel: [11, 12, 13] include: - laravel: 11 testbench: 9 - laravel: 12 testbench: 10 - laravel: '13' testbench: '11' exclude: - laravel: '13' php: 8.2 name: PHP ${{ matrix.php }} - Laravel ${{ matrix.laravel }} steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} ini-values: error_reporting=E_ALL tools: composer:v2 coverage: none - name: Install dependencies run: | composer require "illuminate/contracts:^${{ matrix.laravel }}" "orchestra/testbench:^${{ matrix.testbench }}" --no-interaction --no-update composer update --prefer-dist --no-interaction --no-progress - name: Execute tests run: composer test ================================================ FILE: .gitignore ================================================ .idea .php_cs .php_cs.cache .phpunit.cache .phpunit.result.cache composer.lock phpunit.xml phpstan.neon vendor node_modules .php-cs-fixer.cache ================================================ FILE: .stubs.php ================================================ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================

Toaster Banner

# Beautiful toast notifications for Livewire [![Latest Version on Packagist](https://img.shields.io/packagist/v/masmerise/livewire-toaster.svg?style=flat-square)](https://packagist.org/packages/masmerise/livewire-toaster) [![GitHub Tests Action Status](https://img.shields.io/github/actions/workflow/status/masmerise/livewire-toaster/test.yml?branch=master)](https://github.com/masmerise/livewire-toaster/actions?query=workflow%3A%22Automated+testing%22+branch%3Amaster) [![Total Downloads](https://img.shields.io/packagist/dt/masmerise/livewire-toaster.svg?style=flat-square)](https://packagist.org/packages/masmerise/livewire-toaster) **Toaster** provides a seamless experience to display toast notifications in your Livewire powered Laravel apps. Unlike many other toast implementations that are available, Toaster makes it effortless to dispatch a toast notification from either a standard `Controller` or a Livewire `Component`. You don't have to think about "flashing" things to the session or "dispatching browser events" from your Livewire components. Just dispatch your toast and Toaster will route the message accordingly. ## Showcase

Toaster Demo

## Compatibility
LivewirePHPLaravel
| | [2](https://laravel-livewire.com/docs/2.x) | [3 & 4](https://livewire.laravel.com/docs) | |-|-|-| | [1.x](https://github.com/masmerise/livewire-toaster/tree/1.3.0) | ✅ | ❌ | | 2.x | ❌ | ✅ | | | 8.2 | 8.3 | 8.4 | 8.5 |-|-|-|-|-| | 1.0 - ∞ | ✅ | ✅ | ✅ | ✅ | | | 10 | 11 | 12 | 13 |-|-|-|-|-| | 1.0 - 2.1 * | ✅ | ❌ | ❌ | ❌ | 2.2 - ∞ | ❌ | ✅ | ✅ | ✅
_* feature complete_ ## Contents **Looking for v1 docs?** [Click here](https://github.com/masmerise/livewire-toaster/tree/1.3.0). - [Installation](#installation) - [Preparing your template](#preparing-your-template) - [Configuring scripts](#configuring-scripts) - [Tailwind styles](#tailwind-styles) - [RTL support](#rtl-support) - [Usage](#usage) - [Sending toasts from the back-end](#sending-toasts-from-the-back-end) - [Sending toasts from the front-end](#sending-toasts-from-the-front-end) - [Automatic translation of messages](#automatic-translation-of-messages) - [Accessibility](#accessibility) - [Replacing similar toasts](#replacing-similar-toasts) - [Suppressing duplicate toasts](#suppressing-duplicate-toasts) - [Unit testing](#unit-testing) - [Extending behavior](#extending-behavior) - [View customization](#view-customization) - [Testing](#testing) - [Changelog](#changelog) - [Security](#security) - [Credits](#credits) - [License](#license) ## Installation You can install the package via [composer](https://getcomposer.org): ```bash composer require masmerise/livewire-toaster ``` You can publish the package's config file: ```bash php artisan vendor:publish --tag=toaster-config ``` This is the contents of the `toaster.php` config file: ```php return [ /** * Add an additional second for every 100th word of the toast messages. * * Supported: true | false */ 'accessibility' => true, /** * The vertical alignment of the toast container. * * Supported: "bottom", "middle" or "top" */ 'alignment' => 'bottom', /** * Allow users to close toast messages prematurely. * * Supported: true | false */ 'closeable' => true, /** * The on-screen duration of each toast. * * Minimum: 3000 (in milliseconds) */ 'duration' => 3000, /** * The horizontal position of each toast. * * Supported: "center", "left" or "right" */ 'position' => 'right', /** * New toasts immediately replace similar ones, ensuring only one toast of a kind is visible at any time. * Takes precedence over the "suppress" option. * * Supported: true | false */ 'replace' => false, /** * Prevent the display of duplicate toast messages. * * Supported: true | false */ 'suppress' => false, /** * Whether messages passed as translation keys should be translated automatically. * * Supported: true | false */ 'translate' => true, ]; ``` ### Preparing your template Next, you'll need to use the `` component in your master template: ```html ``` ### Configuring scripts After that, you'll need to import `Toaster` at the top of your `resources/js/app.js` bundle to start listening to incoming toasts: ```js import './bootstrap'; import '../../vendor/masmerise/livewire-toaster/resources/js'; // 👈 // other app stuff... ``` ### Tailwind styles > [!NOTE] > Skip this step if you're going to customize Toaster's default view. Toaster provides a minimal view that utilizes Tailwind CSS defaults. If the default toast appearances suffice your needs, you'll need to register it with Tailwind's purge list: For Tailwind CSS Version < 4.x ```js module.exports = { content: [ './resources/**/*.blade.php', './vendor/masmerise/livewire-toaster/resources/views/*.blade.php', // 👈 ], } ``` For Tailwind CSS V >= 4+ you'll need to add this to your `app.css` file: > [!NOTE] > Tailwind CSS v4.0 introduced a major change where you define your source content files directly in the main CSS entry point using the `@source` directive. This is the **most modern and recommended approach** for v4+. ```css @import "tailwindcss"; ... @source '../../vendor/masmerise/livewire-toaster/resources/views/*.blade.php'; /* 👈 */ ``` or into the file `tailwind.config.js`: > [!NOTE] > The `content` array still exists and functions in Tailwind CSS 4+. For many existing projects or frameworks, defining the paths here is a fallback or continued method. The config file itself is correctly updated to use the modern ESM `export default` syntax. ```js /** @type {import('tailwindcss').Config} */ export default { content: [ "./resources/**/*.blade.php", "./resources/**/*.js", "./resources/**/*.vue", "./vendor/masmerise/livewire-toaster/resources/views/*.blade.php", // 👈 ], plugins: [], }; ``` Otherwise, please refer to [View customization](#view-customization). ### RTL support > [!NOTE] > **LTR** will be assumed regardless of whether you apply the `ltr` attribute or not. If your app makes use of an **RTL** language such as Arabic and Hebrew, don't forget to add the `rtl` attribute to the document root: ```html ... ``` This will make sure the UI elements (such as the close button) are flipped and the text is properly aligned. ## Usage ### Sending toasts from the back-end > [!NOTE] > Toaster supports the dispatch of multiple toasts at once, you are not limited to dispatching a single toast. #### Toaster The standard recommended way for dispatching toast messages is through the `Toaster` facade. ```php use Masmerise\Toaster\Toaster; final class RegistrationForm extends Component { public function submit(): void { $this->validate(); User::create($this->form); Toaster::success('User created!'); // 👈 } } ``` If you need fine-grained control, you can always use the `PendingToast` class directly to which `Toaster` proxies its calls: ```php use Masmerise\Toaster\PendingToast; final class RegistrationForm extends Component { public function submit(): void { $this->validate(); $user = User::create($this->form); // 👇 PendingToast::create() ->when($user->isAdmin(), fn (PendingToast $toast) => $toast->message('Admin created') ) ->unless($user->isAdmin(), fn (PendingToast $toast) => $toast->message('User created') ) ->success(); } } ``` #### Toastable You can make any class `Toastable` to dispatch toasts from: ```php use Masmerise\Toaster\Toastable; final class ProductListing extends Component { use Toastable; // 👈 public function check(): void { $result = Product::query() ->tap(new Available()) ->count(); if ($result < 5) { $this->warning('The quantity on hand is critically low.'); // 👈 } } } ``` #### Redirects Whenever you return a `RedirectResponse` from anywhere in your app, you can chain any of the `Toaster` methods to dispatch a toast message: ```php final class CompanyController extends Controller { /** @throws ValidationException */ public function store(Request $request): RedirectResponse { $validator = Validator::make($request->all(), [...]); if ($validator->fails()) { return Redirect::back() ->error('The form contains several errors'); // 👈 } Company::create($validator->validate()); return Redirect::route('dashboard') ->info('Company created!'); // 👈 } } ``` This is, of course, **not** limited to `Controller`s as you can also redirect in Livewire `Component`s. #### Dependency injection If you'd like to keep things "pure", you can also inject the `Collector` contract and use the `ToastBuilder` to dispatch your toasts: ```php use Masmerise\Toaster\Collector; use Masmerise\Toaster\ToasterConfig; use Masmerise\Toaster\ToastBuilder; final readonly class SendEmailVerifiedNotification { public function __construct( private ToasterConfig $config, private Collector $toasts, ) {} public function handle(Verified $event): void { $toast = ToastBuilder::create() ->duration($this->config->duration) ->success() ->message("Thank you, {$event->user->name}!") ->get(); $this->toasts->collect($toast); } } ``` ### Sending toasts from the front-end You can invoke the globally available `Toaster` instance to dispatch any toast message from anywhere: ```html ``` Available methods: `error`, `info`, `warning` & `success` ### Automatic translation of messages > [!NOTE] > The `translate` configuration value must be set to `true`. Instead of doing this: ```php Toaster::success( Lang::get('path.to.translation', ['replacement' => 'value']) ); ``` Toaster makes it possible to do this: ```php Toaster::success('path.to.translation', ['replacement' => 'value']); ``` You can mix and match without any problems: ```php Toaster::info('user.created', ['name' => $user->full_name]); Toaster::info('You now have full access!'); ``` You can do whatever you want, whenever you want. ### Accessibility > [!NOTE] > The `accessibility` configuration value must be set to `true`. Toaster will add an additional second to a toast's on-screen duration for every 100th word. This way, your users will have enough time to read toasts that are a tad larger than usual. So, if your base duration value is `3 seconds` and your toast contains 223 words, the total on-screen duration of the toast will be `3 + 2 = 5 seconds` ### Replacing similar toasts > [!NOTE] > The `replace` configuration value must be set to `true`. > [!WARNING] > Takes precedence over `suppress`. Toaster will dispose of any toast that is similar to the one being dispatched prior to displaying the new toast. A toast is considered similar if it has the same `duration`, `message`, and `type`. ### Suppressing duplicate toasts > [!NOTE] > The `suppress` configuration value must be set to `true`. Toaster will prevent the display of duplicate toast messages while another toast with the same message is still on-screen. A toast is considered a duplicate if it has the same `duration`, `message`, and `type`. ### Unit testing > [!NOTE] > If you make use of [automatic translation of messages](#automatic-translation-of-messages), you should assert whether the **translation keys** are passed along correctly instead of the human readable messages that are replaced by Laravel's translator. > Otherwise, your tests are going to fail as the messages are not translated during unit testing. Toaster provides a couple of testing capabilities in order for you to build a robust application: ```php use Masmerise\Toaster\Toaster; final class RegisterUserControllerTest extends TestCase { #[Test] public function users_can_register(): void { // Arrange Toaster::fake(); Toaster::assertNothingDispatched(); // Act $response = $this->post('users', [ ... ]); // Assert $response->assertRedirect('profile'); Toaster::assertDispatched('Welcome!'); } } ``` ### Extending behavior Imagine that you'd like to keep track of how many toasts are dispatched daily to display on an admin dashboard. First, create a new class that encapsulates this logic: ```php final readonly class DailyCountingCollector implements Collector { public function __construct(private Collector $next) {} public function collect(Toast $toast): void { // increment the counter on durable storage $this->next->collect($toast); } public function release(): array { return $this->next->release(); } } ``` After that, extend the behavior in your `AppServiceProvider`: ```php public function register(): void { $this->app->extend(Collector::class, static fn (Collector $next) => new DailyCountingCollector($next) ); } ``` That's it! ## View customization Even though the default toasts are pretty, they might not fit your design and you may want to customize them. You can do so by publishing Toaster's views: ```php php artisan vendor:publish --tag=toaster-views ``` The `hub.blade.php` view will be published to your application's `resources/views/vendor/toaster` directory. Feel free to modify anything to your liking. ### Available `viewData` - `$alignment` - can be used to align the toast container vertically depending on the configuration - `$closeable` - whether the close button should be rendered by the Blade component - `$config` - default configuration values, used by the Alpine component - `$position` - can be used to position the toasts depending on the configuration - `$toasts` - toasts that were flashed to the session by Toaster, used by the Alpine component > [!WARNING] > You **must** keep the `x-data` and `x-init` directives and you **must** keep using the `x-for` loop. > Otherwise, the Alpine component that powers Toaster will start malfunctioning. ## Testing ```bash composer test ``` ## Changelog Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. ## Security If you discover any security related issues, please email support@muhammedsari.me instead of using the issue tracker. ## Credits - [Muhammed Sari](https://github.com/masmerise) - [Greg Korba](https://github.com/wirone) - [All Contributors](../../contributors) ## License The MIT License (MIT). Please see [License File](LICENSE.md) for more information. ================================================ FILE: UPGRADING.md ================================================ # Upgrade Guide ## v1 → v2 ### Minimum versions The following dependency versions have been updated: - The minimum Livewire version is now v3.0 ### Adjusting the `Toaster` import `Alpine` is now registered by Livewire automatically. Therefore, any direct `Alpine.plugin` or `Alpine.start` call will no longer work. You should adjust your `app.js` JavaScript entry point to account for this change. An example: ```diff import './bootstrap'; + import '../../vendor/masmerise/livewire-toaster/resources/js'; - import Alpine from 'alpinejs'; - import Toaster from '../../vendor/masmerise/livewire-toaster/resources/js'; - Alpine.plugin(Toaster); - window.Alpine = Alpine; - Alpine.start(); ``` ================================================ FILE: composer.json ================================================ { "name": "masmerise/livewire-toaster", "description": "Beautiful toast notifications for Laravel / Livewire.", "license": "MIT", "keywords": [ "alert", "laravel", "livewire", "toast", "toaster" ], "authors": [ { "name": "Muhammed Sari", "email": "support@muhammedsari.me", "role": "Developer" } ], "homepage": "https://github.com/masmerise/livewire-toaster", "require": { "php": "~8.2 || ~8.3 || ~8.4 || ~8.5", "illuminate/contracts": "^11.0 || ^12.0 || ^13.0", "illuminate/http": "^11.0 || ^12.0 || ^13.0", "illuminate/routing": "^11.0 || ^12.0 || ^13.0", "illuminate/support": "^11.0 || ^12.0 || ^13.0", "illuminate/view": "^11.0 || ^12.0 || ^13.0", "livewire/livewire": "^3.0 || ^4.0" }, "require-dev": { "larastan/larastan": "^2.0 || ^3.1", "laravel/pint": "^1.0", "orchestra/testbench": "^9.0 || ^10.0 || ^11.0", "phpunit/phpunit": "^10.0 || ^11.5.3 || ^12.5.12" }, "conflict": { "stevebauman/unfinalize": "*" }, "minimum-stability": "dev", "prefer-stable": true, "autoload": { "psr-4": { "Masmerise\\Toaster\\": "src" } }, "autoload-dev": { "psr-4": { "Tests\\": "tests" } }, "config": { "sort-packages": true }, "extra": { "laravel": { "aliases": { "Toaster": "Masmerise\\Toaster\\Toaster" }, "providers": [ "Masmerise\\Toaster\\ToasterServiceProvider" ] } }, "scripts": { "format": "vendor/bin/pint", "larastan": "vendor/bin/phpstan analyse --memory-limit=2G", "test": "vendor/bin/phpunit", "verify": [ "@larastan", "@test" ] } } ================================================ FILE: config/toaster.php ================================================ true, /** * The vertical alignment of the toast container. * * Supported: "bottom", "middle" or "top" */ 'alignment' => 'bottom', /** * Allow users to close toast messages prematurely. * * Supported: true | false */ 'closeable' => true, /** * The on-screen duration of each toast. * * Minimum: 3000 (in milliseconds) */ 'duration' => 3000, /** * The horizontal position of each toast. * * Supported: "center", "left" or "right" */ 'position' => 'right', /** * New toasts immediately replace similar ones, ensuring only one toast of a kind is visible at any time. * Takes precedence over the "suppress" option. * * Supported: true | false */ 'replace' => false, /** * Prevent the display of duplicate toast messages. * * Supported: true | false */ 'suppress' => false, /** * Whether messages passed as translation keys should be translated automatically. * * Supported: true | false */ 'translate' => true, ]; ================================================ FILE: phpstan.neon.dist ================================================ includes: - ./vendor/larastan/larastan/extension.neon parameters: paths: - src level: 9 ignoreErrors: - identifier: missingType.generics - identifier: missingType.iterableValue - '#Cannot access offset .* on Illuminate\\Contracts\\Foundation\\Application#' - '#Parameter \#.* of class Masmerise\\Toaster\\ToasterConfig constructor expects .*, mixed given.#' - '#Trait Masmerise\\Toaster\\Toastable is used zero times and is not analysed.#' ================================================ FILE: phpunit.xml.dist ================================================ tests ================================================ FILE: pint.json ================================================ { "preset": "psr12", "rules": { "blank_line_after_opening_tag": false, "blank_line_before_statement": { "statements": ["return"] }, "braces": { "allow_single_line_closure": true }, "concat_space": { "spacing": "one" }, "declare_strict_types": true, "method_argument_space": false, "no_extra_blank_lines": { "tokens": ["curly_brace_block", "extra", "throw", "use"] }, "no_useless_else": true, "not_operator_with_successor_space": true, "ordered_imports": { "imports_order": ["class", "function", "const"], "sort_algorithm": "alpha" }, "single_line_empty_body": true, "trailing_comma_in_multiline": true } } ================================================ FILE: resources/js/config.js ================================================ class Alignment { static Top = 'top'; constructor(value) { this.value = value; } isTop() { return this.value === Alignment.Top; } } export class Config { constructor(alignment, duration, replace, suppress) { this.alignment = new Alignment(alignment); this.duration = duration; this.replace = replace; this.suppress = suppress; } static fromJson(data) { return new Config(data.alignment, data.duration, data.replace, data.suppress); } } ================================================ FILE: resources/js/hub.js ================================================ import { Config } from './config'; import { Toast } from './toast'; export function Hub(Alpine) { Alpine.data('toasterHub', (initialToasts, config) => { config = Config.fromJson(config); return { _toasts: [], get toasts() { const toasts = this._toasts.filter(t => ! t.trashed); if (this._toasts.length && ! toasts.length) { this.$nextTick(() => { this._toasts = []; }); } return toasts; }, init() { document.addEventListener('toaster:received', event => { this.processToast(event); }); initialToasts.map(Toast.fromJson).forEach(toast => this.show(toast)); }, processToast(data) { const toast = Toast.fromJson({ duration: config.duration, ...data.detail }); if (config.replace) { this.toasts.filter(t => t.equals(toast)).forEach(t => t.dispose()); } else if (config.suppress && this.toasts.some(t => t.equals(toast))) { return; } this.show(toast); }, show(toast) { toast = Alpine.reactive(toast); toast.runAfterDuration(toast => toast.dispose()); if (config.alignment.isTop()) { this._toasts.unshift(toast); } else { this._toasts.push(toast); } }, } }); } ================================================ FILE: resources/js/index.js ================================================ import { Hub } from './hub'; import * as Toaster from './toaster'; window.Toaster = Toaster; document.addEventListener('alpine:init', () => { window.Alpine.plugin(Hub); }); ================================================ FILE: resources/js/toast.js ================================================ import { uuid41 } from './uuid41'; export class Toast { constructor(duration, message, type) { this.$el = null; this.id = uuid41(); this.isVisible = false; this.duration = duration; this.message = message; this.timeout = null; this.trashed = false; this.type = type; } static fromJson(data) { return new Toast(data.duration, data.message, data.type); } dispose() { if (this.timeout) { clearTimeout(this.timeout); } this.isVisible = false; if (this.$el) { this.$el.addEventListener('transitioncancel', () => { this.trashed = true; }) this.$el.addEventListener('transitionend', () => { this.trashed = true; }) } } equals(other) { return this.duration === other.duration && this.message === other.message && this.type === other.type; } runAfterDuration(callback) { this.timeout = setTimeout(() => callback(this), this.duration); } select(config) { return config[this.type]; } show($el) { this.$el = $el; this.isVisible = true; } } ================================================ FILE: resources/js/toaster.js ================================================ const event = (message, type) => { document.dispatchEvent(new CustomEvent('toaster:received', { detail: { message, type }})); }; const error = message => event(message, 'error'); const info = message => event(message, 'info'); const success = message => event(message, 'success'); const warning = message => event(message, 'warning'); export { error, info, success, warning } ================================================ FILE: resources/js/uuid41.js ================================================ export function uuid41() { let d = ''; while (d.length < 32) { d += Math.random().toString(16).substring(2); } const vr = ((Number.parseInt(d.substring(16, 1), 16) & 0x3) | 0x8).toString(16); return `${d.substring(0, 8)}-${d.substring(8, 4)}-4${d.substring(13, 3)}-${vr}${d.substring(17, 3)}-${d.substring(20, 12)}`; } ================================================ FILE: resources/views/close-button.blade.php ================================================ ================================================ FILE: resources/views/hub.blade.php ================================================
$alignment->is('bottom'), 'top-1/2 -translate-y-1/2' => $alignment->is('middle'), 'top-0' => $alignment->is('top'), 'items-start rtl:items-end' => $position->is('left'), 'items-center' => $position->is('center'), 'items-end rtl:items-start' => $position->is('right'), ])>
================================================ FILE: src/AccessibleCollector.php ================================================ message->value) / self::AMOUNT_OF_WORDS); $addend = $addend * self::ONE_SECOND; if ($addend > 0) { $toast = ToastBuilder::proto($toast)->duration($toast->duration->value + $addend)->get(); } $this->next->collect($toast); } public function release(): array { return $this->next->release(); } } ================================================ FILE: src/Alignment.php ================================================ value; } } ================================================ FILE: src/Collector.php ================================================ value = $value; } public static function fromMillis(int $value): self { return new self($value); } } ================================================ FILE: src/LivewireRelay.php ================================================ get('redirect'); $isRedirectingUsingNavigate = store($component)->get('redirectUsingNavigate'); if ($isRedirecting && ! $isRedirectingUsingNavigate) { return; } if ($toasts = Toaster::release()) { foreach ($toasts as $toast) { $event = new Event(self::EVENT, $toast->toArray()); $ctx->pushEffect('dispatches', $event->serialize()); } } } } ================================================ FILE: src/Message.php ================================================ value = $value; $this->replace = $replace; } public static function fromString(string $value): self { return new self($value); } public static function fromTranslatable(string $value, array $replace = []): self { return new self($value, $replace); } public function equals(Message|string $other): bool { if ($other instanceof Message) { $other = $other->value; } return $other === $this->value; } } ================================================ FILE: src/PendingToast.php ================================================ builder = ToastBuilder::create()->duration($duration); } public static function create(): self { return new self(Toaster::config()->duration); } public function dispatch(): void { $toast = $this->builder->get(); Toaster::collect($toast); $this->dispatched = true; } public function __call(string $name, array $arguments): mixed { $result = $this->forwardCallTo($this->builder, $name, $arguments); if ($result instanceof ToastBuilder) { $this->builder = $result; return $this; } return $result; } public function __destruct() { if (! $this->dispatched) { $this->dispatch(); } } } ================================================ FILE: src/Position.php ================================================ toasts[] = $toast; } public function release(): array { $toasts = $this->toasts; $this->toasts = []; return $toasts; } } ================================================ FILE: src/SessionRelay.php ================================================ app->resolved(Collector::class)) { return $response; } if ($toasts = $this->app[Collector::class]->release()) { $this->app[Session::class]->put(self::NAME, $this->serialize($toasts)); } return $response; } private function serialize(array $toasts): array { return array_map(static fn (Toast $toast) => $toast->toArray(), $toasts); } } ================================================ FILE: src/TestableCollector.php ================================================ toasts[] = $toast; } public function release(): array { $toasts = $this->toasts; $this->toasts = []; return $toasts; } public function assertDispatched(string $message): void { $toasts = array_filter($this->toasts, static fn (Toast $toast) => $toast->message->equals($message)); PHPUnit::assertNotEmpty($toasts, "A toast with the message `{$message}` was not dispatched."); } public function assertNothingDispatched(): void { $count = count($this->toasts); PHPUnit::assertSame(0, $count, "{$count} unexpected toasts were dispatched."); } } ================================================ FILE: src/Toast.php ================================================ $this->duration->value, 'message' => $this->message->value, 'type' => $this->type->value, ]; } } ================================================ FILE: src/ToastBuilder.php ================================================ duration = $toast->duration; $builder->message = $toast->message; $builder->type = $toast->type; return $builder; } public function duration(int $milliseconds): self { return $this->modify('duration', Duration::fromMillis($milliseconds)); } public function error(): self { return $this->modify('type', ToastType::Error); } public function info(): self { return $this->modify('type', ToastType::Info); } public function message(string $message, array $replace = []): self { return $this->modify('message', Message::fromTranslatable($message, $replace)); } public function success(): self { return $this->modify('type', ToastType::Success); } public function type(string $type): self { return $this->modify('type', ToastType::from($type)); } public function warning(): self { return $this->modify('type', ToastType::Warning); } public function get(): Toast { if (! $this->duration instanceof Duration) { throw new UnexpectedValueException('You must provide a valid duration.'); } if (! $this->message instanceof Message) { throw new UnexpectedValueException('You must provide a valid message.'); } if (! $this->type instanceof ToastType) { throw new UnexpectedValueException('You must choose a valid type.'); } return new Toast($this->message, $this->duration, $this->type); } private function modify(string $property, mixed $value): self { $that = clone $this; $that->{$property} = $value; return $that; } } ================================================ FILE: src/ToastType.php ================================================ macro('error'); } protected function info(): Closure { return $this->macro('info'); } protected function success(): Closure { return $this->macro('success'); } protected function warning(): Closure { return $this->macro('warning'); } private function macro(string $type): Closure { return function (string $message, array $replace = []) use ($type) { Toaster::toast()->type($type)->message($message, $replace); return $this; }; } } ================================================ FILE: src/Toaster.php ================================================ message($message, $replace)->error(); } public static function fake(): TestableCollector { self::swap($fake = new TestableCollector()); return $fake; } public static function info(string $message, array $replace = []): PendingToast { return self::toast()->message($message, $replace)->info(); } public static function success(string $message, array $replace = []): PendingToast { return self::toast()->message($message, $replace)->success(); } public static function toast(): PendingToast { return PendingToast::create(); } public static function warning(string $message, array $replace = []): PendingToast { return self::toast()->message($message, $replace)->warning(); } protected static function getFacadeAccessor(): string { return ToasterServiceProvider::NAME; } protected static function getMockableClass(): string { return Collector::class; } } ================================================ FILE: src/ToasterConfig.php ================================================ alignment); } public function position(): Position { return Position::from($this->position); } public function toJavaScript(): array { return [ 'alignment' => $this->alignment, 'duration' => $this->duration, 'replace' => $this->wantsReplacement, 'suppress' => $this->wantsSuppression, ]; } } ================================================ FILE: src/ToasterHub.php ================================================ view($this->view, [ 'alignment' => $this->config->alignment(), 'closeable' => $this->config->wantsCloseableToasts, 'config' => $this->config->toJavaScript(), 'position' => $this->config->position(), 'toasts' => $this->session->pull(SessionRelay::NAME, []), ]); } } ================================================ FILE: src/ToasterServiceProvider.php ================================================ loadViewsFrom(__DIR__ . '/../resources/views', self::NAME); if ($this->app->runningInConsole()) { $this->registerPublishing(); } $this->callAfterResolving(BladeCompiler::class, $this->aliasToasterHub(...)); $this->callAfterResolving(Collector::class, $this->relayToLivewire(...)); $this->callAfterResolving(Router::class, $this->relayToSession(...)); Redirector::mixin($macros = new ToastableMacros()); RedirectResponse::mixin($macros); } public function register(): void { $config = $this->configureService(); parent::register(); $this->app->scoped(Collector::class, QueuingCollector::class); $this->app->alias(Collector::class, self::NAME); if ($config->wantsAccessibility) { $this->app->extend(Collector::class, static fn (Collector $next) => new AccessibleCollector($next)); } if ($config->wantsTranslation) { $this->app->extend(Collector::class, fn (Collector $next) => new TranslatingCollector($next, $this->app['translator'])); } } private function aliasToasterHub(BladeCompiler $blade): void { $blade->component(ToasterHub::NAME, ToasterHub::class); } private function configureService(): ToasterConfig { $this->mergeConfigFrom(__DIR__ . '/../config/toaster.php', self::NAME); $config = ToasterConfig::fromArray($this->app['config'][self::NAME] ?? []); $this->app->instance(ToasterConfig::class, $config); $this->app->alias(ToasterConfig::class, self::CONFIG); return $config; } private function registerPublishing(): void { $this->publishes([ __DIR__ . '/../config/toaster.php' => $this->app->configPath('toaster.php'), ], 'toaster-config'); $this->publishes([ __DIR__ . '/../resources/views' => $this->app->resourcePath('views/vendor/toaster'), ], 'toaster-views'); } private function relayToLivewire(): void { $this->app[LivewireManager::class]->listen('dehydrate', new LivewireRelay()); } private function relayToSession(Router $router): void { $router->aliasMiddleware(SessionRelay::NAME, SessionRelay::class); $router->pushMiddlewareToGroup('web', SessionRelay::NAME); } } ================================================ FILE: src/TranslatingCollector.php ================================================ translator->get($original = $toast->message->value, $toast->message->replace); if (is_string($replacement) && $replacement !== $original) { $toast = ToastBuilder::proto($toast)->message($replacement)->get(); } $this->next->collect($toast); } public function release(): array { return $this->next->release(); } } ================================================ FILE: tests/AccessibleCollectorTest.php ================================================ aCollector()); $message = Message::fromTranslatable(str_repeat('word ', 223)); $collector->collect($this->aToast(message: $message)); [$toast] = $collector->release(); $this->assertSame(5000, $toast->duration->value); } } ================================================ FILE: tests/CollectorFactoryMethods.php ================================================ get('inject', [ToastController::class, 'inject'])->middleware('web'); $router->get('multiple', [ToastController::class, 'multiple'])->middleware('web'); } #[Test] public function multiple_toasts_can_be_dispatched(): void { $this->get('multiple')->assertOk()->assertSessionHas('toasts', [ [ 'duration' => 3000, 'message' => 'The biggest battle is the war against ignorance. - Mustafa Kemal Atatürk', 'type' => 'warning', ], [ 'duration' => 3333, 'message' => 'Life is available only in the present moment. - Thich Nhat Hanh', 'type' => 'error', ], ]); } #[Test] public function toast_is_flashed_to_the_session_using_dependency_injection(): void { $this->get('inject')->assertOk()->assertSessionHas('toasts', [[ 'duration' => 4000, 'message' => 'The biggest battle is the war against ignorance. - Mustafa Kemal Atatürk', 'type' => 'success', ]]); } } final class ToastController { use Toastable; public function inject(Collector $toasts): array { $toast = ToastBuilder::create() ->success() ->duration(4000) ->message('The biggest battle is the war against ignorance. - Mustafa Kemal Atatürk') ->get(); $toasts->collect($toast); return ['message' => 'ok']; } public function multiple(): array { $this->warning('The biggest battle is the war against ignorance. - Mustafa Kemal Atatürk'); Toaster::error('Life is available only in the present moment. - Thich Nhat Hanh') ->duration(3333) ->error() ->dispatch(); return ['message' => 'ok']; } } ================================================ FILE: tests/DurationTest.php ================================================ assertSame(3000, $duration->value); $this->expectExceptionMessage(InvalidArgumentException::class); $this->expectExceptionMessage('The duration value must be at least 3000 ms.'); Duration::fromMillis(999); } } ================================================ FILE: tests/LivewireRelayTest.php ================================================ skip $component = Livewire::test(TestComponent::class); $component->assertNotDispatched(LivewireRelay::EVENT); // redirect => skip $component->call('redirectingAction'); $component->assertNotDispatched(LivewireRelay::EVENT); // redirect using navigate => dispatch $component->call('redirectingActionUsingNavigate'); $component->assertDispatched(LivewireRelay::EVENT, duration: 3000, message: 'Crispy toasts', type: 'success', ); // normal action => dispatch $component->call('normalAction'); $component->assertDispatched(LivewireRelay::EVENT, duration: 3000, message: 'Crispy toasts', type: 'success', ); } } final class TestComponent extends Component { use ToastFactoryMethods; public function normalAction(): void { // noop } public function redirectingAction(): void { $this->redirect('https://localhost'); } public function redirectingActionUsingNavigate(): void { $this->redirect('https://localhost', true); } public function render(Collector $toasts): string { $toasts->collect($this->aToast()); // trigger relay registration return '
'; } } ================================================ FILE: tests/LivewireTest.php ================================================ component = Livewire::test(ToastComponent::class); } #[Test] public function multiple_toasts_can_be_dispatched(): void { $this->component->call('multiple'); $this->assertCount(2, $events = $this->component->effects['dispatches']); [$eventA, $eventB] = $events; $this->assertSame('toaster:received', $eventA['name']); $this->assertSame('toaster:received', $eventB['name']); $this->assertSame([ 'duration' => 3000, 'message' => 'The biggest battle is the war against ignorance. - Mustafa Kemal Atatürk', 'type' => 'warning', ], $eventA['params']); $this->assertSame([ 'duration' => 3333, 'message' => 'Life is available only in the present moment. - Thich Nhat Hanh', 'type' => 'error', ], $eventB['params']); } #[Test] public function toast_is_dispatched_to_the_browser_using_dependency_injection(): void { $this->component->call('inject'); $this->assertCount(1, $events = $this->component->effects['dispatches']); [$event] = $events; $this->assertSame([ 'duration' => 4000, 'message' => 'The biggest battle is the war against ignorance. - Mustafa Kemal Atatürk', 'type' => 'success', ], $event['params']); } } final class ToastComponent extends Component { use Toastable; public function inject(Collector $toasts): void { $toast = ToastBuilder::create() ->success() ->duration(4000) ->message('The biggest battle is the war against ignorance. - Mustafa Kemal Atatürk') ->get(); $toasts->collect($toast); } public function multiple(): void { $this->warning('The biggest battle is the war against ignorance. - Mustafa Kemal Atatürk'); Toaster::error('Life is available only in the present moment. - Thich Nhat Hanh') ->duration(3333) ->dispatch(); } public function render(): string { return '
'; } } ================================================ FILE: tests/MessageTest.php ================================================ 'Tangerine']); $this->assertSame('Clementine :name', $message->value); $this->assertSame(['name' => 'Tangerine'], $message->replace); $this->expectExceptionMessage(InvalidArgumentException::class); $this->expectExceptionMessage('The message value cannot be empty.'); Message::fromString(' '); } } ================================================ FILE: tests/PendingToastTest.php ================================================ 'terms'])->get(); $this->assertSame([ 'duration' => 3000, 'message' => 'validation.accepted', 'type' => 'error', ], $error->toArray()); $info = Toaster::info('Informational')->duration(3500)->get(); $this->assertSame([ 'duration' => 3500, 'message' => 'Informational', 'type' => 'info', ], $info->toArray()); $success = Toaster::success('Successful')->get(); $this->assertSame([ 'duration' => 3000, 'message' => 'Successful', 'type' => 'success', ], $success->toArray()); $warning = Toaster::warning('passwords.reset')->get(); $this->assertSame([ 'duration' => 3000, 'message' => 'passwords.reset', 'type' => 'warning', ], $warning->toArray()); } #[Test] public function it_can_be_instantiated_with_defaults(): void { $toast = PendingToast::create()->message('test')->success()->get(); $this->assertSame([ 'duration' => 3000, // config default 'message' => 'test', 'type' => 'success', ], $toast->toArray()); } #[Test] public function it_will_automatically_dispatch_the_toast_upon_destruction(): void { Toaster::shouldReceive('collect')->once(); PendingToast::create() ->duration(4000) ->error() ->message('Uvuvuvwevwe Onyetenyevwe Ughemuhwem Osas'); } #[Test] public function it_will_only_dispatch_once(): void { Toaster::shouldReceive('collect')->once(); PendingToast::create() ->duration(4000) ->success() ->message('Uvuvuvwevwe Onyetenyevwe Ughemuhwem Osas') ->dispatch(); } } ================================================ FILE: tests/QueuingCollectorTest.php ================================================ aCollector(); $collector->collect($toastA = $this->aToast()); $collector->collect($toastB = $this->aToast()); $toasts = $collector->release(); $this->assertCount(2, $toasts); $this->assertSame($toastA, $toasts[0]); $this->assertSame($toastB, $toasts[1]); $this->assertEmpty($collector->release()); } } ================================================ FILE: tests/SessionRelayTest.php ================================================ app['session.store']; $relay = new SessionRelay($this->app); $relay->handle(new Request(), function () {}); $this->assertFalse($session->exists(SessionRelay::NAME)); $collector = $this->app[Collector::class]; $collector->collect($this->aToast()); $collector->collect($this->aToast()); $relay->handle(new Request(), function () {}); $this->assertTrue($session->exists(SessionRelay::NAME)); $this->assertCount(2, $toasts = $session->get(SessionRelay::NAME)); $this->assertEmpty($collector->release()); $this->assertIsArray($toast = $toasts[0]); $this->assertArrayHasKey('duration', $toast); $this->assertArrayHasKey('message', $toast); $this->assertArrayHasKey('type', $toast); } } ================================================ FILE: tests/TestCase.php ================================================ assertNothingDispatched(); $instance->collect($this->aToast()); $instance->assertDispatched('Crispy toasts'); } } ================================================ FILE: tests/ToastBuilderTest.php ================================================ message('Rice cooker'); $builderC = $builderB->success(); $builderD = $builderC->duration(4000); $toast = $builderD->get(); $this->assertNotSame($builderA, $builderB); $this->assertNotSame($builderB, $builderC); $this->assertNotSame($builderC, $builderD); $this->assertSame('Rice cooker', $toast->message->value); $this->assertSame(ToastType::Success, $toast->type); $this->assertSame(4000, $toast->duration->value); } #[Test] public function it_throws_if_the_builder_data_is_incomplete_to_build_a_toast(): void { $this->expectException(UnexpectedValueException::class); $this->expectExceptionMessage('You must provide a valid duration.'); ToastBuilder::create()->get(); } } ================================================ FILE: tests/ToastFactoryMethods.php ================================================ Message::fromString('Crispy toasts'), 'duration' => Duration::fromMillis(3000), 'type' => ToastType::Success, ...$values, ]); } } ================================================ FILE: tests/ToastTest.php ================================================ aToast(); $result = $toast->toArray(); $this->assertSame([ 'duration' => 3000, 'message' => 'Crispy toasts', 'type' => 'success', ], $result); } } ================================================ FILE: tests/ToastableMacrosTest.php ================================================ get('redirect', [ToastableMacroController::class, 'redirect'])->middleware('web'); } #[Test] public function redirect(): void { $this->get('redirect')->assertSessionHas('toasts'); } } final class ToastableMacroController { public function redirect(): RedirectResponse { return redirect() ->away('https://www.google.com') ->success('Success Macro!'); } } ================================================ FILE: tests/ToastableTest.php ================================================ app['router']->get('test', [ToastableController::class, 'index'])->middleware('web'); $response = $this->get('test'); $response->assertOk()->assertSessionHas(SessionRelay::NAME, [[ 'duration' => 3000, 'message' => 'I am a crispy toast, yummy!', 'type' => 'info', ]]); } #[Test] public function it_can_be_invoked_from_livewire_components(): void { $component = Livewire::test(ToastableComponent::class); $component->call('bake'); $component->assertDispatched(LivewireRelay::EVENT, duration: 3000, message: 'I became a crispy toast, yummy!', type: 'success', ); } } final class ToastableComponent extends Component { use Toastable; public function bake(): void { $this->success('I became a crispy toast, yummy!'); } public function render(): string { return '
'; } } final class ToastableController { use Toastable; public function index(): array { $this->info('I am a crispy toast, yummy!'); return ['message' => 'success']; } } ================================================ FILE: tests/ToasterConfigTest.php ================================================ toJavaScript(); $this->assertSame([ 'alignment' => 'bottom', 'duration' => 3000, 'replace' => false, 'suppress' => false, ], $array); } } ================================================ FILE: tests/ToasterHubTest.php ================================================ Alignment::Bottom->value, 'closeable' => true, 'position' => Position::Right->value]]; yield [['alignment' => Alignment::Middle->value, 'closeable' => true, 'position' => Position::Right->value]]; yield [['alignment' => Alignment::Top->value, 'closeable' => true, 'position' => Position::Right->value]]; yield [['alignment' => Alignment::Bottom->value, 'closeable' => false, 'position' => Position::Left->value]]; yield [['alignment' => Alignment::Middle->value, 'closeable' => false, 'position' => Position::Left->value]]; yield [['alignment' => Alignment::Top->value, 'closeable' => false, 'position' => Position::Left->value]]; yield [['alignment' => Alignment::Bottom->value, 'closeable' => false, 'position' => Position::Center->value]]; yield [['alignment' => Alignment::Middle->value, 'closeable' => false, 'position' => Position::Center->value]]; yield [['alignment' => Alignment::Top->value, 'closeable' => false, 'position' => Position::Center->value]]; } #[DataProvider('configurations')] #[Test] public function it_can_be_rendered(array $config): void { $component = $this->component(ToasterHub::class, ['config' => ToasterConfig::fromArray($config)]); $component->assertSee('toaster'); } } ================================================ FILE: tests/ToasterServiceProviderTest.php ================================================ app->register(ToasterServiceProvider::class); } #[Test] public function it_binds_the_service_as_a_singleton(): void { $this->assertTrue($this->app->isShared(Collector::class)); $this->assertTrue($this->app->isAlias(ToasterServiceProvider::NAME)); } #[Test] public function it_registers_the_translating_behaviour_only_if_enabled_in_the_config(): void { $this->assertInstanceOf(TranslatingCollector::class, $this->app[ToasterServiceProvider::NAME]); $this->refreshApplication(); $this->app['config']->set('toaster.translate', false); $this->app->register(ToasterServiceProvider::class); $this->assertInstanceOf(AccessibleCollector::class, $this->app[ToasterServiceProvider::NAME]); } #[Test] public function it_registers_macros(): void { $this->assertTrue(Redirector::hasMacro('error')); $this->assertTrue(Redirector::hasMacro('info')); $this->assertTrue(Redirector::hasMacro('success')); $this->assertTrue(Redirector::hasMacro('warning')); $this->assertTrue(RedirectResponse::hasMacro('error')); $this->assertTrue(RedirectResponse::hasMacro('info')); $this->assertTrue(RedirectResponse::hasMacro('success')); $this->assertTrue(RedirectResponse::hasMacro('warning')); } #[Test] public function it_registers_custom_config_object(): void { $this->assertTrue($this->app->bound(ToasterConfig::class)); $config = $this->app[ToasterConfig::class]; $this->assertSame(3000, $config->duration); $this->assertTrue($config->wantsAccessibility); $this->assertTrue($config->wantsCloseableToasts); $this->assertTrue($config->wantsTranslation); $this->assertSame('right', $config->position); } } ================================================ FILE: tests/TranslatingCollectorTest.php ================================================ aCollector(), $this->app['translator']); $message = Message::fromTranslatable('auth.throttle', ['seconds' => 1337]); $collector->collect($this->aToast(message: $message)); [$toast] = $collector->release(); $this->assertSame('Too many login attempts. Please try again in 1337 seconds.', $toast->message->value); } #[Test] public function it_doesnt_replace_array_resolved_translations(): void { $collector = new TranslatingCollector($this->aCollector(), $this->app['translator']); $message = Message::fromTranslatable('validation.size'); $collector->collect($this->aToast(message: $message)); [$toast] = $collector->release(); $this->assertSame('validation.size', $toast->message->value); } #[Test] public function it_doesnt_modify_regular_strings(): void { $collector = new TranslatingCollector($this->aCollector(), $this->app['translator']); $collector->collect($this->aToast()); [$toast] = $collector->release(); $this->assertSame('Crispy toasts', $toast->message->value); } }