Skip to content

Adding to Built-in Components ​

Your module can add its own markup inside a Lynx component, such as a note under every cart line or a badge on every product card, and run its own code before the component sends something, such as checking quantities before Update Cart. You do this without copying or overriding the component's file:

  • A container is a named place inside a component's markup. Your module puts a block (a component of yours) in it.
  • An event is a named moment in a component's code. Your module adds an observer (a .ts function) to it.

The names follow Magento's own: containers read like layout XML (checkout.cart.item.actions), events like events.xml (checkout_cart_update_items_before). They run in the browser, not in PHP.

Use a container where the place repeats for every item of a list, which a slot cannot do. A slot is filled by the merchant in the PageBuilder editor and saved with the layout. A container is filled by code: it does not show in the editor, and its blocks appear on every page that uses the component, once your module is enabled and the theme is built.

Containers and events in Lynx ​

ContainerWhereEach block gets
checkout.cart.item.actionsCart page, under each lineLynx.CartItem
checkout.cart.order.actionsCart page, under the list of linesnothing
sales.order.item.afterOrder page, under each item's name (Items Ordered tab)Lynx.OrderDetailItems
sales.order.items.afterOrder page, under the items table (Items Ordered tab)nothing
category.product.labelsCategory and search results, over each product card's imageLynx.ProductListingProduct
catalog.product.list.labelsProduct Carousel, Related, Up-sell and Cross-sell, over each card's imageLynx.ProductCarouselProduct
EventWhenData
checkout_cart_product_add_beforeAdd to Cart on a product page (every product type), a product list, a carousel or the compare page, before the request is sentLynx.CartProductAdd: product_id, qty, and params, the URLSearchParams or FormData about to be posted
checkout_cart_product_add_afterAfter that requestLynx.CartProductAdded
checkout_cart_update_items_beforeUpdate Cart on the cart pageLynx.CartUpdateItems: cart, item id → { qty }
checkout_cart_coupon_apply_beforeApply Discount on the cart pageLynx.CartCouponApply: code
checkout_submit_beforePlace Order, for every payment method that places the order through the checkout's Payment step, and the Braintree PayPal express buttonLynx.CheckoutSubmit: method, and payload, the payment information about to be posted
checkout_submit_all_afterAfter the order is placedLynx.CheckoutSubmitted: order_id
customer_register_beforeCreate an AccountLynx.CustomerRegister: form, the <form> element about to be submitted
customer_account_edit_beforeSave on Account InformationLynx.CustomerAccountEdit
customer_address_save_beforeSave Address in the accountLynx.CustomerAddressSave

Payment methods that post to their own endpoint instead of the checkout's Payment step (Stripe wallets, PayPal Express shortcut and review page) do not raise the checkout events.

The full types are in your theme's lynx/dist/props.d.ts: Lynx.Containers maps each container to what its blocks get, and Lynx.Events maps each event to its data.

Add a block ​

The example adds a delivery note under each cart line, from a module Vendor_DeliveryNote that lists OOCS_LynxCheckout in its <sequence>.

1. Declare it in the module's etc/components/DeliveryNote.graphqls, on the component that owns the container. The data your block needs travels as an extra field on that component's props:

graphql
extend type CheckoutCart
  @block(container: "checkout.cart.item.actions", name: "delivery_note",
         template: "Vendor_DeliveryNote::Components/ItemDeliveryNote") {
  "Notes keyed by cart item id"
  delivery_notes: StringMap @optional
}
  • container: where the block goes.
  • name: unique within the container. Another module can declare the same name to replace your block, or remove it with remove: true.
  • template: the block's file, Vendor_Module::Components/<Name> → view/frontend/lynx/vue/src/Components/<Name>.vue.
  • before / after (optional): the name of another block in the same container, or "-" for first or last. Without them, blocks follow the order of the module sequence.

2. Fill the data with a plugin on the component's provider (here OOCS\LynxThemeComponent\Provider\Checkout\Cart). Add the key only when there is something to show, so the block can render nothing otherwise:

php
public function afterResolve(Cart $subject, array $result): array
{
    $notes = $this->notes->forQuote($this->checkoutSession->getQuoteId());
    if ($notes) {
        $result['delivery_notes'] = $notes;
    }

    return $result;
}

