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
1 change: 1 addition & 0 deletions apps/user-edit/user-edit.os.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
require_once dirname( __DIR__ ) . '/users/parts/permissions.php';
require_once dirname( __DIR__ ) . '/users/parts/color-schemes.php';
require_once dirname( __DIR__ ) . '/users/parts/fields.php';
require_once dirname( __DIR__ ) . '/users/parts/plugin-sections.php';
require_once dirname( __DIR__ ) . '/users/parts/facts.php';
require_once dirname( __DIR__ ) . '/users/parts/profile-script.php';

Expand Down
3 changes: 3 additions & 0 deletions apps/users/parts/facts.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ function openstation_users_profile_facts() {
'defaultRole' => (string) get_option( 'default_role', 'subscriber' ),
'contactMethods' => (array) wp_get_user_contact_methods(),
'colorSchemes' => openstation_user_edit_window_color_schemes(),
// Sections other plugins print on the classic profile screens,
// which the element embeds below its own form.
'pluginSections' => openstation_users_profile_plugin_sections(),
);
return $cache[ $viewer_id ];
}
196 changes: 196 additions & 0 deletions apps/users/parts/plugin-sections.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
<?php
/**
* Users: the profile sections other plugins add, inside the native profile.
*
* `profile.php` and `user-edit.php` print whatever plugins hook onto
* `show_user_profile` / `edit_user_profile` (sign-in security options,
* author fields, …), plus rows on `personal_options` (inside
* core's Personal Options table) and `profile_personal_options` (right
* after it), and save all of it through the classic POST
* that fires `personal_options_update` / `edit_user_profile_update`.
* `<os-user-profile>` edits the core fields over REST, which runs none
* of that, so those sections would simply not exist in the window.
*
* The element embeds the real screen instead (`wp.os.embedAdminPage()`)
* with the flag below, and this file narrows that page to the plugin
* sections with CSS: core's form stays whole (its nonce, hidden
* fields and submit button), so a plugin's markup, its scripts keyed
* on the screen, its own nonces and its save hook all run untouched.
*
* @package OpenStation
*/

defined( 'ABSPATH' ) || exit;

/** The request flag that narrows the profile screen to plugin sections. */
const OPENSTATION_PROFILE_SECTIONS_FLAG = 'openstation_profile_sections';

/**
* Which profile screens carry plugin sections, and where they live:
* the facts `<os-user-profile>` reads to decide whether to embed.
*
* @return array{own: bool, others: bool, adminUrl: string}
*/
function openstation_users_profile_plugin_sections() {
$rows = (bool) has_action( 'personal_options' );
return array(
'own' => $rows || has_action( 'show_user_profile' ) || has_action( 'profile_personal_options' ),
'others' => $rows || has_action( 'edit_user_profile' ),
'adminUrl' => admin_url(),
);
}

