Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/pages/pages.os.php
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,12 @@ static function ( State $state, Os $os, array $args ) {
openstation_posts_app_trash( $os, $args, 'page' );
}
)
->action(
'restore',
static function ( State $state, Os $os, array $args ) {
openstation_posts_app_restore( $os, $args, 'page' );
}
)
->watch( 'page' )
->data(
static function ( State $state ) {
Expand Down
26 changes: 22 additions & 4 deletions apps/posts/parts/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,11 +330,29 @@ export function createPostsApp( id: string, options: PostsAppOptions = {} ) {

const bulkBar = ( ctx: Ctx, ui: UiState, mode: PostsMode, footer: boolean ): TemplateResult => {
// Resolved once, on the first paint: the registry filter sees the
// same defaults it always did, and every button dispatches
// against the live selection at click time.
// shipped defaults ('trash' and 'restore'), and buttons dispatch
// against the live selection at click time. Actions are dynamically
// partitioned so 'restore' renders in Trash status and 'trash' in others.
if ( ! ui.bulkActions ) {
ui.bulkActions = resolveBulkActions( defaultBulkActions( mode, ( ids ) => ctx.dispatch( 'trash', { ids } ) ), mode );
ui.bulkActions = resolveBulkActions(
defaultBulkActions(
mode,
( ids ) => ctx.dispatch( 'trash', { ids } ),
( ids ) => ctx.dispatch( 'restore', { ids } ),
),
mode,
);
}
const isTrash = ctx.state.status === 'trash';
const visibleActions = ui.bulkActions.filter( ( action ) => {
if ( action.id === 'trash' ) {
return ! isTrash;
}
if ( action.id === 'restore' ) {
return isTrash;
}
return true;
} );
return html`
<div class="os-app-list__toolbar-right ${ footer ? 'os-app-list__bulk--footer' : '' }" data-os-posts-bulk ?hidden=${ ui.selected === 0 }>
<span class="os-app-list__count" data-os-posts-count>${ sprintf(
Expand All @@ -343,7 +361,7 @@ export function createPostsApp( id: string, options: PostsAppOptions = {} ) {
ui.selected,
) }</span>
<os-button variant="ghost" @click=${ () => tableOf( ctx )?.clearSelection() }>${ __( 'Clear selection' ) }</os-button>
<span class="os-app-list__bulk-actions" data-os-posts-bulk-actions>${ ui.bulkActions.map(
<span class="os-app-list__bulk-actions" data-os-posts-bulk-actions>${ visibleActions.map(
( action ) => html`<os-button
variant=${ action.variant ?? 'secondary' }
data-os-posts-bulk-action=${ action.id }
Expand Down
28 changes: 24 additions & 4 deletions apps/posts/parts/columns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,14 +346,16 @@ export function resolveStatusSegments(): StatusSegment[] {
}

/**
* The shipped bulk action: "Move to trash". `trash` runs the app's
* server action over the ids not already in the trash (a second
* delete would remove them for good) and returns `false` — the
* action's own dispatch already refreshed the list.
* The shipped bulk actions: "Move to trash" and "Restore". `trash` runs the
* window's server action over the ids not already in the trash (a second
* delete would remove them for good); `restore` runs over the ids in the
* trash. Both return `false` — the action's own dispatch already refreshed
* the list.
*/
export function defaultBulkActions(
mode: 'posts' | 'pages',
trash: ( ids: number[] ) => Promise< boolean >,
restore?: ( ids: number[] ) => Promise< boolean >,
): BulkAction[] {
return [
{
Expand Down Expand Up @@ -386,6 +388,24 @@ export function defaultBulkActions(
return false;
},
},
{
id: 'restore',
label: __( 'Restore' ),
icon: 'dashicons-image-rotate',
variant: 'secondary',
run: async ( ids, ctx ): Promise< false > => {
const data = ctx.table.data ?? [];
const restorable = ids.filter( ( id ) => {
const row = data.find( ( r ) => r.id === id );
return row && row.status === 'trash';
} );
if ( restorable.length > 0 && restore ) {
await restore( restorable );
}
ctx.table.clearSelection();
return false;
},
},
];
}

Expand Down
43 changes: 43 additions & 0 deletions apps/posts/parts/query.php
Original file line number Diff line number Diff line change
Expand Up @@ -341,3 +341,46 @@ function openstation_posts_app_trash( Os $os, array $args, $type ) {
);
}
}

/**
* `restore` — restore the selected rows from the trash. Rows not in the
* trash are skipped, every id is checked against `delete_post`, the
* survivors are untrashed, their trash metadata cleared, and announced
* as one content change (what the Recycle Bin and list windows repaint
* on), and failures become a toast.
*
* @param Os $os Host handle.
* @param array<string,mixed> $args `ids`.
* @param string $type Content type announced (`post` | `page`).
* @return void
*/
function openstation_posts_app_restore( Os $os, array $args, $type ) {
$ids = openstation_posts_app_ids( isset( $args['ids'] ) ? $args['ids'] : array() );
$ok = array();
$failed = 0;
foreach ( $ids as $id ) {
$post = get_post( $id );
if ( ! $post || 'trash' !== $post->post_status ) {
continue;
}
if ( ! $os->can( 'delete_post', $id ) || ! wp_untrash_post( $id ) ) {
++$failed;
continue;
}
delete_post_meta( $id, '_desktop_mode_trash_user_id' );
delete_post_meta( $id, '_desktop_mode_trash_time_gmt' );
$ok[] = $id;
}
if ( array() !== $ok ) {
$os->announce( $type, 'untrashed', $ok );
}
if ( $failed > 0 ) {
$os->toast(
sprintf(
/* translators: %d: number of rows that could not be restored. */
_n( '%d item could not be restored.', '%d items could not be restored.', $failed, 'desktop-mode' ),
$failed
)
);
}
}
7 changes: 4 additions & 3 deletions apps/posts/parts/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,9 +247,10 @@ export interface PostsWindowContext {

/**
* A bulk action that appears in the toolbar when one or more rows
* are selected. The shipped default is "Move to trash"; plugins
* append/replace via the `openstation.postsWindow.bulkActions`
* filter, whose second argument is `{ mode: PostsMode }`.
* are selected. The shipped defaults are "Move to trash" and "Restore"
* (swapped dynamically depending on view status); plugins append/replace
* via the `openstation.postsWindow.bulkActions` filter, whose second
* argument is `{ mode: PostsMode }`.
*/
export interface BulkAction {
/** Stable id — used as a key, also the `data-os-posts-bulk-action` on the button. */
Expand Down
6 changes: 6 additions & 0 deletions apps/posts/posts.os.php
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,12 @@ static function ( State $state, Os $os, array $args ) {
openstation_posts_app_trash( $os, $args, 'post' );
}
)
->action(
'restore',
static function ( State $state, Os $os, array $args ) {
openstation_posts_app_restore( $os, $args, 'post' );
}
)
// A post trashed, restored or edited anywhere on the desktop
// repaints the list — the legacy bundle's `os.post.changed`
// subscription.
Expand Down
30 changes: 30 additions & 0 deletions apps/posts/posts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,21 @@ describe( 'the bulk bar', () => {
await runBulkAction( ctx, optingOut, postsCtx );
expect( refresh ).toHaveBeenCalledTimes( 2 );
} );

it( 'shows restore instead of trash when in trash status and dispatches restore without confirmation', async () => {
const { ctx, root, dispatch, table } = mount( { status: 'trash' }, data( [ row( 1, { status: 'trash' } ), row( 2, { status: 'trash' } ) ] ) );
app.mounted( ctx );
table().selection = [ 2 ];
table().dispatchEvent( new CustomEvent( 'os-table-selection-change' ) );
expect( root.querySelector( '[data-os-posts-bulk-action="trash"]' ) ).toBeNull();
const restoreBtn = root.querySelector( '[data-os-posts-bulk-action="restore"]' ) as HTMLElement;
expect( restoreBtn ).not.toBeNull();
restoreBtn.click();
await flush();
expect( ctx.host.confirm ).not.toHaveBeenCalled();
expect( dispatch ).toHaveBeenCalledWith( 'restore', { ids: [ 2 ] } );
expect( Array.from( table().selection ?? [] ) ).toEqual( [] );
} );
} );

describe( 'the registries', () => {
Expand Down Expand Up @@ -619,6 +634,21 @@ describe( 'the registries', () => {
expect( seen ).toEqual( [ { mode: 'posts' }, { mode: 'pages' }, { mode: 'posts' }, { mode: 'pages' } ] );
} );

it( 'the restore bulk action skips non-trashed rows and opts out of auto-refresh without confirm', async () => {
const trash = vi.fn( async () => true );
const restore = vi.fn( async () => true );
const [ , action ] = defaultBulkActions( 'posts', trash, restore );
expect( action.id ).toBe( 'restore' );
expect( action.confirm ).toBeUndefined();
const clearSelection = vi.fn();
const result = await action.run( [ 1, 2 ], {
table: { data: [ row( 1, { status: 'publish' } ), row( 2, { status: 'trash' } ) ], clearSelection } as never,
} as never );
expect( restore ).toHaveBeenCalledWith( [ 2 ] );
expect( clearSelection ).toHaveBeenCalled();
expect( result ).toBe( false );
} );

it( 'hides user-hidden columns but never the title, narrows to the phone set, and lists the togglable ones', () => {
const env = cellEnv( { cells: { tags: buildTagsCell, categories: buildCategoriesCell } } );
expect( buildAllColumns( env, new Map() ).map( ( c ) => c.key ) ).toEqual( [ 'title', 'author', 'categories', 'tags', 'date' ] );
Expand Down
6 changes: 3 additions & 3 deletions docs/examples/native-posts.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ That's it. The column appears in every Posts window load and never makes a secon

## Add a bulk action

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 filter's second argument is `{ mode }`, `'posts'` or `'pages'`: the Posts and Pages windows share the filter, so an action that only makes sense for posts returns `actions` unchanged in pages mode. The `run()` callback receives the selected row ids and a `PostsWindowContext` (`{ body, table, refresh, getSelectedIds, getSelectedRows, getCurrentParams }`):
The shipped bulk actions are "Move to trash" (`id: 'trash'`, destructive, requires confirmation) and "Restore" (`id: 'restore'`, neutral, instant execution). The bulk bar dynamically displays "Restore" when viewing the Trash status and "Move to trash" in all other views. 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 filter's second argument is `{ mode }`, `'posts'` or `'pages'`: the Posts and Pages windows share the filter, so an action that only makes sense for posts returns `actions` unchanged in pages mode. The `run()` callback receives the selected row ids and a `PostsWindowContext` (`{ body, table, refresh, getSelectedIds, getSelectedRows, getCurrentParams }`):

```js
wp.hooks.addFilter(
Expand Down Expand Up @@ -181,13 +181,13 @@ wp.hooks.addFilter(

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:
To remove default actions (e.g. for read-only views or custom workflows), filter them out by id:

```js
wp.hooks.addFilter(
'openstation.postsWindow.bulkActions',
'myplugin/no-trash',
( actions ) => actions.filter( ( a ) => a.id !== 'trash' ),
( actions ) => actions.filter( ( a ) => a.id !== 'trash' && a.id !== 'restore' ),
);
```

Expand Down
4 changes: 2 additions & 2 deletions docs/hooks-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -3360,7 +3360,7 @@ See [`docs/examples/recycle-bin.md`](./examples/recycle-bin.md) for end-to-end r

## Native Posts window

Native writing desk that replaces the chromeless `edit.php` iframe. Ruled note cards show titles, excerpts, publishing states, word counts, approved comment totals, tag counts and nearby Edit / Details actions; a companion inspector exposes the taxonomy pickers and extension fields. The Content view control opens the optional Details table. **Opt-in Beta** — fresh installs land on the classic iframe; users turn it on via **OpenStation Preferences → Features → Beta features → Use the native Posts window** (persisted as `OsSettingsState.nativePostsEnabled`, default `false`). The dock tile that points at `edit.php` is unchanged — every click path consults the URL → native-window remap registry first and falls back to the iframe on no-match. An [App Framework](./app-framework.md) app — `apps/posts/` — whose list is a `data()` over `openstation_app_rest_page( 'wp/v2/posts', … )` (so every REST field and the query-args filter below reach the rows exactly as they reached the old bundle), whose server paging / filtering / sorting are its state, and whose "Move to trash" is a server action; the Categories mind map and the Tags cloud are its two canvases. The registration (title, size, config extra) is filterable through [`openstation_app_manifest`](#openstation_app_manifest--experimental-filter) for `$id === 'desktop-mode-posts'`. See [`examples/native-posts.md`](./examples/native-posts.md) for end-to-end recipes and [`migration-list-apps.md`](./migration-list-apps.md) for what the port removed.
Native writing desk that replaces the chromeless `edit.php` iframe. Ruled note cards show titles, excerpts, publishing states, word counts, approved comment totals, tag counts and nearby Edit / Details actions; a companion inspector exposes the taxonomy pickers and extension fields. The Content view control opens the optional Details table. **Opt-in Beta** — fresh installs land on the classic iframe; users turn it on via **OpenStation Preferences → Features → Beta features → Use the native Posts window** (persisted as `OsSettingsState.nativePostsEnabled`, default `false`). The dock tile that points at `edit.php` is unchanged — every click path consults the URL → native-window remap registry first and falls back to the iframe on no-match. An [App Framework](./app-framework.md) app — `apps/posts/` — whose list is a `data()` over `openstation_app_rest_page( 'wp/v2/posts', … )` (so every REST field and the query-args filter below reach the rows exactly as they reached the old bundle), whose server paging / filtering / sorting are its state, and whose "Move to trash" and "Restore" are server actions; the Categories mind map and the Tags cloud are its two canvases. The registration (title, size, config extra) is filterable through [`openstation_app_manifest`](#openstation_app_manifest--experimental-filter) for `$id === 'desktop-mode-posts'`. See [`examples/native-posts.md`](./examples/native-posts.md) for end-to-end recipes and [`migration-list-apps.md`](./migration-list-apps.md) for what the port removed.

**Its tabs are the Posts menu, in both directions.** All posts, Add Post, Categories and Tags are the same rows the dock's Posts submenu lists, in the same order, because both come from the window's own [`App::menu()`](./app-framework.md) declaration. Each tab names the wp-admin page it replaces, so the shell claims `post-new.php` and the two taxonomy screens for the window **wherever** they are clicked — a link in another window, the admin bar's "+ New", a workspace's launch list — and opens it on the matching tab. A page the window has no tab for keeps its own row, so a plugin's screen registered under Posts stays reachable. Add Post shows the editor embedded in the panel through [`wp.os.embedAdminPage()`](./javascript-reference.md#wposembedadminpage-host-url-opts---stable); it mounts blank unless the page inside says it is holding unsaved changes, in which case the draft is handed back. While Add Post is the visible tab, the embedded editor's content identity is the window's, so the title bar's Preview (eye), revisions row and Related menu work there and leave when another tab is picked. An embedded editor is not an iframe window, so it has no close-time unsaved-changes prompt. Pages works the same minus the taxonomies, and adds the reverse case: **Page atlas** is a tab wp-admin has no screen for, and the dock offers it as a row anyway. All of it is gated on the opt-in, which is why flipping a Beta toggle spends a menu refresh.

Expand Down Expand Up @@ -3415,7 +3415,7 @@ Every JS hook below is also documented on `wp.hooks` so plugins can register wit
|---|---|---|---|
| `openstation.postsWindow.columns` | filter | built-in 5 columns | `OsTableColumn< PostListItem >[]` — append, replace, or remove cells. Second arg: `{ mode: 'posts' \| 'pages' }`, the window the columns are for. |
| `openstation.postsWindow.statusSegments` | filter | All / Published / Drafts / Pending / Scheduled / Trash | `StatusSegment[]` — `{ value, label }` pairs. `value` is sent verbatim as `?status=…`; use `''` for "All" (the bundle remaps to `?status=any`). |
| `openstation.postsWindow.bulkActions` | filter | one entry: "Move to trash" | `BulkAction[]` — `{ id, label, icon?, variant?, confirm?, run( ids, ctx ) }`. `confirm` is `string \| ( count: number ) => string`; the function form is the one `_n()` can pluralize. Filter out by id to remove. Second arg: `{ mode: 'posts' \| 'pages' }` — return `actions` unchanged when the action does not belong in that window. |
| `openstation.postsWindow.bulkActions` | filter | two entries: "Move to trash" (`id: 'trash'`) and "Restore" (`id: 'restore'`) | `BulkAction[]` — `{ id, label, icon?, variant?, confirm?, run( ids, ctx ) }`. `confirm` is `string \| ( count: number ) => string`; the function form is the one `_n()` can pluralize. The bulk bar dynamically displays "Restore" when viewing the Trash status and "Move to trash" otherwise. Filter out by id to remove either. Second arg: `{ mode: 'posts' \| 'pages' }` — return `actions` unchanged when the action does not belong in that window. |
| `openstation.postsWindow.toolbarTrailing` | filter | `[]` | `HTMLElement[]` rendered before Refresh + Add New. Receives the live `PostsWindowContext` as the second arg. |
| `openstation.postsWindow.opened` | action | — | `( ctx: PostsWindowContext )` — fired after the first paint with a populated table. |
| `openstation.postsWindow.dataLoaded` | action | — | `( payload: { items, total, totalPages, page } )` — fired after every successful refresh. |
Expand Down
2 changes: 1 addition & 1 deletion tests/phpunit/tests/pagesApp.php
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ public function test_manifest_mirrors_the_legacy_registration() {
$this->assertSame( array( 'page' ), $manifest['watch'] );
// `reopen` is the framework's, declared for every window that
// declares a menu so the client dispatches it.
$this->assertSame( array( 'filter', 'page', 'sort', 'trash', 'reopen' ), $manifest['actions'] );
$this->assertSame( array( 'filter', 'page', 'sort', 'trash', 'restore', 'reopen' ), $manifest['actions'] );
// Pages are usually shallow + ordered by menu_order.
$this->assertSame( 'menu_order', $manifest['state']['orderby'] );
$this->assertSame( 'asc', $manifest['state']['order'] );
Expand Down
Loading
Loading