From 4c90c3668a6044838d6e57239c54ae439a6f3b88 Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Mon, 6 Jan 2025 12:36:58 -0500 Subject: [PATCH] Add some new resources and examples --- composer.json | 3 + docs/articles.md | 6 + docs/navigation.json | 43 +++-- docs/videos.md | 12 ++ examples/Cart/README.md | 4 + examples/Cart/navigation.json | 55 ++++++ .../Cart/src/Console/Commands/ShopCommand.php | 96 ++++++++++ examples/Cart/src/Events/CheckedOut.php | 34 ++++ examples/Cart/src/Events/ItemAddedToCart.php | 50 +++++ .../Cart/src/Events/ItemRemovedFromCart.php | 35 ++++ examples/Cart/src/Events/ItemRestocked.php | 18 ++ examples/Cart/src/States/CartState.php | 17 ++ examples/Cart/src/States/ItemState.php | 27 +++ examples/Cart/tests/CartTest.php | 179 ++++++++++++++++++ 14 files changed, 558 insertions(+), 21 deletions(-) create mode 100644 docs/articles.md create mode 100644 docs/videos.md create mode 100644 examples/Cart/README.md create mode 100644 examples/Cart/navigation.json create mode 100644 examples/Cart/src/Console/Commands/ShopCommand.php create mode 100644 examples/Cart/src/Events/CheckedOut.php create mode 100644 examples/Cart/src/Events/ItemAddedToCart.php create mode 100644 examples/Cart/src/Events/ItemRemovedFromCart.php create mode 100644 examples/Cart/src/Events/ItemRestocked.php create mode 100644 examples/Cart/src/States/CartState.php create mode 100644 examples/Cart/src/States/ItemState.php create mode 100644 examples/Cart/tests/CartTest.php diff --git a/composer.json b/composer.json index 6f1c14ca..45df19c3 100644 --- a/composer.json +++ b/composer.json @@ -49,6 +49,9 @@ "autoload-dev": { "psr-4": { "Thunk\\Verbs\\Tests\\": "tests/", + "Thunk\\Verbs\\Examples\\Cart\\": "examples/Cart/src/", + "Thunk\\Verbs\\Examples\\Cart\\Tests\\": "examples/Cart/tests/", + "Thunk\\Verbs\\Examples\\Cart\\Database\\Factories\\": "examples/Cart/database/factories/", "Thunk\\Verbs\\Examples\\Counter\\": "examples/Counter/src/", "Thunk\\Verbs\\Examples\\Counter\\Tests\\": "examples/Counter/tests/", "Thunk\\Verbs\\Examples\\Counter\\Database\\Factories\\": "examples/Counter/database/factories/", diff --git a/docs/articles.md b/docs/articles.md new file mode 100644 index 00000000..314eb4f4 --- /dev/null +++ b/docs/articles.md @@ -0,0 +1,6 @@ +This is a collection of articles and blog posts that have been published about Verbs. If you have written something +about Verbs, feel free to [submit a PR](https://github.com/hirethunk/verbs/blob/main/docs/articles.md) to add it +to the list. + +- [Models in Verbs](https://cmorrell.com/models-in-verbs) (Chris Morrell - 2024) +- [Verbs Validation Errors](https://cmorrell.com/verbs-errors) (Chris Morrell - 2024) diff --git a/docs/navigation.json b/docs/navigation.json index bf09b309..85d0ed4a 100644 --- a/docs/navigation.json +++ b/docs/navigation.json @@ -41,10 +41,32 @@ } ] }, + { + "title": "Resources", + "slug": "resources", + "items": [ + { + "title": "Videos", + "slug": "videos", + "icon": "tv" + }, + { + "title": "Articles", + "slug": "articles", + "icon": "newspaper" + } + ] + }, { "title": "Examples", "slug": "examples", "items": [ + { + "title": "Shopping Cart", + "slug": "cart", + "icon": "shopping-cart", + "url": "/examples/cart" + }, { "title": "Board Game", "slug": "monopoly", @@ -105,26 +127,5 @@ "icon": "circle-stack" } ] - }, - { - "title": "Packages", - "slug": "verbs-packages", - "items": [ - { - "title": "Verbs Commands", - "slug": "verbs-commands", - "icon": "window" - }, - { - "title": "Verbs History", - "slug": "verbs-history", - "icon": "book-open" - }, - { - "title": "Verbs Livewire", - "slug": "verbs-livewire", - "icon": "bolt" - } - ] } ] diff --git a/docs/videos.md b/docs/videos.md new file mode 100644 index 00000000..6a960790 --- /dev/null +++ b/docs/videos.md @@ -0,0 +1,12 @@ +> [!note] +> These videos are relatively off-the-cuff and are using Verbs pre-releases. Some features +> may change, or best practices may emerge prior to version 1.0 that will make these videos +> obsolete. Once Verbs 1.0 is released, we plan on publishing a full, produced video series +> on Verbs. + +------- + +Chris has posted a couple of introductory videos to YouTube. You can watch the playlist +below, or [view it on YouTube](https://www.youtube.com/playlist?list=PLfp8k2-5wAK5Knn7HT43k1MJca08q_1nP). + +https://youtube.com/playlist?list=PLfp8k2-5wAK5Knn7HT43k1MJca08q_1nP&si=8wbEtU4Sz6Qp3A5_ diff --git a/examples/Cart/README.md b/examples/Cart/README.md new file mode 100644 index 00000000..75155b28 --- /dev/null +++ b/examples/Cart/README.md @@ -0,0 +1,4 @@ +# Shopping Cart Example + +This example is the code written in Chris's [Using state in Verbs](https://www.youtube.com/watch?v=d6_ggotpb_8&t=594s) +YouTube video. Tests and a few comments have been added. diff --git a/examples/Cart/navigation.json b/examples/Cart/navigation.json new file mode 100644 index 00000000..009b18d0 --- /dev/null +++ b/examples/Cart/navigation.json @@ -0,0 +1,55 @@ +[ + { + "title": "Intro", + "slug": "intro", + "items": [ + { + "title": "Shopping Cart Example", + "slug": "readme", + "path": "README.md" + } + ] + }, + { + "title": "Events", + "slug": "events", + "items": [ + { + "title": "ItemRestocked", + "slug": "restock", + "path": "src/Events/ItemRestocked.php" + }, + { + "title": "ItemAddedToCart", + "slug": "add-to-cart", + "path": "src/Events/ItemAddedToCart.php" + }, + { + "title": "ItemRemovedFromCart", + "slug": "remove-from-cart", + "path": "src/Events/ItemRemovedFromCart.php" + }, + { + "title": "CheckedOut", + "slug": "checkout", + "path": "src/Events/CheckedOut.php" + } + ] + }, + { + "title": "State", + "slug": "state", + "items": [ + { + "title": "CartState", + "slug": "cart", + "path": "src/States/CartState.php" + }, + { + "title": "ItemState", + "slug": "item", + "path": "src/States/ItemState.php" + } + ] + } +] diff --git a/examples/Cart/src/Console/Commands/ShopCommand.php b/examples/Cart/src/Console/Commands/ShopCommand.php new file mode 100644 index 00000000..c42984d8 --- /dev/null +++ b/examples/Cart/src/Console/Commands/ShopCommand.php @@ -0,0 +1,96 @@ +setup(); + + do { + try { + $action = $this->action(); + $this->getLaravel()->forgetScopedInstances(); // Emulate a new request + $action(); + } catch (Exception $exception) { + error("Error: {$exception->getMessage()}"); + } + } while (true); + } + + protected function action(): Closure + { + $selection = select( + label: 'What would you like to do?', + options: [ + 'Add item to cart', + 'Remove item from cart', + 'Check out', + 'Restock items', + ] + ); + + return match ($selection) { + 'Add item to cart' => function () { + [$item, $quantity] = $this->selectSticker(); + ItemAddedToCart::commit(cart: $this->cart_id, item: $item, quantity: $quantity); + }, + 'Remove item from cart' => function () { + [$item, $quantity] = $this->selectSticker(); + ItemRemovedFromCart::commit(cart: $this->cart_id, item: $item, quantity: $quantity); + }, + 'Check out' => function () { + CheckedOut::commit(cart: $this->cart_id); + }, + 'Restock items' => function () { + [$sticker, $quantity] = $this->selectSticker(4); + ItemRestocked::commit(item: $sticker, quantity: (int) $quantity); + }, + }; + } + + protected function selectSticker($default_quantity = 1): array + { + $sticker = select('Which sticker?', $this->stickers); + $quantity = (int) text('Quantity', default: $default_quantity, required: true, validate: ['quantity' => 'required|int|min:1']); + + return [$sticker, $quantity]; + } + + protected function setup() + { + // Each time we load our app we'll create a new shopping cart by assigning + // it a unique ID. + + $this->cart_id = snowflake_id(); + + // These are entirely arbitrary IDs. They're just hard-coded so that they're + // consistent across separate runs of the app. + + $this->stickers = [ + 1000 => 'PHPĂ—Philly', + 1001 => 'Ignore Prev. Instructions', + 1002 => 'Verbs', + 1003 => 'Over Engineered', + ]; + } +} diff --git a/examples/Cart/src/Events/CheckedOut.php b/examples/Cart/src/Events/CheckedOut.php new file mode 100644 index 00000000..41b8f0ec --- /dev/null +++ b/examples/Cart/src/Events/CheckedOut.php @@ -0,0 +1,34 @@ +assert(! $this->cart->checked_out, 'Already checked out'); + + foreach ($this->cart->items as $item_id => $quantity) { + $item = ItemState::load($item_id); + $held = $item->activeHolds()[$this->cart->id]['quantity'] ?? 0; + $this->assert($held + $item->available() >= $quantity, 'Some items in your cart are out of stock'); + } + } + + public function apply() + { + foreach ($this->cart->items as $item_id => $quantity) { + $item = ItemState::load($item_id); + $item->quantity -= $quantity; + unset($item->holds[$this->cart->id]); + } + + $this->cart->checked_out = true; + } +} diff --git a/examples/Cart/src/Events/ItemAddedToCart.php b/examples/Cart/src/Events/ItemAddedToCart.php new file mode 100644 index 00000000..2a96b9dc --- /dev/null +++ b/examples/Cart/src/Events/ItemAddedToCart.php @@ -0,0 +1,50 @@ +assert(! $this->cart->checked_out, 'Already checked out'); + + $this->assert( + $this->item->available() >= $this->quantity, + 'Out of stock', + ); + + $this->assert( + $this->cart->count($this->item->id) + $this->quantity <= self::$item_limit, + 'Reached limit' + ); + } + + public function apply() + { + // Add (additional?) quantity to cart + $this->cart->items[$this->item->id] = $this->cart->count($this->item->id) + $this->quantity; + + // Initialize hold to 0 if it doesn't already exist + $this->item->holds[$this->cart->id] ??= [ + 'quantity' => 0, + 'expires' => now()->unix() + self::$hold_seconds, + ]; + + // Add quantity to hold + $this->item->holds[$this->cart->id]['quantity'] += $this->quantity; + } +} diff --git a/examples/Cart/src/Events/ItemRemovedFromCart.php b/examples/Cart/src/Events/ItemRemovedFromCart.php new file mode 100644 index 00000000..0f46a17a --- /dev/null +++ b/examples/Cart/src/Events/ItemRemovedFromCart.php @@ -0,0 +1,35 @@ +assert(! $this->cart->checked_out, 'Already checked out'); + + $this->assert( + $this->cart->count($this->item->id) >= $this->quantity, + "There aren't {$this->quantity} items in the cart to remove." + ); + } + + public function apply() + { + $this->cart->items[$this->item->id] -= $this->quantity; + + if (isset($this->item->holds[$this->cart->id])) { + $this->item->holds[$this->cart->id]['quantity'] -= $this->quantity; + } + } +} diff --git a/examples/Cart/src/Events/ItemRestocked.php b/examples/Cart/src/Events/ItemRestocked.php new file mode 100644 index 00000000..84ac49bc --- /dev/null +++ b/examples/Cart/src/Events/ItemRestocked.php @@ -0,0 +1,18 @@ +item->quantity += $this->quantity; + } +} diff --git a/examples/Cart/src/States/CartState.php b/examples/Cart/src/States/CartState.php new file mode 100644 index 00000000..83f8cc2d --- /dev/null +++ b/examples/Cart/src/States/CartState.php @@ -0,0 +1,17 @@ +items[$item_id] ?? 0; + } +} diff --git a/examples/Cart/src/States/ItemState.php b/examples/Cart/src/States/ItemState.php new file mode 100644 index 00000000..def379b0 --- /dev/null +++ b/examples/Cart/src/States/ItemState.php @@ -0,0 +1,27 @@ +quantity - $this->activeHoldCount(); + } + + public function activeHolds(): array + { + return $this->holds = array_filter($this->holds, fn ($hold) => $hold['expires'] > now()->unix()); + } + + protected function activeHoldCount(): mixed + { + return collect($this->activeHolds())->sum('quantity'); + } +} diff --git a/examples/Cart/tests/CartTest.php b/examples/Cart/tests/CartTest.php new file mode 100644 index 00000000..c36f3fa3 --- /dev/null +++ b/examples/Cart/tests/CartTest.php @@ -0,0 +1,179 @@ +assertEquals(0, $item->quantity); + + ItemRestocked::fire(item: $item, quantity: 4); + + $this->assertEquals(4, $item->quantity); + + ItemRestocked::fire(item: $item, quantity: 2); + + $this->assertEquals(6, $item->quantity); +}); + +it('can have items added to it', function () { + $item = ItemState::load(snowflake_id()); + $cart = CartState::load(snowflake_id()); + + $this->assertEquals(0, $item->available()); + $this->assertEquals(0, $cart->count($item->id)); + + $this->assertThrows(fn () => ItemAddedToCart::fire(cart: $cart, item: $item, quantity: 4)); + + ItemRestocked::fire(item: $item, quantity: 4); + + $this->assertEquals(4, $item->available()); + $this->assertEquals(0, $cart->count($item->id)); + + ItemAddedToCart::fire(cart: $cart, item: $item, quantity: 2); + + $this->assertEquals(2, $item->available()); + $this->assertEquals(2, $cart->count($item->id)); +}); + +it('enforces item limits', function () { + ItemAddedToCart::$item_limit = 2; + + $item1 = ItemState::load(snowflake_id()); + $item2 = ItemState::load(snowflake_id()); + $cart = CartState::load(snowflake_id()); + + ItemRestocked::fire(item: $item1, quantity: 100); + ItemRestocked::fire(item: $item2, quantity: 100); + + ItemAddedToCart::fire(cart: $cart, item: $item1, quantity: 2); + ItemAddedToCart::fire(cart: $cart, item: $item2, quantity: 2); + + $this->assertThrows(fn () => ItemAddedToCart::fire(cart: $cart, item: $item1, quantity: 1)); + $this->assertThrows(fn () => ItemAddedToCart::fire(cart: $cart, item: $item2, quantity: 1)); +}); + +it('reserves items for a configured number of seconds', function () { + ItemAddedToCart::$hold_seconds = 10; + Date::setTestNow(); + + $item = ItemState::load(snowflake_id()); + $cart1 = CartState::load(snowflake_id()); + $cart2 = CartState::load(snowflake_id()); + + ItemRestocked::fire(item: $item, quantity: 2); + + ItemAddedToCart::fire(cart: $cart1, item: $item, quantity: 2); + + $this->assertThrows(fn () => ItemAddedToCart::fire(cart: $cart2, item: $item, quantity: 2)); + + Date::setTestNow(now()->addSeconds(11)); + + ItemAddedToCart::fire(cart: $cart2, item: $item, quantity: 2); + + $this->assertEquals(2, $cart2->count($item->id)); + $this->assertNotTrue(isset($item->activeHolds()[$cart1->id])); + $this->assertEquals(2, $item->activeHolds()[$cart2->id]['quantity']); +}); + +it('can have items removed from it', function () { + $item = ItemState::load(snowflake_id()); + $cart = CartState::load(snowflake_id()); + + ItemRestocked::fire(item: $item, quantity: 2); + + $this->assertThrows(fn () => ItemRemovedFromCart::fire(cart: $cart, item: $item, quantity: 1)); + + ItemAddedToCart::fire(cart: $cart, item: $item, quantity: 2); + ItemRemovedFromCart::fire(cart: $cart, item: $item, quantity: 1); + + $this->assertEquals(1, $item->available()); + $this->assertEquals(1, $cart->count($item->id)); + + ItemRemovedFromCart::fire(cart: $cart, item: $item, quantity: 1); + + $this->assertThrows(fn () => ItemRemovedFromCart::fire(cart: $cart, item: $item, quantity: 1)); +}); + +it('allows checking out', function () { + $cart = CartState::load(snowflake_id()); + $item1 = ItemState::load(snowflake_id()); + $item2 = ItemState::load(snowflake_id()); + + ItemRestocked::fire(item: $item1, quantity: 2); + ItemRestocked::fire(item: $item2, quantity: 2); + + ItemAddedToCart::fire(cart: $cart, item: $item1, quantity: 2); + ItemAddedToCart::fire(cart: $cart, item: $item2, quantity: 2); + + CheckedOut::fire(cart: $cart); + + $this->assertEquals(0, $item1->available()); + $this->assertEquals(0, collect($item1->activeHolds())->sum('quantity')); + $this->assertEquals(0, $item2->available()); + $this->assertEquals(0, collect($item2->activeHolds())->sum('quantity')); +}); + +it('allows checking out after a hold expires if there is enough stock', function () { + ItemAddedToCart::$hold_seconds = 10; + Date::setTestNow(); + + $cart = CartState::load(snowflake_id()); + $item1 = ItemState::load(snowflake_id()); + $item2 = ItemState::load(snowflake_id()); + + ItemRestocked::fire(item: $item1, quantity: 2); + ItemRestocked::fire(item: $item2, quantity: 2); + + ItemAddedToCart::fire(cart: $cart, item: $item1, quantity: 2); + ItemAddedToCart::fire(cart: $cart, item: $item2, quantity: 2); + + Date::setTestNow(now()->addSeconds(11)); + + $this->assertNotTrue(isset($item1->activeHolds()[$cart->id])); + $this->assertNotTrue(isset($item2->activeHolds()[$cart->id])); + + CheckedOut::fire(cart: $cart); + + $this->assertEquals(0, $item1->available()); + $this->assertEquals(0, collect($item1->activeHolds())->sum('quantity')); + $this->assertEquals(0, $item2->available()); + $this->assertEquals(0, collect($item2->activeHolds())->sum('quantity')); +}); + +it('does not allow checking out if there is no stock', function () { + ItemAddedToCart::$hold_seconds = 10; + Date::setTestNow(); + + $cart1 = CartState::load(snowflake_id()); + $cart2 = CartState::load(snowflake_id()); + $item1 = ItemState::load(snowflake_id()); + $item2 = ItemState::load(snowflake_id()); + + ItemRestocked::fire(item: $item1, quantity: 2); + ItemRestocked::fire(item: $item2, quantity: 2); + + ItemAddedToCart::fire(cart: $cart1, item: $item1, quantity: 2); + ItemAddedToCart::fire(cart: $cart1, item: $item2, quantity: 2); + + Date::setTestNow(now()->addSeconds(11)); + + ItemAddedToCart::fire(cart: $cart2, item: $item2, quantity: 2); + + $this->assertThrows(fn () => CheckedOut::fire(cart: $cart1)); + + $this->assertEquals(2, $item1->available()); + $this->assertEquals(0, collect($item1->activeHolds())->sum('quantity')); + $this->assertEquals(0, $item2->available()); + $this->assertEquals(2, collect($item2->activeHolds())->sum('quantity')); +});