/**
* Whether this request is the narrowed profile screen. Read from
* `$_REQUEST`: the form posts to its own URL without the query string,
* so a save carries the flag as a hidden field.
*
* @return bool
*/
function openstation_is_profile_sections_request() {
global $pagenow;
if ( ! in_array( $pagenow, array( 'profile.php', 'user-edit.php' ), true ) || ! openstation_is_chromeless_request() ) {
return false;
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display flag; the save itself is core's nonce-checked handler.
return isset( $_REQUEST[ OPENSTATION_PROFILE_SECTIONS_FLAG ] ) && '1' === sanitize_text_field( wp_unslash( $_REQUEST[ OPENSTATION_PROFILE_SECTIONS_FLAG ] ) );
}

/**
* Wire the narrowed screen. On `admin_init`, so the plugin hooks the
* facts count are never inflated by this file's own marker.
*
* @return void
*/
function openstation_profile_sections_setup() {
if ( ! openstation_is_profile_sections_request() ) {
return;
}
// Rows inside core's own Personal Options table, and the block a
// plugin prints right after it, are marked only when a plugin hooks
// them: a lone marker would show an empty core table.
if ( has_action( 'personal_options' ) ) {
add_action( 'personal_options', 'openstation_profile_sections_rows_marker', PHP_INT_MIN );
}
if ( has_action( 'profile_personal_options' ) ) {
add_action( 'profile_personal_options', 'openstation_profile_sections_block_start', PHP_INT_MIN );
add_action( 'profile_personal_options', 'openstation_profile_sections_block_end', PHP_INT_MAX );
}
add_action( 'show_user_profile', 'openstation_profile_sections_marker', PHP_INT_MIN );
add_action( 'edit_user_profile', 'openstation_profile_sections_marker', PHP_INT_MIN );
add_action( 'openstation_chromeless_styles', 'openstation_profile_sections_styles' );
add_action( 'admin_head', 'openstation_profile_sections_link_script' );
add_filter( 'admin_body_class', 'openstation_profile_sections_body_class' );
}
add_action( 'admin_init', 'openstation_profile_sections_setup' );

/**
* Where core's sections end and the plugins' begin, plus the flag the
* save posts back so the page it lands on is narrowed too.
*
* @return void
*/
function openstation_profile_sections_marker() {
printf(
'<input type="hidden" name="%1$s" value="1" /><span id="os-profile-sections-start" hidden></span>',
esc_attr( OPENSTATION_PROFILE_SECTIONS_FLAG )
);
}

/**
* Where plugin rows begin inside core's Personal Options table.
*
* @return void
*/
function openstation_profile_sections_rows_marker() {
echo '<tr id="os-profile-rows-start" hidden></tr>';
}

/**
* Where the `profile_personal_options` block begins.
*
* @return void
*/
function openstation_profile_sections_block_start() {
echo '<span id="os-profile-block-start" hidden></span>';
}

/**
* Where the `profile_personal_options` block ends.
*
* @return void
*/
function openstation_profile_sections_block_end() {
echo '<span id="os-profile-block-end" hidden></span>';
}

/**
* Hide the page heading and every child of the form that is not plugin
* output: before the section marker, outside the `profile_personal_options`
* block, and core's own rows of the Personal Options table.
* Nothing is removed: hidden fields still post, notices still show.
*
* @return void
*/
function openstation_profile_sections_styles() {
wp_add_inline_style(
'os-chromeless',
'body.os-profile-sections #profile-page > h1,'
. 'body.os-profile-sections #profile-page > .page-title-action,'
. 'body.os-profile-sections #profile-page > .wp-header-end,'
. 'body.os-profile-sections #your-profile > :not( #os-profile-sections-start ~ * )'
. ':not( #os-profile-block-start ~ :not( #os-profile-block-end ~ * ) )'
. ':not( table:has( #os-profile-rows-start ) ):not( h2:has( + table #os-profile-rows-start ) ),'
. 'body.os-profile-sections #your-profile table:has( #os-profile-rows-start ) tr:not( #os-profile-rows-start ~ tr ) { display: none !important; }'
);
}

/**
* Keep a plugin's links to this screen, or to `wp-login.php` for a
* re-authentication that returns here, inside the embed. The chromeless
* bridge hands every admin link to the shell, which would open the
* profile as a window of its own; this listener runs first (on the
* window, in the capture phase) and lets the browser follow the link.
*
* @return void
*/
function openstation_profile_sections_link_script() {
wp_print_inline_script_tag(
'window.addEventListener( "click", function ( e ) {'
. 'if ( e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey ) { return; }'
. 'var a = e.target && e.target.closest ? e.target.closest( "a[href]" ) : null;'
. 'if ( ! a || ( a.target && a.target !== "_self" ) || a.hasAttribute( "download" ) ) { return; }'
. 'var u; try { u = new URL( a.href, location.href ); } catch ( err ) { return; }'
. 'if ( u.origin === location.origin && /\/(profile|user-edit|wp-login)\.php$/.test( u.pathname ) ) { e.stopImmediatePropagation(); }'
. '}, true );'
);
}

/**
* Scope class for the styles above.
*
* @param string $classes Space-separated body classes.
* @return string
*/
function openstation_profile_sections_body_class( $classes ) {
return $classes . ' os-profile-sections';
}

/**
* Keep the flag through the save's redirect back to the profile, so
* the embed does not land on the whole screen after "Update Profile".
*
* @param string $location The redirect URL.
* @return string
*/
function openstation_profile_sections_preserve_redirect( $location ) {
if ( empty( $location ) || ! openstation_is_profile_sections_request() || ! openstation_is_admin_redirect_target( $location ) ) {
return $location;
}
$path = (string) wp_parse_url( $location, PHP_URL_PATH );
if ( ! preg_match( '#/(profile|user-edit)\.php$#', $path ) || false !== strpos( $location, OPENSTATION_PROFILE_SECTIONS_FLAG . '=' ) ) {
return $location;
}
return add_query_arg( OPENSTATION_PROFILE_SECTIONS_FLAG, '1', $location );
}
add_filter( 'wp_redirect', 'openstation_profile_sections_preserve_redirect', 999 );
91 changes: 90 additions & 1 deletion apps/users/profile/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* one mount per id with the record and the insights fetched ONCE each,
* a retarget that re-mounts once, the properties the host feeds, and
* an aside that refreshes ITSELF after a save (not the first aside in
* the document).
* the document), and the plugin sections embedded below the form.
*/
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import '../../../src/ui/components/os-form/os-form';
Expand All @@ -17,6 +17,7 @@ import '../../../src/ui/components/os-button/os-button';
import './index';
import type { OsUserProfile } from './index';
import type { ProfileConfig } from './types';
import { isFormDirty, refreshUntouched, shouldRenarrow, snapshotForm } from './plugin-sections-form';
import { createSharedStore } from '../../../src/shared-store';
import { readFootprintTarget } from '../../../src/open-targets/footprint-target';

