Language

Get in touch

WordPress plugin · since 2020

TableOn

Shows WordPress posts and custom post types as sortable, filterable tables built from a column constructor.

First release
Active installs
200+
Rating
4.6 / 5 (15)Read reviews
  • PHP
  • WordPress
  • WP_Query
  • MySQL (custom tables, dbDelta)
  • JavaScript (ES6 classes)
  • Fetch API
  • AbortController
  • AlaSQL
  • WPML
View on wordpress.org wordpress.org

About

TableOn renders posts of any post type as a table. The admin builds tables from predefined columns (title, thumbnail, author, dates, comment count, taxonomies, gallery) and from post meta fields, adds a filter form, predefined subsets and skins, and places the result with a shortcode, in a popup, or inside another site through an iframe.

Engineering-wise it is two parts. On the server one public ajax endpoint turns a table definition and a filter state into a WP_Query and returns rows as JSON. In the browser a dependency-free table class pages, sorts and searches, either over ajax or over data already loaded, and switches to a compact layout on narrow screens.

The difficult parts are turning one configurable table definition into a WP_Query for any post type, typing meta fields that WordPress stores as strings, and keeping several tables with different skins and languages independent on one page.

TableOn · posts table

what was solved

LIVEsince 2020

Column profiles and the query endpoint

shipped · 2020

What it givesStaff decide which columns a table shows and which posts it includes, and each column can be sorted and filtered the same way.

Problem
One ajax endpoint serves rows for every table: any post type, admin-defined meta columns, filters, predefined subsets and sort orders. The same field has to render a cell, sort the query and act as a filter.
Solution

A table is described by a profile: an array of field keys, each with closures for the cell value, the query arguments and the filter widget data. Requested columns are looked up in that profile by key.

Stack
  • PHP
  • closures
  • WP_Query
  • WordPress filters
Tasks · 5
One field definition must drive the cell, the sort and the filterRequested fields only index the profile; each key brings its own closures

TABLEON_Default::action() returns the profile: for every key a title, an 'action' closure that renders the cell, and for filterable keys get_filter_query_args and get_filter_draw_data. get_table_data() loops over requested fields and calls $profile[$field]['action'] only when the key exists, so an unknown field name is ignored. Every row also carries pid, which cell actions on the front use.

Admin-defined meta fields need typed query behaviorMeta rows typed text, number or calendar; each type builds its own meta_query

assemble_meta_for_system() turns a row of the meta table into a profile entry. Text uses LIKE and a text input. Number uses BETWEEN with type numeric from a 'min:max' value, a range slider and meta_value_num ordering. Calendar compares either as a unix timestamp (numeric) or as DATETIME, chosen per field, because plugins such as ACF store date strings while others store timestamps.

Some sort orders are not expressible as WP_Query argumentsTemporary posts_orderby closure for post_status, removed right after the query

Ordering by post_status is done with a closure hooked to posts_orderby for the duration of one WP_Query and removed afterwards, so it does not leak into other queries on the page. Sort-by drop-down values such as title-desc or <meta_key>-desc are mapped to orderby/order, with meta_value_num for meta fields typed as number.

Tables limited to a chosen subset of postsPredefinition rules become post__in, post__not_in, author__in and tax_query

Each table can store rules: post IDs, excluded IDs, authors, terms to include or exclude. get_table_data() applies them before the visitor's filters, with IDs cast to integers. A shortcode attribute author=N is folded into the same rules (code comment dated 16-09-2020).

Table configuration does not fit post meta wellFour custom tables via dbDelta: tables, columns, meta fields, vocabulary

Columns are rows with table_id, field_key, pos_num, is_active, show_on_mobile and JSON options, indexed on table_id and is_active, so a table's active columns are one indexed query. The whole configuration exports as one JSON document; import truncates each table and reinserts rows, behind a role check.

Filter form over posts and meta

shipped · 2020

What it givesVisitors narrow tables by text, categories, number ranges and dates. Slider limits follow the actual data, so staff do not have to enter them.

Problem
The filter form is generated from the same profile for any post type: text search, taxonomy trees, numeric ranges and date ranges. The plugin has no index of its own, so bounds and options must come from live data.
Solution

Each filter key supplies draw data (bounds, options, labels) and a query builder. Numeric bounds come from MIN/MAX queries unless the admin set them. Conditions that WP_Query cannot express go through posts_where: text values are escaped and prepared, dates are formatted with date() and counts cast with intval().

Stack
  • PHP
  • WP_Query
  • posts_where
  • MySQL
Tasks · 6
Text search across chosen columns, case-insensitive, safeposts_where with LOWER(field) LIKE; fields whitelisted, values prepared and esc_like

The searchable columns come from the shortcode attribute tableon_text_search_by but are filtered to post_title, post_content and post_excerpt, so a column name never reaches SQL from outside the list. The term is lower-cased with a multibyte-aware helper, escaped with esc_like() and bound with $wpdb->prepare().

Range sliders need bounds the admin did not enterDefault bounds are MIN/MAX of CAST(meta_value AS UNSIGNED) for the meta key

get_field_drawing_data() uses the stored min/max of the table when set; otherwise it queries wp_postmeta for the key with a numeric cast, so text-stored numbers compare as numbers. Comment count bounds are MIN/MAX over wp_posts of the table's post type.

One date field needs two inputs, from and toDraw a twin key with suffix ---to; the provider routes it with a direction flag

draw_filter_form_data() duplicates a calendar field as <key>---to with 'From:' and 'To:' labels. The key does not exist in the profile, so provider() recognizes the suffix, strips it and calls the original field's query builder with is_calendar_dir_to = true, which flips >= to <=.

