B2B Quick Order in Hyvä: Bulk SKU Entry, CSV Import, and GraphQL Batch Mutations
AI generated
Hyvä
phtml
Hyvä Theme
B2B Quick Order in Hyvä: From SKU List to Cart
Bulk SKU entry, CSV import, and a GraphQL batch mutation for recurring bulk orders

B2B buyers who already know exactly which SKUs they need, often pulled straight from an ERP export or a purchasing spreadsheet, do not want to click through category pages and product detail pages one item at a time. A quick order form with free-form SKU and quantity entry, optional CSV import, and a GraphQL batch mutation behind the scenes shortens the path from purchasing list to cart considerably, backed by real-time validation against actual stock.

12 min read SKU List Entry CSV Import GraphQL Batch Mutation Real-Time Stock Check

1. Where Quick Order Fits in B2B, and What It Is Not

B2B purchasing follows a recognizable pattern: a facility manager, a buyer, or an assistant already knows the exact SKUs they need, usually copied from an ERP export or an internal spreadsheet, and wants to get them into the cart without navigating search, category filters, or individual product pages. That is exactly the use case a quick order form addresses, as a separate ordering path alongside the regular PDP flow.

It is worth drawing a clear line against the reorder feature. Reorder replays an existing, already placed order from order history with the exact same SKU and quantity combination it originally contained. Quick order carries no such binding to a past order. The customer types or pastes any SKUs and quantities, regardless of whether that exact combination was ever ordered before. The two features complement each other but solve different problems, and both deserve their own visible entry points in the frontend.

This article also stays clear of the broader topic of company accounts and B2B features in Hyvä. It focuses solely on the quick order form itself: the entry, the validation, and the path the data takes into the cart. Company accounts, roles, and approval workflows are only touched on briefly at the end, in terms of how they interact with this form, not how to build them.

2. An Alpine.js Component for Dynamic SKU Row Entry

The core of the quick order form is an Alpine.js component that manages a list of rows, each with a field for the SKU and a field for the quantity. New rows can be added with a click, empty or unwanted rows removed just as easily. Since Hyvä consistently relies on Alpine.js instead of Knockout.js, the component stays lightweight and works directly in the template without any extra build dependency.

Each row also carries an internal status, ranging from idle through checking to available or an error message. That status gets populated by an actual GraphQL query in the next section, here the focus is purely on the form structure, using x-for over the rows array and x-model for two-way binding of SKU and quantity.


<div x-data="quickOrderForm()" class="not-prose">
    <template x-for="(row, index) in rows" :key="row.id">
        <div class="grid grid-cols-12 gap-2 items-center mb-2">
            <input
                type="text"
                x-model="row.sku"
                @input.debounce.400ms="validateRow(index)"
                placeholder="SKU"
                class="col-span-5 border rounded px-2 py-1"
            />
            <input
                type="number"
                min="1"
                x-model.number="row.qty"
                @input.debounce.400ms="validateRow(index)"
                class="col-span-3 border rounded px-2 py-1"
            />
            <span class="col-span-3 text-sm" x-text="row.status"></span>
            <button type="button" @click="removeRow(index)" class="col-span-1 text-red-600">&times;</button>
        </div>
    </template>
    <button type="button" @click="addRow()" class="mt-2 text-sm font-medium">+ Add row</button>
</div>

<script>
function quickOrderForm() {
    return {
        rows: [{ id: 1, sku: '', qty: 1, status: '' }],
        nextId: 2,
        addRow() {
            this.rows.push({ id: this.nextId++, sku: '', qty: 1, status: '' });
        },
        removeRow(index) {
            this.rows.splice(index, 1);
        },
        async validateRow(index) {
            const row = this.rows[index];
            if (!row.sku) return;
            row.status = 'checking...';
            // The real GraphQL stock check is added in section 5
        }
    };
}
</script>

3. CSV Import as an Alternative to Manual Row Entry

For bulk orders with dozens or hundreds of line items, typing everything by hand is not realistic. A CSV import, usually exported straight from the customer's ERP system or a spreadsheet, replaces manual entry with a single file upload. Two columns are typically enough per line: SKU and quantity, separated by a comma or semicolon.