Expand Down Expand Up @@ -203,4 +204,92 @@ describe( '<os-user-profile>', () => {
expect( openWindow ).toHaveBeenCalledWith( 'my-wordpress', expect.objectContaining( { source: expect.any( String ) } ) );
expect( readFootprintTarget() ).toMatchObject( { userId: 3, userName: 'Jane Doe' } );
} );

test( 'embeds the plugin sections of the screen the person is edited on, and reloads them after a save', async () => {
const urls: string[] = [];
const embedAdminPage = vi.fn( ( host: HTMLElement, url: string ) => {
urls.push( url );
host.replaceChildren( document.createElement( 'iframe' ) );
return () => host.replaceChildren();
} );
( window as unknown as { wp?: unknown } ).wp = { os: { embedAdminPage } };
const pluginSections = { own: true, others: false, adminUrl: 'http://example.test/wp-admin/' };
const ownUrl = 'http://example.test/wp-admin/profile.php?openstation_profile_sections=1&openstation_chromeless=1';

const own = mountProfile( 1, { fetch: fakeFetch().fetch, config: { ...config, pluginSections } } );
const other = mountProfile( 3, { fetch: fakeFetch().fetch, config: { ...config, pluginSections } } );
await wait( 10 );
expect( urls ).toEqual( [ ownUrl ] );
expect( other.querySelector< HTMLElement >( '[data-os-user-profile-plugins]' )?.hidden ).toBe( true );

// The embedded page posts the core fields it loaded with: with no
// edits in it, a save from the form reloads it.
( own.querySelector( 'os-form' ) as HTMLElement & { submit: () => void } ).submit();
await wait( 30 );
expect( own.querySelector( '[data-os-user-profile-plugins] iframe' )?.getAttribute( 'src' ) ).toBe( ownUrl );

const editor = mountProfile( 3, { fetch: fakeFetch().fetch, config: { ...config, pluginSections: { ...pluginSections, others: true } } } );
await wait( 10 );
expect( urls[ 1 ] ).toBe( 'http://example.test/wp-admin/user-edit.php?user_id=3&openstation_profile_sections=1&openstation_chromeless=1' );
expect( editor.querySelector< HTMLElement >( '[data-os-user-profile-plugins]' )?.hidden ).toBe( false );
} );

test( 'opens the embedded page’s off-site links in a new tab and leaves admin links to the embed', async () => {
( window as unknown as { wp?: unknown } ).wp = {
os: {
embedAdminPage: ( host: HTMLElement ) => {
host.replaceChildren( document.createElement( 'iframe' ) );
return () => host.replaceChildren();
},
},
};
const open = vi.spyOn( window, 'open' ).mockImplementation( () => null );
const el = mountProfile( 1, { fetch: fakeFetch().fetch, config: { ...config, pluginSections: { own: true, adminUrl: 'http://example.test/wp-admin/' } } } );
await wait( 10 );
const frame = el.querySelector( '[data-os-user-profile-plugins] iframe' ) as HTMLIFrameElement;
const post = ( data: unknown ): void => {
window.dispatchEvent( new MessageEvent( 'message', { data, origin: window.location.origin, source: frame.contentWindow } ) );
};

post( { type: 'os-iframe-admin-link', url: `${ window.location.origin }/wp-admin/options-general.php`, label: 'Settings' } );
expect( open ).not.toHaveBeenCalled();
post( { type: 'os-external-link', url: 'https://example.org/docs' } );
expect( open ).toHaveBeenCalledWith( 'https://example.org/docs', '_blank', 'noopener' );
} );
} );

