-
Notifications
You must be signed in to change notification settings - Fork 41
example native posts
A <os-table>-driven replacement for the chromeless edit.php iframe. Server-paginated, sortable, filterable, multi-select bulk-trash, sub-row excerpt + featured image. Opt-in Beta — fresh installs use the classic iframe; users turn it on via OpenStation Preferences → Features → Beta features → Use the native Posts window. The dock tile stays where it is; only the destination changes.
Status: Experimental. Hook names are stable; the JS column-filter shape may grow.
The dock tile that points at edit.php is unchanged. Every code path that opens an admin URL (dock click, portal deep-link, <a href="/wp-admin/edit.php"> anywhere in the shell) consults a central registry — src/native-url-remap.ts — before falling back to the iframe.
User clicks the Posts dock tile
│
▼
Dock.openPage(item)
│
▼
tryNativeUrlRemap(item.url) ── matches "edit.php" ─┐
│ │
▼ ▼
no match nativePostsEnabled?
│ │
▼ ▼
iframe edit.php openById('desktop-mode-posts')
Future native windows (Pages, Media, Users) register themselves with one line — they don't need to touch the Dock or any dispatcher.
Status: Stable — see
wp.os.registerNativeUrlRemap( entry )for the full entry shape.
const unsub = wp.os.registerNativeUrlRemap( {
id: 'myplugin-pages',
nativeWindowId: 'myplugin-pages',
matches: ( _url, parsed ) =>
parsed.pathname.endsWith( '/edit.php' ) &&
parsed.searchParams.get( 'post_type' ) === 'page',
enabled: ( settings ) => settings.nativePagesEnabled === true,
} );Returning false from enabled (or returning false from matches) lets the click fall through to the iframe path. Returning a nativeWindowId that isn't registered for the current user (cap-gated, opt-in-gated) also falls through — openById() reports false and the registry walks on.
The bundled Posts window consumes this same primitive internally (
src/native-url-remap.ts);wp.os.registerNativeUrlRemaphands the same registry out to plugins.
wp.hooks.addFilter(
'openstation.postsWindow.columns',
'myplugin/word-count-column',
( cols ) => [
...cols,
{
key: 'wordCount',
label: 'Words',
sortable: false,
width: '100px',
align: 'end',
render: ( _v, row ) => {
const text = ( row.excerpt?.rendered ?? '' ).replace( /<[^>]+>/g, '' );
return text.trim() ? text.split( /\s+/ ).length.toString() : '—';
},
},
],
);The columns render inside <os-table>'s shadow DOM — outer stylesheets do not reach the cells. Inline styles on the returned element are the working contract.
Walks all three legs of the extensibility surface — server-side data, REST projection, JS column. The default /wp/v2/posts response doesn't expose a comments count, so we expose one ourselves with register_rest_field, ask the bundle to fetch it via the existing query-args filter, and render it via the columns filter.
1. Server: expose the comment count on /wp/v2/posts.
add_action( 'rest_api_init', function () {
register_rest_field( 'post', 'openstation_comment_count', array(
'get_callback' => static function ( $post ) {
return (int) get_post_field( 'comment_count', $post['id'] );
},
'schema' => array(
'type' => 'integer',
'description' => 'Approved + pending comment count for the post.',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
) );
} );2. Server: ask the bundle to fetch the new field.
The Posts window sends a tight _fields projection on every request to keep the payload small. Append our field so it lands in the response:
add_filter( 'openstation_posts_window_query_args', function ( $args ) {
$args['_fields'] .= ',openstation_comment_count';
return $args;
} );3. Client: render the column.
wp.hooks.addFilter(
'openstation.postsWindow.columns',
'myplugin/comments-column',
( cols ) => [
...cols,
{
key: 'openstation_comment_count',
label: 'Comments',
sortable: false,
width: '110px',
align: 'end',
render: ( _v, row ) => {
const span = document.createElement( 'span' );
const n = row.openstation_comment_count ?? 0;
span.textContent = String( n );
if ( n > 0 ) {
span.style.fontWeight = '600';
}
return span;
},
},
],
);That's it. The column appears in every Posts window load and never makes a second round-trip per row. Note that plugin columns can't sort server-side: the Posts window maps unknown column keys to orderby=date, and core's /wp/v2 collections don't accept orderby=comment_count anyway.
The default bulk action is "Move to trash". Plugins extend the registry via the openstation.postsWindow.bulkActions filter — every entry shows up in the bulk bar when one or more rows are selected. The run() callback receives the selected row ids and a PostsWindowContext ({ body, table, refresh, getSelectedIds, getSelectedRows, getCurrentParams }):
wp.hooks.addFilter(
'openstation.postsWindow.bulkActions',
'myplugin/bulk-duplicate',
( actions ) => [
...actions,
{
id: 'duplicate',
label: 'Duplicate',
icon: 'dashicons-admin-page',
variant: 'secondary',
confirm: ( count ) =>
wp.i18n.sprintf(
wp.i18n._n(
'Duplicate %d post?',
'Duplicate %d posts?',
count
),
count
),
run: async ( ids ) => {
await fetch( '/wp-json/myplugin/v1/duplicate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': wpApiSettings.nonce,
},
body: JSON.stringify( { ids } ),
} );
// Returning anything other than `false` triggers the
// window's auto-clear-selection + auto-refresh after
// the action resolves.
},
},
],
);confirm takes either a function or a string. Prefer the function: it receives the row count, which is what _n() needs to pick a plural form. A plain string still works and is interpolated with the count via %d, but it can only carry one form, so it cannot be translated into languages that have more than two.
Returning false from run() opts out of the auto-refresh — useful when the action navigates away or shows its own modal.
To remove the default trash action (read-only views, audit-style mirrors), filter it out by id:
wp.hooks.addFilter(
'openstation.postsWindow.bulkActions',
'myplugin/no-trash',
( actions ) => actions.filter( ( a ) => a.id !== 'trash' ),
);The segmented control above the table is built from the (filterable) status list. CPTs that register custom statuses can surface them here:
wp.hooks.addFilter(
'openstation.postsWindow.statusSegments',
'myplugin/awaiting-review',
( segs ) => [
...segs,
{ value: 'awaiting-review', label: 'Awaiting review' },
],
);The value is sent verbatim as the REST ?status=… param. Use '' (empty string) for the "All" sentinel — the bundle remaps that to ?status=any so the user sees every status they can edit.
The trailing slot sits before the built-in Refresh + Add New buttons:
wp.hooks.addFilter(
'openstation.postsWindow.toolbarTrailing',
'myplugin/export-button',
( elements, ctx ) => {
const btn = document.createElement( 'os-button' );
btn.setAttribute( 'variant', 'ghost' );
btn.textContent = 'Export CSV';
btn.addEventListener( 'click', () => {
const params = ctx.getCurrentParams();
window.open( `/wp-json/myplugin/v1/posts/export?status=${ params.status ?? 'any' }` );
} );
return [ ...elements, btn ];
},
);The filter receives a fresh array on every window open (and an empty default), plus the live PostsWindowContext so the button can refresh, read the selection, or read the current view params at click time.
Two actions on the hook bus, both with matching CustomEvents on document:
// Fired AFTER the first paint with a populated table.
wp.hooks.addAction(
'openstation.postsWindow.opened',
'myplugin/track-open',
( ctx ) => {
analytics.track( 'posts-window-opened', {
count: ctx.table.data?.length ?? 0,
} );
},
);
// Fired after every successful refresh (initial + every search /
// sort / pagination change).
wp.hooks.addAction(
'openstation.postsWindow.dataLoaded',
'myplugin/track-page',
( payload ) => {
analytics.track( 'posts-window-page', {
page: payload.page,
total: payload.total,
} );
},
);
// CustomEvent equivalents — same payloads.
document.addEventListener( 'os-posts-window-opened', ( e ) => { /* … */ } );
document.addEventListener( 'os-posts-window-data-loaded', ( e ) => { /* … */ } );The opened action's ctx is the same PostsWindowContext passed to bulk-action runners — read the table, fire ctx.refresh(), etc.
add_filter( 'openstation_posts_window_user_can_register', function ( $can, $user_id ) {
return $can && user_can( $user_id, 'edit_others_posts' );
}, 10, 2 );The default gate is edit_posts. Returning false here skips the window registration entirely — openById() reports false, the remap registry walks on, and the classic chromeless edit.php iframe remains the destination.
Don't reach for openstation_posts_window_user_can_use here: that filter is the combined informational check (capability AND the opt-in toggle) for callers that need the combined answer — it has no routing effect. Registration gates on user_can_register; the runtime dock-click swap gates on the JS-side nativePostsEnabled settings snapshot.
add_filter( 'openstation_posts_window_query_args', function ( $args ) {
$args['post_type'] = 'product';
return $args;
} );The bundle threads post_type straight through to /wp/v2/posts (or, if your CPT registers its own REST base, swap postsUrl via openstation_posts_window_args). v1 ships with post; v1.1 will add a CPT picker in the toolbar.
add_filter( 'openstation_posts_window_query_args', function ( $args ) {
$args['meta_key'] = 'featured';
$args['meta_value'] = '1';
return $args;
} );Anything /wp/v2/posts accepts is fair game — meta_*, categories_exclude, sticky, etc.
The window broadcasts os.post.changed after every bulk trash. Subscribe to keep your own UI in sync without re-fetching:
const unsub = wp.os.subscribe( 'os.post.changed', ( payload ) => {
if ( payload.source !== 'posts-window' ) {
return;
}
console.log( 'posts trashed:', payload.ids );
} );The recycle bin window is already a subscriber — that's how trashing 12 posts here makes the bin tile's badge tick up to 12 without a refresh.
PHP:
-
openstation_posts_window_user_can_register( $can, $user_id )— the registration gate. Default:edit_posts. Returningfalseskips registration entirely, so every click falls through to the chromelessedit.phpiframe. -
openstation_posts_window_user_can_use( $can, $user_id )— the combined informational check: capability AND the user has turned the opt-in toggle on. No routing effect; for callers that need the combined answer (analytics, an arrange-menu entry). -
openstation_posts_window_args( $args )— args passed toopenstation_register_window()(title, icon, dimensions, config blob). -
openstation_posts_window_template_html( $html )— the rendered template HTML beforewp_kses. -
openstation_posts_window_query_args( $args )— outbound REST query params (_fields,_embed,post_type).
JavaScript:
-
openstation.postsWindow.columns(filter) — column descriptors beforetable.columns =is set.
CustomEvents / broadcasts:
-
os.post.changed— broadcast on bulk trash;{ source: 'posts-window', action: 'trashed', ids }.
This wiki is generated from the docs/ directory — edits made here are overwritten by the next sync.
To change a page, open a pull request against docs/.
Guides
- Development guide
- Releasing openstation
- Agents security model
- API Index
- Architecture
- Bridge protocol — wiring overview
- <os-*> component reference
- Native Desktop Host — Experimental
- Desktop themes
- Dock customization — two registries, one mental model
- The event-driven framework
- Files on the Desktop
- Folder sharing
- Getting Started
- Hooks Reference
- Icons
- JavaScript Reference
- The Living Tree — algorithm definition
- Mio
- Native Windows & Framework Interop
- Plugin compatibility layer
- Progressive Web App (PWA)
- Station Home
- Using openstation from your own plugin
Migration notes
- Migration: built-in activity channels move to the os/ namespace
- Migration: window, wallpaper and widget bundles load on demand
- Migration — the navigation model
- Migration: a native window's tabs move to the window chrome
All examples
- AI Agents — extend and invoke from a plugin
- wp.os.ai.ask() — programmatic AI Copilot
- Tune the AI model config
- Custom arrange-menu action
- Open a child window its owner can't cover
- Style a specific admin page inside the iframe
- Code Blue — register your plugin's log file
- Open a file in the Code editor (deep-link from any window)
- Connect to a window — title-bar button + iframe pub/sub
- Content changes — live-refresh every window listing your type
- Custom window chrome (Experimental)
- Register a custom unfocused-window effect
- Example: render a data table
- Real file storage — react to uploads, gate policy, share from PHP
- React to a window being set free onto the real desktop
- Cross-window devtools — instrumentation primitives
- Add a dock item with a badge
- Decorate the dock without forking the renderer
- Replace the dock rail entirely
- Retune the Drafts widget's AI writing assistant
- Gate OpenStation by role
- Iframe-initiated window opens
- Build a feed reader without the bookkeeping
- Inject data into openStationConfig
- Render a list without losing clicks — renderKeyedList()
- Example: layout primitives (body → panel → row → col)
- Use <os-*> components from a plugin that ships as a zip
- Restyle and drive Mio
- Add an action that works on a whole selection
- WP Explorer — custom post types and their folder
- Add an action button to a WP Explorer preview pane
- Example: native Posts window
- Example: native window with tabs
- Native windows
- Customize note → post conversion
- Send a notification
- OAuth relay — connect to an external service
- OS-file drop
- <os-flyout> — window-scoped sliding card
- Plugins window — extras
- Track who's around — wp.os.presence
- Example: progress bar
- PWA install — surface your own button
- React to window events
- Example: extend the Trash
- Register a slash-command
- Register a desktop theme from a plugin
- Register a game
- Example: register a desktop icon (Jorvy)
- Register a wallpaper
- Register a widget
- Related entities — extend the title bar's "Related" menu
- The native-window render ctx
- Programmatic folder sharing
- Share state across multi-bundle plugins — wp.os.createSharedStore()
- Example: loading spinner
- Add an opt-in card to Station Home
- Accept drops on your desktop icon
- Give a tile two icons, one per state
- Add a row to a window's ⋯ menu
- Example: window activity & the status ring
- Window controls
- Subscribe to window lifecycle events
- Window links — relate windows and restyle the ties (Experimental)
- Window loading state — spinner overlay & ready signal
- Show a banner at the top of a window
- Pulse a window's icon — Window.requestAttention()
- Register a custom window reveal
- Window slots
- Window themes
- Native window with bundle-bound config