Parsing raises a fundamental choice between client side and server side. Client side parsing in the browser gives instant feedback with no round trip to the server, but runs into trouble with very large files and encoding pitfalls such as Windows-1252 exports from Excel. Server side parsing through a dedicated upload endpoint normalizes delimiters and character encoding more reliably and scales better for very large lists, at the cost of an extra request and a bit more implementation effort.

In practice a combination works well: small files up to a few hundred rows get parsed client side and dropped straight into the row table, larger files go to a server endpoint that handles validation and returns a structured result including a list of errors.


function parseCsv(text) {
    const lines = text.trim().split(/\r?\n/);
    const separator = lines[0].includes(';') ? ';' : ',';
    return lines
        .map((line) => {
            const [sku, qty] = line.split(separator).map((v) => v.trim());
            return { sku, qty: Number(qty) || 1 };
        })
        .filter((row) => row.sku && row.sku.toLowerCase() !== 'sku');
}

document.getElementById('csvUpload').addEventListener('change', async (event) => {
    const file = event.target.files[0];
    if (!file) return;

    const text = await file.text();
    const parsedRows = parseCsv(text);

    if (parsedRows.length > 500) {
        // Large file: hand off to a server endpoint for validation
        await uploadCsvToServer(file);
        return;
    }

    Alpine.store('quickOrder').rows = parsedRows;
});

4. A GraphQL Batch Mutation for Adding Multiple Products

Once the row list is ready, every line item should move into the cart in a single step rather than a chain of individual addProductToCart calls. Magento 2.4.8 provides the addProductsToCart mutation for exactly this, accepting a cart ID plus an array of SKU and quantity pairs and processing all of them in one request.

The user_errors field in the response is particularly valuable. It returns a distinct error code for every failed line item along with a path pointing at the specific row in the submitted array. That makes it possible to pinpoint exactly which row failed in the frontend, without the entire request failing as a whole.

A single mutation for the whole list cuts the number of requests dramatically compared to a loop of individual calls. Very long lists still hit practical limits with this approach, which is covered in the performance section below.


mutation AddProductsToCart($cartId: String!, $items: [CartItemInput!]!) {
    addProductsToCart(cartId: $cartId, cartItems: $items) {
        cart {
            id
            total_quantity
        }
        user_errors {
            code
            message
            path
        }
    }
}

# Example variables
# {
#   "cartId": "abc123",
#   "items": [
#     { "sku": "24-MB01", "quantity": 5 },
#     { "sku": "24-MB02", "quantity": 12 }
#   ]
# }

5. Real-Time Stock Validation: Debounce and a Stock Query

Before a row is even submitted to the cart, immediate feedback on stock availability right inside the form is valuable. Every change to the SKU or quantity field triggers a GraphQL query against the products field, filtered by the entered SKU, requesting stock_status and, where multi source inventory is active, the available quantity via the only_x_left_in_stock field.

Without debouncing, every single keystroke would fire its own request, needlessly loading both frontend and server. A debounce of roughly 300 to 500 milliseconds per row, implemented with setTimeout and clearTimeout or Alpine's built in .debounce modifier, ensures a request only goes out after a short pause in typing.

Switching quickly between rows can cause older responses to arrive after newer ones. A simple safeguard is a request counter or an AbortController per row that discards stale responses, so the displayed status always matches the most recently entered value.


let debounceTimer;

function onRowInput(index) {
    clearTimeout(debounceTimer);
    debounceTimer = setTimeout(() => checkStock.call(this, index), 400);
}

async function checkStock(index) {
    const row = this.rows[index];
    row.status = 'checking...';

    const query = `
        query CheckStock($skus: [String!]!) {
            products(filter: { sku: { in: $skus } }) {
                items {
                    sku
                    stock_status
                    only_x_left_in_stock
                }
            }
        }
    `;

    const response = await fetch('/graphql', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ query, variables: { skus: [row.sku] } }),
    });

    const { data } = await response.json();
    const found = data.products.items[0];

    if (!found) {
        row.status = 'SKU not found';
    } else if (found.stock_status !== 'IN_STOCK') {
        row.status = 'out of stock';
    } else if (found.only_x_left_in_stock && row.qty > found.only_x_left_in_stock) {
        row.status = `only ${found.only_x_left_in_stock} available`;
    } else {
        row.status = 'available';
    }
}