describe( 'the embedded profile form', () => {
const formIn = ( html: string ): HTMLFormElement => {
const doc = document.implementation.createHTMLDocument( '' );
doc.body.innerHTML = `<form id="your-profile">${ html }</form>`;
return doc.getElementById( 'your-profile' ) as HTMLFormElement;
};

test( 'is narrowed again once, and only when the page is the profile form', () => {
const whole = formIn( '<input name="email" />' ).ownerDocument;
const narrowed = formIn( '<span id="os-profile-sections-start"></span>' ).ownerDocument;
const invalid = document.implementation.createHTMLDocument( '' );
invalid.body.textContent = 'Invalid user ID.';

expect( shouldRenarrow( whole, true, false ) ).toBe( true );
expect( shouldRenarrow( whole, true, true ) ).toBe( false );
expect( shouldRenarrow( narrowed, true, false ) ).toBe( false );
expect( shouldRenarrow( invalid, true, false ) ).toBe( false );
expect( shouldRenarrow( whole, false, false ) ).toBe( false );
} );

test( 'keeps the user’s edits and takes fresh values for every field left alone', () => {
const live = formIn( '<input name="email" value="old@example.test" /><input type="checkbox" name="opt" /><input name="plugin_field" value="a" />' );
const snapshot = snapshotForm( live );
expect( isFormDirty( live, snapshot ) ).toBe( false );

( live.elements.namedItem( 'plugin_field' ) as HTMLInputElement ).value = 'edited';
expect( isFormDirty( live, snapshot ) ).toBe( true );

const fresh = formIn( '<input name="email" value="new@example.test" /><input type="checkbox" name="opt" checked /><input name="plugin_field" value="a" />' );
refreshUntouched( live, snapshot, fresh );
expect( ( live.elements.namedItem( 'email' ) as HTMLInputElement ).value ).toBe( 'new@example.test' );
expect( ( live.elements.namedItem( 'opt' ) as HTMLInputElement ).checked ).toBe( true );
expect( ( live.elements.namedItem( 'plugin_field' ) as HTMLInputElement ).value ).toBe( 'edited' );
} );
} );
25 changes: 25 additions & 0 deletions apps/users/profile/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { joinRestUrl } from '../../../src/rest-url';
import { trackedFetch } from '../../../src/tracked-fetch';
import { fetchInsights } from './client';
import { mountProfileFormAt } from './form';
import { mountPluginSections, type PluginSectionsHandle } from './plugin-sections';
import { paintActivity, paintAside, paintInsightsError, paintInsightsLoading } from './insights';
import type { ProfileConfig, ProfileHost } from './types';

Expand All @@ -59,6 +60,7 @@ export class OsUserProfile extends HTMLElement {
private _config: ProfileConfig = {};
private _fetch: ProfileHost[ 'fetch' ] | null = null;
private _toast: ProfileHost[ 'toast' ] | null = null;
private _pluginSections: PluginSectionsHandle | null = null;
private _shellReady = false;
private _mountedFor: number | null = null;
private _generation = 0;
Expand Down Expand Up @@ -99,14 +101,23 @@ export class OsUserProfile extends HTMLElement {
<aside class="os-users__edit-aside" data-os-user-profile-aside></aside>
<main class="os-users__edit-main">
<div data-os-user-profile-form></div>
<section data-os-user-profile-plugins hidden></section>
<div class="os-users__edit-activity" data-os-user-profile-activity></div>
</main>
</div>
`;
}
if ( this._mountedFor ) {
this._mountPluginSections();
}
this._schedule();
}

disconnectedCallback(): void {
this._pluginSections?.teardown();
this._pluginSections = null;
}

attributeChangedCallback( name: string, oldValue: string | null, newValue: string | null ): void {
if ( name === 'user-id' && oldValue !== newValue ) {
this._schedule();
Expand Down Expand Up @@ -164,12 +175,26 @@ export class OsUserProfile extends HTMLElement {
onSaved: () => {
if ( generation === this._generation ) {
void this.refreshInsights();
// The embedded page posts the core fields it loaded
// with: reload it, or save the plugin edits it holds.
this._pluginSections?.afterHostSave();
}
},
} ).catch( () => undefined );
this._mountPluginSections();
void this._loadInsights( generation, false );
}

/** (Re-)mount the plugin sections for the mounted user. */
private _mountPluginSections(): void {
this._pluginSections?.teardown();
this._pluginSections = null;
const section = this.querySelector< HTMLElement >( '[data-os-user-profile-plugins]' );
if ( section && this._mountedFor ) {
this._pluginSections = mountPluginSections( section, this._mountedFor, this.host() );
}
}

private async _loadInsights( generation: number, fresh: boolean ): Promise< void > {
const aside = this.querySelector< HTMLElement >( '[data-os-user-profile-aside]' );
const activity = this.querySelector< HTMLElement >( '[data-os-user-profile-activity]' );
Expand Down
Loading
Loading