diff --git a/allterrain-forms.php b/allterrain-forms.php index 91e404e..a073922 100644 --- a/allterrain-forms.php +++ b/allterrain-forms.php @@ -201,6 +201,7 @@ require_once ATF_DIR . 'includes/assets.php'; require_once ATF_DIR . 'includes/admin-page.php'; require_once ATF_DIR . 'includes/openstation.php'; +require_once ATF_DIR . 'includes/openstation-explorer.php'; require_once ATF_DIR . 'includes/privacy.php'; register_activation_hook( __FILE__, 'atf_activate' ); diff --git a/assets/js/dock.js b/assets/js/dock.js index e886a1f..bb729e7 100644 --- a/assets/js/dock.js +++ b/assets/js/dock.js @@ -127,6 +127,32 @@ var allTerrainFormsDock = function(exports) { if (!boot()) { document.addEventListener("os-init", () => void boot(), { once: true }); } + function registerExplorerActions() { + const hooks = window.wp?.hooks; + if (!hooks?.addFilter) { + return; + } + const windows = { + "allterrain-forms/open-builder": BUILDER, + "allterrain-forms/open-entries": ENTRIES, + "allterrain-forms/open-analytics": ANALYTICS + }; + hooks.addFilter( + "os.my-wordpress.preview-actions", + "allterrain-forms/explorer", + (actions) => actions.map((action) => { + const id = action.id ?? ""; + if (!windows[id]) { + return action; + } + return { + ...action, + onSelect: () => open(windows[id]) + }; + }) + ); + } + registerExplorerActions(); exports.submenuFor = submenuFor; Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" }); return exports; diff --git a/assets/js/dock.min.js b/assets/js/dock.min.js index 5219e39..9cb9dc9 100644 --- a/assets/js/dock.min.js +++ b/assets/js/dock.min.js @@ -1 +1 @@ -var allTerrainFormsDock=function(l){"use strict";const c=window.allTerrainForms,r="allterrain-forms",o="allterrain-forms-entries",d="allterrain-forms-themes",i="allterrain-forms-analytics",m="allterrain-forms-import";function t(e){a()?.openWindow?.(e,{source:"dock"})}function h(e,n,s){const p=a()?.windowManager;if(p?.open){p.open({id:e,url:n,title:s,icon:"dashicons-download"});return}window.location.assign(n)}function a(){return window.wp?.os??null}function w(e){const n=[];if(e?.canEdit&&n.push({title:"Forms",url:"",onSelect:()=>t(r),windowId:r}),e?.canRead&&n.push({title:"Entries",url:"",onSelect:()=>t(o),windowId:o}),e?.canRead&&n.push({title:"Analytics",url:"",onSelect:()=>t(i),windowId:i}),e?.canEdit&&n.push({title:"Themes",url:"",onSelect:()=>t(d),windowId:d}),e?.canEdit&&e?.adminUrl){const s=`${e.adminUrl}admin.php?page=allterrain-forms-import`;n.push({title:"Import forms",url:s,onSelect:()=>h(m,s,"AllTerrain Forms — Import"),windowId:m})}return e?.canEdit&&e?.devMode&&n.push({title:"Demo data",url:"",onSelect:()=>{t(i),document.dispatchEvent(new CustomEvent("atf-open-demo-panel"))},windowId:i}),n}function u(){const e=a();if(!e?.registerSystemTile)return;const n=w(c);try{e.registerSystemTile({id:"allterrain-forms",title:"AllTerrain Forms",icon:"dashicons-feedback",order:5,onOpen:()=>t(c?.canEdit?r:o),isOpen:()=>!!(e.windowManager?.getById?.(r)||e.windowManager?.getById?.(o)||e.windowManager?.getById?.(d)),submenu:n})}catch{}}function f(){const e=a();return e?.ready?(e.ready(u),!0):e?.whenReady?(e.whenReady(u),!0):e?.registerSystemTile?(u(),!0):!1}return f()||document.addEventListener("os-init",()=>void f(),{once:!0}),l.submenuFor=w,Object.defineProperty(l,Symbol.toStringTag,{value:"Module"}),l}({}); +var allTerrainFormsDock=function(d){"use strict";const m=window.allTerrainForms,i="allterrain-forms",s="allterrain-forms-entries",u="allterrain-forms-themes",a="allterrain-forms-analytics",w="allterrain-forms-import";function r(e){l()?.openWindow?.(e,{source:"dock"})}function y(e,n,t){const o=l()?.windowManager;if(o?.open){o.open({id:e,url:n,title:t,icon:"dashicons-download"});return}window.location.assign(n)}function l(){return window.wp?.os??null}function p(e){const n=[];if(e?.canEdit&&n.push({title:"Forms",url:"",onSelect:()=>r(i),windowId:i}),e?.canRead&&n.push({title:"Entries",url:"",onSelect:()=>r(s),windowId:s}),e?.canRead&&n.push({title:"Analytics",url:"",onSelect:()=>r(a),windowId:a}),e?.canEdit&&n.push({title:"Themes",url:"",onSelect:()=>r(u),windowId:u}),e?.canEdit&&e?.adminUrl){const t=`${e.adminUrl}admin.php?page=allterrain-forms-import`;n.push({title:"Import forms",url:t,onSelect:()=>y(w,t,"AllTerrain Forms — Import"),windowId:w})}return e?.canEdit&&e?.devMode&&n.push({title:"Demo data",url:"",onSelect:()=>{r(a),document.dispatchEvent(new CustomEvent("atf-open-demo-panel"))},windowId:a}),n}function c(){const e=l();if(!e?.registerSystemTile)return;const n=p(m);try{e.registerSystemTile({id:"allterrain-forms",title:"AllTerrain Forms",icon:"dashicons-feedback",order:5,onOpen:()=>r(m?.canEdit?i:s),isOpen:()=>!!(e.windowManager?.getById?.(i)||e.windowManager?.getById?.(s)||e.windowManager?.getById?.(u)),submenu:n})}catch{}}function f(){const e=l();return e?.ready?(e.ready(c),!0):e?.whenReady?(e.whenReady(c),!0):e?.registerSystemTile?(c(),!0):!1}f()||document.addEventListener("os-init",()=>void f(),{once:!0});function E(){const e=window.wp?.hooks;if(!e?.addFilter)return;const n={"allterrain-forms/open-builder":i,"allterrain-forms/open-entries":s,"allterrain-forms/open-analytics":a};e.addFilter("os.my-wordpress.preview-actions","allterrain-forms/explorer",t=>t.map(o=>{const h=o.id??"";return n[h]?{...o,onSelect:()=>r(n[h])}:o}))}return E(),d.submenuFor=p,Object.defineProperty(d,Symbol.toStringTag,{value:"Module"}),d}({}); diff --git a/docs/wp-explorer-requests.md b/docs/wp-explorer-requests.md new file mode 100644 index 0000000..9b87128 --- /dev/null +++ b/docs/wp-explorer-requests.md @@ -0,0 +1,115 @@ +# WP Explorer — what this plugin needed, and what it still needs + +The companion to [`openstation-requests.md`](openstation-requests.md), for the +shell's **My WordPress** window (WP Explorer). It records what the Explorer's +extension surface let us ship, and — for the Explorer's developer — the asks +that would let a forms plugin appear there the way it deserves to. + +The vision being asked for, in one sentence: **each form as a folder, with its +preview, its submissions and its report inside** — the way a project reads in +a file manager. + +## What the current surface allowed (shipped) + +| Surface | What we did with it | +|---|---| +| `openstation_my_wordpress_entities` | Added a **Forms** section at the root — necessary because every type this plugin registers is `show_ui => false` (it has its own windows), and the Explorer only auto-lists `show_ui` types. | +| `rest_prepare_atf_form` (core) | Tiles carry vitals — *12 questions · 493 entries · Clean* — instead of a bare title. (Needed `excerpt` added to the type's `supports`; without it core REST omits the field entirely.) | +| `openstation_my_wordpress_preview_actions` | Three capability-gated actions declared — **Open in the form builder**, **View entries**, **Open the report** — with `onSelect` wired via `os.my-wordpress.preview-actions` in `dock.ts`. Registered correctly on both sides, but see ask 0: the pane never renders them. | + +## The asks + +### 0. Preview actions never render for a plugin-added section *(bug, with repro)* + +Descriptors survive `openstation_my_wordpress_preview_actions` (verified: +`wp eval 'apply_filters(...)'` lists all three with `sections: ['atf-forms']`), +and the JS filter attaches `onSelect` (verified: +`wp.hooks.applyFilters('os.my-wordpress.preview-actions', …)` returns them +wired). Yet the right pane and the tile context menu render only the shell's +defaults — *Explore details / Open in editor / Navigate into / Edit… / Move to +Trash*. Either plugin-added sections aren't matched against `sections`, or the +pane reads a config snapshot taken before plugins filter. Repro: install +AllTerrain Forms, open the Forms section, select a tile. + +### 0b. The default "Open in editor" must be overridable per section *(bug)* + +On a section whose type has no post-editor screen (`show_ui => false`), **Open +in editor** and **Edit…** try the classic editor URL and fail. Ask: let a +section descriptor declare its own `editAction` (or honor a preview action +flagged as the editor replacement), so a type edited by a native window opens +in it. + +### 0c. What the right pane should let a plugin say *(from user testing)* + +Three things a person selecting a form immediately wanted, none expressible: + +- **A scaled-down live preview** — the section's posts have a front-end render; + a ~50% iframe of it in the pane beats a title and a date. Ask: a section + field naming a preview URL per post (we already serve one), rendered scaled + in the pane. +- **Stat cards above the preview** — submissions, conversion, unread. Ask: a + descriptor for pane stat tiles fed by REST fields the plugin already exposes. +- The action buttons from ask 0 under them. + +### 1. Per-post children — a post as a folder *(the big one)* + +The Explorer's model is flat: sections of posts. A form is a *container* — its +submissions belong inside it. Ask: let a section declare a `children` resolver +(REST path template per post, e.g. `.../forms/{id}/entries`), so a tile can +open as a folder of plugin-provided items instead of a preview pane. Everything +below gets better if this lands. (The context menu already offers **Navigate +into** backed by `openstation_my_wordpress_attached_media` — so the window can +render a post's children today; what's missing is letting a plugin supply +children that are not attachments.) + +### 2. Virtual items inside a folder + +With children, a form's folder wants non-post items too: a **Preview** node +that opens the front-end render, an **Export.csv** node that streams the +export, a **Report** node. Ask: a child-item descriptor with `kind: +'action' | 'file' | 'window'` and an `onOpen` contract, so a folder can hold +things that are not posts. + +### 3. Capability-scoped bridged sections for non-REST types + +Entries are `show_in_rest => false` **by privacy design** — their values are +whatever the form asked for. The shell's own bridge controller +(`desktop-mode/v1/post-type/{type}`) exists, but only for types the shell +itself lists. Ask: a supported way to request a bridged section for a hidden +type, with a declared capability (`atf_read_entries`) enforced by the bridge — +then submissions could be listed without making them world-REST-readable. + +### 4. Custom post statuses in section queries + +Entry statuses are real post statuses (`atf-unread`, `atf-read`, `atf-spam`), +registered `exclude_from_search`. A `wp/v2`-backed section lists `publish` +only. Ask: let a section descriptor declare `statuses: [...]` that the window +passes through to its list requests. + +### 5. Drag payloads on tiles + +This plugin's Entries window emits `allterrain-forms/entry` as a public drag +payload (an entry dragged onto AllTerrain Work becomes a task). Explorer tiles +should be able to carry the same: a section-level `dragPayload( post )` +contract handing `wp.os.dragManager` a typed payload, so anything listed in +the Explorer is draggable everywhere its window equivalent already is. + +### 6. The selected entity as an argument to preview actions + +`onSelect` on a preview action receives nothing. To deep-link — *open the +builder on this form*, not just open the builder — the click needs the entity +it was clicked on. Ask: call `onSelect( action, entity )`, and document it. +(Half of this is ours: the builder should accept an `openWindow` param naming +the form. Tracked on our side.) + +### 7. Count badges on sections + +The dock tile shows an unread-entries badge; the Explorer section should be +able to as well. Ask: an optional `badge` callback or REST field on the +section descriptor. + +## Status + +Ask 0 is a bug with a repro; 0b breaks a shipped default on our types; 0c is what user testing asked for within a day. Asks 1–2 are the vision; 3–4 unlock submissions; 5–7 are polish. None of them +required patching the shell to *want* — every shipped piece above went through +existing filters, which is the discipline working exactly as intended. diff --git a/includes/openstation-explorer.php b/includes/openstation-explorer.php new file mode 100644 index 0000000..c1178e7 --- /dev/null +++ b/includes/openstation-explorer.php @@ -0,0 +1,162 @@ + false` — they have their own windows, so + * putting them in wp-admin's menu twice would be noise. The side effect was + * that the site's forms were invisible in the one place somebody browses + * everything they have. This file adds the Forms section back deliberately: + * a tile per form, wearing what a form is actually about — how many questions + * it asks, how many answers it holds, what it looks like — with the builder, + * the entries window and the report one click away in the preview pane. + * + * Entries are deliberately NOT listed here. They are `show_in_rest => false` + * by privacy design, and the Explorer can only list what REST serves. The + * asks that would make them safe to show — per-post children, capability- + * scoped bridged sections — live in `docs/wp-explorer-requests.md`. + * + * Every filter here is additive and gated: with no shell installed none of + * them ever fire. + * + * @package AllTerrain_Forms + */ + +defined( 'ABSPATH' ) || exit; + +add_action( 'plugins_loaded', 'atf_explorer_register' ); + +/** + * Hooks into whichever spelling of the shell is installed. + * + * @since 0.3.0 + * + * @return void + */ +function atf_explorer_register() { + foreach ( atf_shell_hooks( 'my_wordpress_entities' ) as $hook ) { + add_filter( $hook, 'atf_explorer_entities' ); + } + + foreach ( atf_shell_hooks( 'my_wordpress_preview_actions' ) as $hook ) { + add_filter( $hook, 'atf_explorer_preview_actions' ); + } + + add_filter( 'rest_prepare_atf_form', 'atf_explorer_form_excerpt', 10, 2 ); +} + +/** + * Adds the Forms section to the Explorer's root. + * + * @since 0.3.0 + * + * @param array[] $entities Section descriptors. + * @return array[] + */ +function atf_explorer_entities( $entities ) { + if ( ! atf_can_edit_forms() && ! atf_can_read_entries() ) { + return $entities; + } + + $entities[] = array( + 'id' => 'atf-forms', + 'label' => __( 'Forms', 'allterrain-forms' ), + 'icon' => 'dashicons-feedback', + 'restPath' => 'wp/v2/atf-forms', + 'kind' => 'post', + 'post_type' => ATF_FORM_TYPE, + // A form has no featured image, and leaving thumbnails on makes the + // list request embed media on every tile and get nothing back. + 'thumbnails' => false, + ); + + return $entities; +} + +/** + * The preview-pane buttons a form deserves. + * + * One per surface, capability-gated server-side so a viewer who may only read + * entries is never shown a builder button that would refuse them. The click + * side lives in `dock.ts` against `os.my-wordpress.preview-actions`, which is + * what the `script` handle loads. + * + * @since 0.3.0 + * + * @param array[] $actions Registered preview actions. + * @return array[] + */ +function atf_explorer_preview_actions( $actions ) { + $actions[] = array( + 'id' => 'allterrain-forms/open-builder', + 'label' => __( 'Open in the form builder', 'allterrain-forms' ), + 'icon' => 'dashicons-feedback', + 'capability' => 'atf_edit_forms', + 'sections' => array( 'atf-forms' ), + 'script' => 'allterrain-forms-dock', + ); + + $actions[] = array( + 'id' => 'allterrain-forms/open-entries', + 'label' => __( 'View entries', 'allterrain-forms' ), + 'icon' => 'dashicons-list-view', + 'capability' => 'atf_read_entries', + 'sections' => array( 'atf-forms' ), + 'script' => 'allterrain-forms-dock', + ); + + $actions[] = array( + 'id' => 'allterrain-forms/open-analytics', + 'label' => __( 'Open the report', 'allterrain-forms' ), + 'icon' => 'dashicons-chart-bar', + 'capability' => 'atf_read_entries', + 'sections' => array( 'atf-forms' ), + 'script' => 'allterrain-forms-dock', + ); + + return $actions; +} + +/** + * Dresses a form's Explorer tile in what the form is about. + * + * A tile reading only "Team pulse survey" says the least interesting thing a + * form has to say. The excerpt becomes its vitals — questions, answers, theme + * — so the grid reads like an inventory rather than a list of names. + * + * On every `wp/v2/atf-forms` response, not only the Explorer's: a REST + * consumer that asked for an excerpt gets a truthful one either way, and the + * form has no hand-written excerpt to overwrite. + * + * @since 0.3.0 + * + * @param WP_REST_Response $response The response. + * @param WP_Post $post The form. + * @return WP_REST_Response + */ +function atf_explorer_form_excerpt( $response, $post ) { + if ( ! isset( $response->data['excerpt'] ) || ! is_array( $response->data['excerpt'] ) ) { + return $response; + } + + $schema = atf_get_form_schema( $post->ID ); + $themes = atf_get_themes(); + $slug = $schema['settings']['theme']; + $theme = isset( $themes[ $slug ]['label'] ) ? $themes[ $slug ]['label'] : $slug; + $fields = count( atf_input_fields( $schema ) ); + $entries = atf_count_entries( $post->ID ); + + $vitals = sprintf( + /* translators: 1: question count, 2: entry count, 3: theme name. */ + __( '%1$s · %2$s · %3$s', 'allterrain-forms' ), + /* translators: %d: number of questions. */ + sprintf( _n( '%d question', '%d questions', $fields, 'allterrain-forms' ), $fields ), + /* translators: %d: number of entries. */ + sprintf( _n( '%d entry', '%d entries', $entries, 'allterrain-forms' ), $entries ), + $theme + ); + + $response->data['excerpt']['rendered'] = '

' . esc_html( $vitals ) . '

'; + + return $response; +} diff --git a/includes/post-types.php b/includes/post-types.php index 6cc7734..9832c6d 100644 --- a/includes/post-types.php +++ b/includes/post-types.php @@ -50,7 +50,7 @@ function atf_register_post_types() { 'show_in_menu' => false, 'show_in_rest' => true, 'rest_base' => 'atf-forms', - 'supports' => array( 'title', 'revisions', 'author' ), + 'supports' => array( 'title', 'excerpt', 'revisions', 'author' ), 'capability_type' => array( 'atf_form', 'atf_forms' ), 'map_meta_cap' => true, 'exclude_from_search' => true, diff --git a/src/dock.ts b/src/dock.ts index 37899a3..8d4685d 100644 --- a/src/dock.ts +++ b/src/dock.ts @@ -294,3 +294,43 @@ function boot(): boolean { if ( ! boot() ) { document.addEventListener( 'os-init', () => void boot(), { once: true } ); } + +/** + * Wires the Explorer's preview-action buttons to this plugin's windows. + * + * The PHP side declares the buttons (`includes/openstation-explorer.php`); + * the shell filters them through `os.my-wordpress.preview-actions` so each + * plugin attaches its own `onSelect`. This runs at module load rather than + * behind `boot()`, because `wp.hooks` filters can be added before the shell + * reads them — order is the point of a hook. + */ +function registerExplorerActions(): void { + const hooks = ( window as unknown as { wp?: { hooks?: { addFilter?: ( h: string, ns: string, cb: ( a: unknown[] ) => unknown[] ) => void } } } ).wp?.hooks; + + if ( ! hooks?.addFilter ) { + return; + } + + const windows: Record< string, string > = { + 'allterrain-forms/open-builder': BUILDER, + 'allterrain-forms/open-entries': ENTRIES, + 'allterrain-forms/open-analytics': ANALYTICS, + }; + + hooks.addFilter( 'os.my-wordpress.preview-actions', 'allterrain-forms/explorer', ( actions: unknown[] ) => + actions.map( ( action ) => { + const id = ( action as { id?: string } ).id ?? ''; + + if ( ! windows[ id ] ) { + return action; + } + + return { + ...( action as object ), + onSelect: () => open( windows[ id ] ), + }; + } ) + ); +} + +registerExplorerActions(); diff --git a/tests/phpunit/tests/openstationExplorer.php b/tests/phpunit/tests/openstationExplorer.php new file mode 100644 index 0000000..e28cd35 --- /dev/null +++ b/tests/phpunit/tests/openstationExplorer.php @@ -0,0 +1,84 @@ +user->create( array( 'role' => 'administrator' ) ) ); + + $entities = atf_explorer_entities( array() ); + + $this->assertCount( 1, $entities ); + $this->assertSame( 'atf-forms', $entities[0]['id'] ); + $this->assertSame( 'wp/v2/atf-forms', $entities[0]['restPath'] ); + $this->assertFalse( $entities[0]['thumbnails'] ); + } + + /** + * Somebody with no forms capability gets no section at all. + * + * @covers ::atf_explorer_entities + */ + public function test_section_is_withheld_without_capability() { + wp_set_current_user( self::factory()->user->create( array( 'role' => 'subscriber' ) ) ); + + $this->assertSame( array(), atf_explorer_entities( array() ) ); + } + + /** + * Three buttons, each pointing at its own surface and capability. + * + * @covers ::atf_explorer_preview_actions + */ + public function test_preview_actions_cover_the_surfaces() { + $actions = atf_explorer_preview_actions( array() ); + $by_id = wp_list_pluck( $actions, 'capability', 'id' ); + + $this->assertSame( 'atf_edit_forms', $by_id['allterrain-forms/open-builder'] ); + $this->assertSame( 'atf_read_entries', $by_id['allterrain-forms/open-entries'] ); + $this->assertSame( 'atf_read_entries', $by_id['allterrain-forms/open-analytics'] ); + + foreach ( $actions as $action ) { + $this->assertSame( array( 'atf-forms' ), $action['sections'] ); + $this->assertSame( 'allterrain-forms-dock', $action['script'] ); + } + } + + /** + * A form's tile excerpt states its vitals, not its absence of prose. + * + * @covers ::atf_explorer_form_excerpt + */ + public function test_tile_excerpt_is_the_vitals() { + atf_add_capabilities(); + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + + $form_id = atf_create_form_from_template( 'blank', 'Explorer test' ); + $post = get_post( $form_id ); + + $response = new WP_REST_Response( + array( 'excerpt' => array( 'rendered' => '' ) ) + ); + + $filtered = atf_explorer_form_excerpt( $response, $post ); + + $this->assertStringContainsString( 'question', $filtered->data['excerpt']['rendered'] ); + $this->assertStringContainsString( '0 entries', $filtered->data['excerpt']['rendered'] ); + } +}