6. Per-Row Error Feedback: SKU Not Found and Stock Conflicts

For usability, every row needs its own clearly visible status rather than a single combined error message at the bottom of the form. A small icon next to the quantity field, paired with a short label underneath, works well: green for available, amber for limited availability, red for a hard error.

The two most common error types deserve different treatment. A SKU that returns no match in the product query is a hard error, that row cannot move into the cart until the SKU is corrected. A known SKU with insufficient stock is a soft error instead, here it helps to show the maximum available quantity as a clickable suggestion, so the customer can adjust the quantity with one click instead of doing the math themselves.

Above the row list, a compact summary helps, showing how many rows still have open errors. The submit button stays disabled or shows a warning until either every error is resolved or the customer deliberately chooses to proceed with only the error free rows.

7. Order Lists for Recurring Bulk Orders

Many B2B customers do not order just once, they place the same or a very similar base set of items on a regular cadence, for instance a monthly restock of consumables. A saved order list that loads into the quick order form with a single click saves far more time than even the best CSV import.

Adobe Commerce B2B ships a full native module for this called requisition lists, complete with integration into company accounts and approval workflows. That feature is exclusive to Adobe Commerce and Adobe Commerce Cloud, however, and is not available in Magento Open Source.

For Open Source projects a leaner custom approach works: a dedicated entity that ties SKU and quantity pairs, along with a list name, to the customer_id. The quick order form can load such a saved list, prefill the rows, and run it through the same real-time validation as a freshly typed list. The feature set stays deliberately narrower than requisition lists, but it covers most bulk ordering scenarios perfectly well.

8. Performance With Large SKU Lists: Chunking, Debounce, and Progress

Past a certain row count, often somewhere between 100 and 300 line items in practice, a single addProductsToCart mutation becomes a risk. Response time climbs, and server side timeouts or memory limits can fail the entire request even though the overwhelming majority of rows would have processed fine.

Chunking is the fix: the full list is split into smaller blocks, say 50 items each, sent to the server as separate mutations one after another. Each block returns its own user_errors result, so errors still map precisely to a row, even across multiple chunks.

A progress indicator belongs on the interface too, whether as a percentage or a count of processed against total items. Since each chunk request runs asynchronously with await inside a loop, the interface stays responsive throughout, and the customer can watch a large bulk order progress live.


async function submitRowsInChunks(rows, chunkSize = 50) {
    const chunks = [];
    for (let i = 0; i < rows.length; i += chunkSize) {
        chunks.push(rows.slice(i, i + chunkSize));
    }

    let processed = 0;
    for (const chunk of chunks) {
        await addProductsToCart(chunk);
        processed += chunk.length;
        this.progress = Math.round((processed / rows.length) * 100);
    }
}

9. Interaction With Company Accounts and Approval Workflows

In environments with company account structures enabled, submitting the quick order form can trigger an additional step. If a purchase order approval workflow is configured for the given user, the order first sits pending with one or more approvers before it is actually finalized.

For the quick order form itself, practically nothing changes. Entry, CSV import, stock validation, and the batch mutation run exactly the same way. Only the final checkout step differs depending on the company configuration, between placing the order directly and queuing it for approval.

How company accounts, roles, and approval rules are built in Hyvä is deliberately outside the scope of this article, that belongs to the broader discussion of B2B features in the theme. The point worth taking away here is that quick order works as an entry path independent of whichever approval model is in place.

Method Input Effort Performance at Scale Error Handling Availability
Manual SKU rows low to moderate, copy paste works good up to roughly 50 rows per row, visible immediately always, pure frontend component
Client side CSV import very low, file upload good up to roughly 500 rows summarized after parsing always, no server endpoint needed
Server side CSV import very low, file upload good even with several thousand rows detailed per row feedback requires a dedicated REST or GraphQL endpoint
GraphQL batch mutation no extra input, just submission good with chunking, otherwise timeout risk user_errors per line item Magento 2.4.8 GraphQL API, every edition
Requisition lists low, reuse saved lists optimized by the vendor built into the approval workflow natively Adobe Commerce B2B only, not Open Source
Custom order lists low, reuse saved lists depends on the custom implementation as good as what you build Open Source and Commerce alike