Taxonomy multi-select needs IN, NOT IN or AND per tableOperator stored per table, checked against a whitelist before tax_query

The mselect-logic option is read from the table's filter options and replaced by IN if it is not one of IN, NOT IN, AND. Term IDs go into tax_query by term_id.

Hierarchical term lists with include and exclude setsRecursive tree per parent with a depth prefix, filtered by ID lists

build_taxonomy_tree() walks terms parent by parent, skips excluded IDs, keeps only included IDs when that list is set, and prefixes titles with one dash per level so a flat select shows the hierarchy. It runs one get_terms() call per parent with hide_empty.

Other filter plugins must be able to drive the same tableProvider hook tableon_filter_provider_{name} rewrites the WP_Query arguments

The request names a filter provider; get_table_data() passes the query args, filter data and shortcode args through the matching filter. The built-in form is the 'default' provider. Taxonomy Chain Menu uses the client side of this, writing filter_data into the table and asking it to redraw.

Client-side table engine

shipped · 2020

What it givesPaging, sorting and searching feel quick because data that is already loaded gets reused, and wide tables switch to a compact layout on phones.

Problem
Tables run in pages, popups and iframes and must page, sort and search without waiting on the server for every click. Fast clicking makes responses arrive out of order, and wide tables do not fit phones.
Solution

A dependency-free JavaScript class with three data modes, an in-browser SQL engine for loaded rows, a per-request cache, request cancellation and a compact layout below a configurable width.

Stack
  • JavaScript (ES6 classes)
  • Fetch API
  • AbortController
  • AlaSQL
Tasks · 5
A slow earlier response must not overwrite a newer pageAbortController cancels the previous fetch before a new request starts

Each ajax request stores its AbortController; a new request aborts the pending one first, so only the latest page, sort or filter state is drawn.

Sort and page data that is already in the browserjson mode runs AlaSQL SELECT, ORDER BY and LIMIT over the loaded rows

In json mode the rows come embedded in the page. Title search, count, ORDER BY and LIMIT/OFFSET are built as SQL strings and run by AlaSQL over the in-memory array, so paging and sorting need no requests.

Load over ajax once, then stop making round tripsto_json mode: the first ajax response becomes local data and the mode flips to json

to_json starts as ajax with a switch flag. After the first response the rows are kept as table_data and the mode becomes json. On the server json modes request all matching posts (posts_per_page = -1), which the code marks as a memory risk, so the mode suits small and medium sets.

Repeated requests for pages already seenCache responses under a 32-bit hash of the JSON request object

set_cache() and get_cache() key responses by __hash_object(request_data), a string hash over JSON.stringify of the whole request (page, order, filters). Going back to a page already seen is served from memory. Cache is off in json, to_json and load-more modes, where data is local anyway.

Wide tables on phonesCompact view below a set width, per-column mobile hiding, throttled resize

Below compact_view_width rows are drawn as stacked cells and headers are hidden. The resize handler redraws only when the compact state actually changes and locks itself for 999 ms between runs. Columns can be dropped on mobile (hide_on_mobile) or hidden below a pixel width by generated @media rules. Rows are built in a DocumentFragment and inserted once.

Embedding, isolation and admin access

in the product

What it givesSeveral differently styled tables can share a page, tables can be shown on other websites, labels follow the site language, and chosen staff roles can manage them.

Problem
Several tables with different skins can share one page, a table can be embedded into another site, labels must follow the site language, and table management is not limited to administrators.
Solution

CSS is scoped to each table's HTML id at render time, remote embedding goes through a generated loader script, labels go through a translation table, and admin screens are gated by a role setting.

Stack
  • PHP
  • CSS
  • JavaScript
  • iframe
Tasks · 5
Several tables with different skins on one pageSkin CSS uses a 'tid' placeholder replaced with the table HTML id, then minified

get_theme_css() loads the skin from the theme's tableon-skins folder first, then from the plugin, replaces tid with the table's HTML id, strips comments and whitespace. Per-column styles and the table's custom CSS are prefixed with #<table id> as well, so one table's rules do not touch another.

Show a table on a site that is not WordPressLoader script builds a lazy iframe to a page that uses the plugin's blank template

A request with tableon-remote-page and anchor returns JavaScript instead of a page: settings as variables plus remote.js, which creates a lazy-loaded iframe in the given container. The page inside uses a blank template the plugin registers, so the theme chrome is not shown.

Inline JSON config must not break out of its HTML containerjson_encode with JSON_HEX_TAG, JSON_HEX_QUOT and JSON_HEX_AMP in a hidden div

Table settings and, in json mode, all rows are printed into the page. Encoding <, >, quotes and & as unicode escapes means post titles or meta values cannot close the element or inject markup.

Interface strings in several languages without .po filesVocabulary table maps a string to per-language values, loaded once per request

TABLEON_Vocabulary reads the whole table once into a static array; get() returns the value for the current locale or the original string. The current language passes through the tableon_current_lang filter, which also switches WPML, so ajax requests render in the visitor's language.

Editors other than administrators manage tablescan_manage_data(): admin or roles from settings, plus a nonce on every save

Admin screens register only for users who pass can_manage_data(), which accepts administrators and roles listed in the user_roles_can setting. Save, create and delete handlers each verify the 'tableon' nonce.

Numbers

wordpress.org
  • Active installs 200+ wordpress.org counter.
  • Reviews 15 Average 4.6 out of 5.
  • Years supported 6 Since 2 November 2020, last update 12 June 2026.