WOOT
WooCommerce plugin that shows the shop catalog as sortable, filterable tables with add-to-cart in every row.
- First release
- Active installs
- 1,000+
- Rating
- Read reviews
About
WOOT renders a WooCommerce catalog as a table: one row per product, a column for any product field, attribute, taxonomy or meta key, and an add-to-cart control in each row. Tables are configured in the admin panel or described inline in a [woot] shortcode. The same engine also draws the cart, variation lists, upsells, cross-sells, related products and single-product tables.
The hard part is that one column has to do three jobs: render a cell, sort the server query and act as a filter. Products come in several types (simple, variable, grouped, external, variation) that behave differently in a cell and in the cart. All table data goes through one public AJAX endpoint, which has to stay usable on large catalogs and must not expose drafts, non-public post types or files on the server.
The table engine in the browser (data-table-23.js) is plain JavaScript with ES6 classes and fetch. It caches pages by request hash, cancels stale requests, can switch a table to in-browser mode and query it with SQL over a JSON array, and changes layout for small screens. jQuery is used only to fire WooCommerce's own cart events.
WOOT · products tables
what was solved
Server-side table queries
shipped · 2020What it givesCustomers page, sort and filter product tables by price, SKU or stock in their own language, and preset lists like bestsellers show the exact number of items.
- Problem
- Every page turn, sort click and filter change arrives as one AJAX request. It has to become a single WP_Query that respects catalog visibility, table presets, filters, the visitor's language and sort keys that WP_Query does not support.
- Solution
get_table_data() reads a whitelisted set of request keys, applies presets and filters as transforms of the query arguments, patches SQL only where WP_Query falls short, and returns the rows with a total count for pagination.
- Stack
One public endpoint serves every table and every visitorWhitelist request keys, sanitize each by type, build WP_Query args, return rows and count
get_table_data() is registered for logged-in and anonymous users. It walks $_REQUEST through a switch and keeps only known keys: post_type, per_page, orderby, order, current_page, table_id, wp_columns_actions, filter_data, filter_provider, fields, lang, predefinition and shortcode_args_set. Numbers go through intval, keys through sanitize_key, JSON payloads through json_decode and a recursive sanitizer.
Two of these keys are hook names. wp_columns_actions is passed to apply_filters() to load the column profile, and filter_provider selects the woot_filter_provider_{name} filter that adds the filter conditions. Both are sanitized as text but not checked against a list of registered names.
The same function is called from PHP with an array for tables rendered at page load, so the server path and the AJAX path share one implementation. Each row is produced by calling the render closure of every requested column, with the product ID kept as pid for cell actions.
Sorting by price, SKU or stock means sorting by postmetaColumns declare meta_key and orderby; the query maps sort keys to meta_value(_num)
A column entry such as price carries 'orderby' => 'meta_value_num' and 'meta_key' => '_price'. get_table_data() looks up the requested sort key in the column profile and rewrites the query arguments from it.
The sort dropdown uses its own keys (price-desc, sku, stock-desc, sales and so on), translated in a woot_wp_query_args filter. Stock and total sales sorts exclude grouped and external products with a product_type NOT IN clause, since those types have no own stock or sales value. For dropdown sorts the plugin also calls WC()->query->get_catalog_ordering_args(), which attaches WooCommerce's own ordering clauses for popularity, rating and price, so these sorts match the shop page.
WP_Query cannot order by post_statusAttach a posts_orderby filter for that one query and remove it right after the query runs
For orderby=post_status a closure is added to posts_orderby before WP_Query and removed after it, so other queries on the same request keep their own ordering.
Top-N presets (bestsellers, newest) must show exactly N productsCap posts_per_page at N and set found_posts to N so the pager counts only N items
The bestsellers preset orders by total_sales and the newest preset by post_date, both with posts_per_page set to N. WP_Query reports found_posts for all matching products, so after the query found_posts is set to the preset limit and the pager shows only the top N.
Presets need include and exclude by several taxonomiesParse a compact rule string into a nested tax_query; invert the relation for exclusion
A preset rule is stored as a string such as product_cat:25,26|pa_color:19|rel:AND. get_by_taxonomy() turns it into one nested tax_query group and skips taxonomies that no longer exist.
For the exclude variant every clause uses NOT IN and the relation is swapped (AND becomes OR and back), which is De Morgan's law: excluding products that match A and B means keeping those outside A or outside B. SKU presets map a variation SKU to its parent product ID, because the table lists parent products.
admin-ajax requests lose the visitor's languageSend the locale with each request and switch WPML to it before querying and rendering
admin-ajax.php runs outside the front-end language context. draw_table_data() writes get_locale() into the table JSON, the browser sends it back as lang, and get_table_data() passes it through the woot_current_lang filter.
The vocabulary class hooks that filter and calls $sitepress->switch_lang(), so WP_Query returns products in the right language. Column titles and labels are translated by WOOT_Vocabulary::get() from the plugin's own table, loaded once per request and keyed by locale.
Column model
shipped · 2020What it givesThe owner chooses which product details appear as table columns, including the shop's own custom fields, and can copy or move table setups.
- Problem
- A column is more than a cell. The same field must render for each product type, drive sorting and appear as a filter widget, and shop owners add their own taxonomies and meta keys.
- Solution
Each table runs on a profile: an array of field descriptors whose closures render the cell, change WP_Query arguments for filtering and describe the filter widget. Taxonomies and meta keys are added to the profile at runtime.
- Stack
One field definition must drive the cell, the sort and the filterProfile of descriptors with render, filter-args and filter-UI closures for each field
WOOT_WooCommerce::action() returns the profile. Each key is a field with a title, an action closure that renders the cell for a product ID, sort data (order, orderby, meta_key) and, for filterable fields, get_filter_query_args and get_filter_draw_data closures. Index 0 holds table-level settings: post type, allowed statuses, filter provider.
Profiles are registered by name and resolved with apply_filters on the table action. The cart, variations, single-product view and even the plugin's admin screens (tables list, meta keys, vocabulary) run on the same engine with their own profiles.
Every product taxonomy and attribute should be a columnColumns from get_object_taxonomies('product') minus internal ones, shown as term trees
product_type, product_visibility, product_shipping_class and WPML's translation_priority are removed from the list. The cell shows the product's own terms through build_taxonomy_tree() limited to the assigned term IDs, as links or plain text per column option.
The filter side adds a tax_query clause whose operator (IN, NOT IN, AND) comes from the table settings and is checked against that whitelist.
Shop owners need columns for arbitrary meta keysTyped meta registry (text, number, calendar) yields sort mode and filter for each key
Meta keys are stored per table in woot_tables_meta with a type. assemble_meta_for_system() turns each into a profile field: text sorts by meta_value and filters with LIKE; number sorts by meta_value_num and filters with a numeric BETWEEN on a range slider; calendar filters as DATETIME or as a numeric unix timestamp, chosen per field, because plugins such as ACF store dates in different formats.
The fields are merged into the profile through the ext_{action} filter, and each key also appears in the sort dropdown with ascending and descending entries.
Rows times columns repeat the same product and option lookupsRequest-level static caches for products, table options, column rows and meta rows
Every column closure needs the WC_Product object, so get_product() keeps a static map of wc_get_product() results. Table options are decoded from JSON once per table, meta rows are cached by md5 of the SQL string, and the filter builder resolves the profile once instead of per field.
Table configs need their own storage, cloning and transferFour custom tables via dbDelta; clone copies columns and meta; import refills from JSON
install.php creates woot_tables, woot_tables_columns (indexed by table_id and is_active, ordered by pos_num), woot_tables_meta and woot_vocabulary. Drag-and-drop column order arrives as a list of IDs and is written back as pos_num.
clone() copies the table row, then its columns and meta keys under the new table ID. Export dumps all four tables and the options as JSON; import truncates each table and inserts the rows, after a capability and nonce check.
Filtering
shipped · 2020What it givesCustomers narrow the table by ranges, dates, categories and text, and tables follow the shop's existing filter plugin on the same page.
- Problem
- Filters arrive from the browser as a flat key/value map and must become WP_Query arguments: meta ranges, taxonomy clauses, text search and date ranges. Shops that already run a filter plugin expect the table to follow it.
- Solution
A filter provider hands each key to the column's own query closure. External filter plugins plug in as alternative providers that return a list of product IDs.
- Stack
A date range filter needs two inputs for one columnSplit a calendar field into key and key---to; the provider strips it and flips compare
When the filter form is built, a calendar field is duplicated under a ---to key with From and To labels. On the way back provider() sees an unknown key ending in ---to, strips the suffix and calls the same column closure with a flag, so the closure compares with <= instead of >=.
Range sliders need bounds before any filter is setCompute MIN and MAX of the meta key in SQL when the admin left the bounds unset
If a range field has no configured min or max (-1), get_field_drawing_data() runs SELECT MIN or MAX(cast(meta_value as UNSIGNED)) on postmeta for the field's meta key through $wpdb->prepare. The function name comes from an internal whitelist, not from the request. The comment count slider reads its bounds from the posts table.
Text search must cover chosen fields without SQL injectionposts_where clause with esc_like and prepare; field list limited to three post columns
The searchable fields come from the shortcode but are filtered to post_title, post_content and post_excerpt. Each becomes LOWER(field) LIKE %s with the value escaped by esc_like and bound by prepare.
In the browser a search fires on Enter only when the input reaches its minlength, and an empty Enter or Escape removes the term, which keeps short partial words from hitting the server.
Tables must follow the HUSKY (WOOF) filter on the same pageReplay WOOF's request, run its ID prediction and feed the IDs into post__in
With filter_provider=woof the shortcode captures WOOF's request data into the table JSON. On each AJAX call the provider restores $_GET from it, asks for all results, calls woof_products_ids_prediction() and reads woof_wp_query_ids, accepting an array or a comma string and keeping positive integers only. No IDs means post__in = [0].
MDTF filter state must reach the AJAX requestDecode MDTF state, request query args only, strip its own flags, prefetch product IDs
The page_mdf parameter is decoded and MDTF is run with mdf_get_query_args_only so it returns WP_Query arguments instead of HTML. The mdf_hide_post meta clause and the product_visibility clause are removed (WOOT adds its own), ordering is dropped, and the query runs with fields=ids. The ID list travels as filter data and becomes post__in.
Add to cart from the table
shipped · 2020What it givesCustomers buy any type of product directly from the table, add many rows at once, and the cart stays in step with the rest of the shop.
- Problem
- Each product type needs a different buy control, quantities are capped by stock, and the table cart must stay in sync with the theme's mini-cart and WooCommerce's cart page.
- Solution
The server emits a compact descriptor per product type, the browser builds controls from it and posts to WooCommerce's own AJAX endpoint. Cart contents are drawn by the same table engine.
- Stack
Simple, variable, grouped and external need different controlsServer emits a typed data-about descriptor; JS builds input, checkbox or popup link
add_to_cart.php checks is_purchasable() and is_in_stock() and, when stock is managed, requires a non-zero quantity. For simple products the descriptor carries ID, stock and SKU; the client builds a number input with max set to stock, made read-only for sold-individually products, plus a checkbox for bulk adding.
Variable and grouped products get a button that opens a popup table of their children. External products link to the external URL. Unknown types go through the woot_woocommerce_add_to_cart filter so other plugins can supply markup.
Adding to cart must behave like WooCommerce's own buttonPOST to wc-ajax=add_to_cart, then trigger wc_fragment_refresh for theme mini-carts
The endpoint URL is taken from wc_add_to_cart_params when WooCommerce prints it, so custom AJAX endpoints keep working. The quantity is checked against max before sending. After success the plugin fires wc_fragment_refresh, refreshes its own cart info and, depending on a table option, redirects to the cart or checkout.
Customers want to add many rows in one actionChecked rows and quantities go in one request; variations get parent ID and attributes
A checkbox in the cart column header selects all rows. add_to_cart_some() loops over the posted items. For a variation it rebuilds the attribute_* map from get_attributes('edit') and calls WC()->cart->add_to_cart() with the parent ID, quantity, variation ID and that map, which is the form the WooCommerce cart expects.
Variable products need a table of their variationsFilter provider switches post_type to product_variation and limits post__in to children
WOOT_WooCommerceVariations::filter_provider() sets post_type to product_variation and post__in to the parent's children after checking that the parent is visible to the visitor. Upsells, cross-sells, grouped and related tables share the base class WOOT_WooCommerceUniversal and differ only in the ID source.
In variation rows the title gets the attribute term names appended, and taxonomy columns read the variation's own attribute slug instead of terms assigned to the post.
The cart itself should be a sortable tableCart profile closures take product ID and quantity; the engine draws it in JSON mode
The woot_cart profile combines the configured product columns with quantity, subtotal (WC_Cart::get_product_subtotal) and remove. Every cart change responds with a fresh cart table, item count and subtotal. Remove and update find the cart line by product_id or variation_id.
The cart popup takes the skin of the table that opened it: the parent's style block is copied and its table ID replaced with the popup table's ID. A table option can also hide products already in the cart through post__not_in.
Table engine in the browser
shipped · 2020What it givesTables respond quickly when paging and sorting, several tables can share a page, and wide tables stay usable on phones.
- Problem
- Paging, sorting and searching have to feel immediate, several tables with different skins can share a page, and wide tables have to work on phones.
- Solution
data-table-23.js is a dependency-free class per table with a request-hash cache, request cancellation, an optional in-browser SQL mode and a compact layout switch.
- Stack
A page or sort order already seen should not hit the server againCache responses under a hash of the whole request object; clear it on events
__hash_object() serializes request_data to JSON and folds it into a 32-bit hash used as the cache key. The cache is off in JSON and load-more modes, where the data is already local. Extensions such as compare and favourites dispatch table23-reset-cache when their data changes.
Small catalogs should sort and search without round tripsto_json mode loads all rows once, then sorts, pages and searches them with AlaSQL
With mode to_json the server returns every row (posts_per_page -1). After the first response the table switches itself to json mode, and draw_data() builds SELECT ... WHERE ... ORDER BY ... LIMIT ... OFFSET and runs it with AlaSQL over the array. A one-page table found in the cache is searched and sorted the same way.
A slow earlier response must not overwrite a newer pageAbort the previous fetch with AbortController before sending the next one
Each table keeps its current AbortController. A new page, sort or filter request aborts the pending one, so a slow earlier response can no longer overwrite the newer page.
Wide tables must switch to a compact layout on phonesRe-render only when width crosses the breakpoint; resize handler locked for 999 ms
Each table has its own compact_view_width. The resize listener compares the new state with the previous one and redraws only on a change; a lock flag stops it from firing again for 999 ms. Columns marked hide-on-mobile are skipped when headers are built, the floating header is turned off in compact mode, and per-column media-hide rules are generated as CSS for the table's HTML ID.
Several tables with different skins on one pageSkin CSS uses a tid placeholder replaced by each table's HTML ID at render time
get_theme_css() reads the skin from the theme's woot-skins folder first and the plugin's skins folder second, replaces the tid placeholder with the table's HTML ID, and strips comments and extra whitespace. Every rule is scoped to one table, so skins do not leak between tables on the same page.
Cells can contain tables of their ownNested table config ships as JSON in a hidden div and is instantiated after each draw
draw_table_data() encodes the config with JSON_HEX_QUOT and JSON_HEX_TAG so it can sit inside HTML without breaking out of the element. After rows are drawn, do_after_draw() finds tables inside cells and creates a WOOT_GeneratedTables instance for each, without the horizontal scrollbar.
Security of public endpoints
in the productWhat it givesPublic tables show only products that should be visible, and visitors cannot use table requests to reach hidden content or run code.
- Problem
- Table data, popups and cart actions are served to anonymous visitors through admin-ajax, and requests carry table settings, shortcode arguments, skin names and hook names.
- Solution
Visibility is enforced at query time and per post, names that reach paths, CSS or SQL are reduced to safe character sets at one point, and every admin write checks capability and nonce.
- Stack
Public table requests carry a post type and status chosen by the clientForce publish status for visitors; non-public types only for tables built on them
Right before WP_Query runs, post_status is reset to publish for anyone without read_private_posts, whatever the request, shortcode or table settings asked for. A status from shortcode arguments is honoured only for such users.
Non-viewable post types (coupons, orders, templates) are refused for users without edit_posts unless the saved table was built for that post type by an admin.
Popups and shortcodes take a product ID from the clientwoot_can_read_post() checks status, viewability and, for variations, the parent
One function decides whether a visitor may see a post's data: published posts of public types are open, non-public types need edit rights, other statuses need read_post, and a variation is visible only when its parent is. Single-product, variations, upsell and other popup tables, the gallery shortcode and the extensions call it before rendering.
Skin names arrive from several sources and end up in a file pathbasename() plus an [A-Za-z0-9_-] filter at one choke point; table ID filtered before CSS
The skin can come from ?woot_skin=, shortcodes, extensions or table settings, and all of them pass through get_theme_css(). There the name is reduced with basename() and a character filter that removes dots and slashes, so it cannot point outside the skin folders. The table HTML ID, which is written into the printed CSS, is filtered the same way so braces or angle brackets cannot break out of the style block.
The popup endpoint dispatches hooks and shortcodes by nameOnly woot_ hooks are callable, call_id is stripped, only plugin shortcodes render
get_smth() is public. Its generic branch calls apply_filters on a name from the request only when the name starts with woot_, and the accompanying call_id is reduced to letters, digits, underscore and dash before any downstream query sees it. The shortcodes_set branch removes every shortcode not on the plugin's whitelist before do_shortcode(). Export and import branches require manage_woocommerce.
Admin writes and SQL built from column namesCapability and nonce on every write; SQL identifiers reduced to [A-Za-z0-9_]
Table, column, meta and vocabulary handlers check manage_woocommerce and the woot_settings_nonce before writing. The attachments extension adds a login check and object-level edit_post checks.
Column names, ORDER BY fields and WHERE keys cannot be bound by prepare(), so get_rows() strips them to a safe character set, limits the order to ASC or DESC and the logic to AND or OR, and binds only values.
Numbers
wordpress.org- Active installs 1,000+ wordpress.org counter.
- Reviews 18 Average 4.8 out of 5.
- Years supported 6 Since 4 September 2020, last update 12 October 2026.