Mironsoft

Hyvä theme development and Luma migration

Still running Luma, or a Hyvä theme that just doesn't feel right?

We build Hyvä themes for Magento from scratch or migrate existing Luma shops cleanly, with Tailwind CSS, Alpine.js, and none of the unnecessary JavaScript baggage.

Luma-to-Hyvä Migration

Move an existing shop to Hyvä in a structured way, without losing functionality.

Custom Theme Development

Build a custom Hyvä theme from scratch based on your design.

Performance Optimization

Improve Core Web Vitals and load times in the Hyvä frontend with purpose.

10. Summary

Quick Order in Hyvä

SKU Rows & CSV Import

Dynamic Alpine rows for SKU and quantity, extended with client side or server side CSV import for larger bulk orders.

GraphQL Batch Mutation

addProductsToCart accepts multiple line items in one request and returns user_errors per row for targeted feedback.

Real-Time Stock Check

A debounced stock query per row catches missing SKUs and tight stock levels before the order is submitted.

Order Lists & Company Accounts

Custom order lists stand in for Adobe Commerce requisition lists in Open Source, purchase order approvals apply unchanged at checkout.

11. FAQ: Quick Order in Hyvä

1What is the difference between quick order and the reorder feature in Hyvä?
Reorder replays an already placed order from order history with the exact same SKU and quantity combination. Quick order allows free entry of any SKUs and quantities, independent of whether that combination was ever ordered before. The two features complement each other but address different use cases.
2Do I need Adobe Commerce B2B to build a quick order form?
No. A quick order form with SKU list entry, CSV import, and a GraphQL batch mutation can be fully implemented in Magento Open Source with Hyvä. Adobe Commerce B2B only adds native requisition lists and approval workflows, which would otherwise need to be built by hand in Open Source.
3How many rows can a single GraphQL mutation handle reliably?
In practice, batches of roughly 50 to 100 line items work reliably within typical timeout limits. Larger lists benefit from chunking, splitting the list into several consecutive mutations with a progress indicator.
4How do I tell a missing SKU apart from one that is simply out of stock?
A product query for an unknown SKU returns no match in the items array, while a known but sold out SKU returns a match with a stock_status other than IN_STOCK. Both cases should be labeled differently in the frontend, so the customer immediately knows whether to fix a typo or adjust the quantity.
5Should CSV import happen client side or server side?
For small files up to a few hundred rows, client side parsing in the browser is enough, it is fast and needs no extra endpoint. For larger files, or when server side validation against the catalog is required, a dedicated upload endpoint that reliably normalizes encoding and delimiters pays off.
6How do I stop stock queries from overloading the server?
A debounce of around 300 to 500 milliseconds per row prevents every keystroke from triggering its own request. Multiple rows can also be combined into a single stock query using a sku in filter instead of querying each row individually.
7What happens to quick order submissions inside a company account with an approval workflow?
If a purchase order approval process is active, an order assembled through quick order goes through the same approval workflow as one placed from the product detail page. The quick order form itself stays unchanged, only the final checkout step differs depending on the company configuration.
8How should partial stock availability be shown when the requested quantity is not fully in stock?
A clear message with the actually available quantity right in the row works well, for example only 12 available, combined with the option to reduce the entered quantity to that value with one click. That keeps the decision with the customer without blocking the entire list.
9Can I save quick order lists for recurring purchases without Adobe Commerce B2B?
Yes, a custom entity that stores SKU and quantity pairs per customer account is enough. The quick order form loads a saved list, prefills the rows, and then runs the same real-time validation as a freshly entered list.
10What error codes does the addProductsToCart mutation typically return?
Common codes include an unknown SKU, insufficient stock, and invalid quantities such as negative or non integer values. Each error is tied to a specific line item in the submitted list through the path field, making it possible to highlight the affected row in the form.