3. Write the block. It receives two props: context, the item of the current loop (the type in the table above), and parent, all the props of the component that owns the container.

vue
<script setup lang="ts">
import { computed } from 'vue'

const props = defineProps<{ context: Lynx.CartItem; parent: Lynx.CheckoutCart }>()

const note = computed(() => props.parent.delivery_notes?.[props.context.item_id])
</script>

<template>
    <p v-if="note" class="col-span-full text-sm text-base-content/70">{{ note }}</p>
</template>

A block is a normal component file with no provider and no .graphqls registration of its own. It is not offered in the editor.

Add an observer ​

1. Declare it next to your blocks, on the component that raises the event:

graphql
extend type CheckoutCart
  @observer(event: "checkout_cart_update_items_before", name: "delivery_note_qty_cap",
            instance: "Vendor_DeliveryNote::observers/capQty")

instance points to view/frontend/lynx/vue/src/observers/capQty.ts. name, remove, before and after work as for blocks.

2. Write the observer. It gets the event's data and may change it; here it caps every line at 10 before the cart is updated. Observers run one after another on the same data and may be async.

ts
export default (data: Lynx.Events['checkout_cart_update_items_before']) => {
    for (const line of Object.values(data.cart as Record<string, { qty: number }>)) {
        line.qty = Math.min(line.qty, 10)
    }
}

Throw an Error to stop the action: the component shows its message as an error and sends nothing. Take that message from the server (translated with __()), as with any text a Lynx component shows.

Build ​

Blocks and observers become part of the component's own code when the theme is built: there is no extra file to download and nothing to wire at runtime. A block attached to several components is copied into each of them.

After enabling or disabling a module, or changing a .graphqls file:

bash
bin/magento cache:clean lynx_component
bin/magento lynx:static:deploy
bin/magento setup:static-content:deploy   # production mode

lynx:theme:setup is needed once, when your module first adds a view/frontend/lynx folder (Build and see it).

The build stops with an error when a block names a container no component declares, an observer names an event no component declares, a block has no template or an observer no instance. In your tests, render the block directly with context and parent props; containers and events are empty when you test the component that owns them (Testing).

In a theme override ​

When a theme overrides a component that has containers or events, its copy must keep them, or the blocks and observers of every module stop running for that component. Keep the Container tags and dispatch() calls exactly as written in the original, with the name as a plain string:

vue
<Container name="checkout.cart.item.actions" :context="item" :parent="props" />
ts
await dispatch('checkout_cart_update_items_before', data)

When the theme's copy lacks a container or an event that the component declares, the build prints a warning such as container checkout.cart.item.actions is declared on OOCS_LynxCheckout/Components/CheckoutCart, but …/CheckoutCart.vue does not use it. This usually means the override was copied from an older version of the component.

Your own containers and events ​

A component of yours can offer containers and events to other modules in the same way. Declare them on its type and place them in its code:

graphql
type VendorDeals @component(code: "vendor_deals", template: "Vendor_Deals::Components/Deals", label: "Deals")
  @container(name: "vendor.deals.item.after", context: "VendorDealsItem")
  @event(name: "vendor_deals_claim_before", data: "VendorDealsClaim") {
  items: [VendorDealsItem!]!
}

input VendorDealsClaim {
  deal_id: Int!
}
vue
<script setup lang="ts">
import Container from 'OOCS_LynxTheme/Components/Container.vue'
import { dispatch } from 'OOCS_LynxTheme/utils/dispatch'

const props = defineProps<Lynx.VendorDeals>()

async function claim(id: number) {
    const data = { deal_id: id }
    await dispatch('vendor_deals_claim_before', data)
    // … send data
}
</script>

<template>
    <div v-for="item in props.items" :key="item.id">
        …
        <Container name="vendor.deals.item.after" :context="item" :parent="props" />
    </div>
</template>

Rules, checked by the build:

  • <Container … /> is self-closing and its name is a plain string (name="…"). No attribute value may contain >.
  • dispatch('…', data) has the event name as a plain string. Wrap the call in try/catch and show the error message when an observer throws.
  • The imports are written exactly as above, each on its own line. A Vue file uses <script setup>.
  • Container and event names are global: two components cannot declare the same one.