TableOn
Shows WordPress posts and custom post types as sortable, filterable tables built from a column constructor.
- First release
- Active installs
- 200+
- Rating
- Read reviews
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
Column profiles and the query endpoint
shipped · 2020What 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
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 · 2020What 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
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 · 2020What 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
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 productWhat 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
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.