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
100 changes: 76 additions & 24 deletions src/components/MultiAuthorPlugin.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { useState, useEffect, useCallback } from '@wordpress/element';
import { PluginDocumentSettingPanel } from '@wordpress/editor';
import {
PluginDocumentSettingPanel,
store as editorStore,
} from '@wordpress/editor';
import { useSelect } from '@wordpress/data';
import { store as coreStore } from '@wordpress/core-data';
import { store as editorStore } from '@wordpress/editor';
import { __, sprintf } from '@wordpress/i18n';
import apiFetch from '@wordpress/api-fetch';
import {
Expand All @@ -22,6 +24,10 @@ const NAMESPACE = '/multi-author-posts/v1';

/**
* A single co-author row.
* @param root0
* @param root0.author
* @param root0.canManage
* @param root0.onRemove
*/
function AuthorCard( { author, canManage, onRemove } ) {
return (
Expand All @@ -44,7 +50,10 @@ function AuthorCard( { author, canManage, onRemove } ) {
onClick={ () => onRemove( author.id ) }
aria-label={
/* translators: %s: author display name */
sprintf( __( 'Remove %s', 'multi-author-posts' ), author.name )
sprintf(
__( 'Remove %s', 'multi-author-posts' ),
author.name
)
}
>
{ __( 'Remove', 'multi-author-posts' ) }
Expand All @@ -56,6 +65,9 @@ function AuthorCard( { author, canManage, onRemove } ) {

/**
* User search + direct-add UI (shown only when the current user can manage co-authors).
* @param root0
* @param root0.postId
* @param root0.onAdd
*/
function DirectAdd( { postId, onAdd } ) {
const [ search, setSearch ] = useState( '' );
Expand All @@ -71,7 +83,9 @@ function DirectAdd( { postId, onAdd } ) {
setIsSearching( true );
const controller = new AbortController();
apiFetch( {
path: `${ NAMESPACE }/posts/${ postId }/suggested-authors?search=${ encodeURIComponent( search ) }`,
path: `${ NAMESPACE }/posts/${ postId }/suggested-authors?search=${ encodeURIComponent(
search
) }`,
signal: controller.signal,
} )
.then( ( data ) => {
Expand Down Expand Up @@ -104,7 +118,10 @@ function DirectAdd( { postId, onAdd } ) {
label={ __( 'Add existing author', 'multi-author-posts' ) }
value={ search }
onChange={ setSearch }
placeholder={ __( 'Search by name or email…', 'multi-author-posts' ) }
placeholder={ __(
'Search by name or email…',
'multi-author-posts'
) }
/>
{ isSearching && <Spinner /> }
{ suggestions.length > 0 && (
Expand All @@ -119,7 +136,12 @@ function DirectAdd( { postId, onAdd } ) {
width={ 28 }
height={ 28 }
/>
<span><TextHighlight text={ user.name } highlight={ search } /></span>
<span>
<TextHighlight
text={ user.name }
highlight={ search }
/>
</span>
</HStack>
<Button
variant="secondary"
Expand All @@ -133,17 +155,24 @@ function DirectAdd( { postId, onAdd } ) {
) ) }
</ul>
) }
{ search.length >= 2 && ! isSearching && suggestions.length === 0 && (
<p className="map-no-suggestions">
{ __( 'No matching authors found.', 'multi-author-posts' ) }
</p>
) }
{ search.length >= 2 &&
! isSearching &&
suggestions.length === 0 && (
<p className="map-no-suggestions">
{ __(
'No matching authors found.',
'multi-author-posts'
) }
</p>
) }
</VStack>
);
}

/**
* Invite-link section (shown only when the current user can manage co-authors).
* @param root0
* @param root0.postId
*/
function InviteSection( { postId } ) {
// Plaintext URL is only available in-memory, immediately after creation.
Expand Down Expand Up @@ -186,18 +215,24 @@ function InviteSection( { postId } ) {
}, [ postId ] );

const handleCopy = useCallback( () => {
if ( ! inviteUrl ) return;
if ( ! inviteUrl ) {
return;
}
navigator.clipboard?.writeText( inviteUrl ).then( () => {
setCopied( true );
setTimeout( () => setCopied( false ), 2000 );
} );
}, [ inviteUrl ] );

if ( isLoading ) return <Spinner />;
if ( isLoading ) {
return <Spinner />;
}

return (
<VStack spacing={ 2 } className="map-invite-section">
<strong>{ __( 'Shared invite link', 'multi-author-posts' ) }</strong>
<strong>
{ __( 'Shared invite link', 'multi-author-posts' ) }
</strong>
<p className="map-invite-description">
{ __(
'Anyone with this link who is registered on the network can join as a co-author. The link is valid for 24 hours and is only shown once — copy it now.',
Expand All @@ -213,7 +248,11 @@ function InviteSection( { postId } ) {
onClick={ ( e ) => e.target.select() }
/>
<HStack justify="flex-start" spacing={ 2 }>
<Button variant="secondary" onClick={ handleCopy } disabled={ copied }>
<Button
variant="secondary"
onClick={ handleCopy }
disabled={ copied }
>
{ copied
? __( 'Copied!', 'multi-author-posts' )
: __( 'Copy link', 'multi-author-posts' ) }
Expand All @@ -223,7 +262,10 @@ function InviteSection( { postId } ) {
) }
{ ! inviteUrl && isActive && (
<p className="map-invite-active">
{ __( 'An invite link is active. Regenerate to issue a new one (the previous link will stop working) or revoke it.', 'multi-author-posts' ) }
{ __(
'An invite link is active. Regenerate to issue a new one (the previous link will stop working) or revoke it.',
'multi-author-posts'
) }
</p>
) }
<HStack justify="flex-start" spacing={ 2 }>
Expand All @@ -233,7 +275,11 @@ function InviteSection( { postId } ) {
: __( 'Generate invite link', 'multi-author-posts' ) }
</Button>
{ isActive && (
<Button variant="tertiary" isDestructive onClick={ handleRevoke }>
<Button
variant="tertiary"
isDestructive
onClick={ handleRevoke }
>
{ __( 'Revoke', 'multi-author-posts' ) }
</Button>
) }
Expand Down Expand Up @@ -263,13 +309,14 @@ export default function MultiAuthorPlugin() {
// Post author and any existing co-author share the same management trust.
// The REST API enforces the same rule server-side.
const canManage =
!! currentUserId && (
currentUserId === postAuthorId ||
coAuthors.some( ( a ) => a.id === currentUserId )
);
!! currentUserId &&
( currentUserId === postAuthorId ||
coAuthors.some( ( a ) => a.id === currentUserId ) );

useEffect( () => {
if ( ! postId ) return;
if ( ! postId ) {
return;
}
setIsLoading( true );
apiFetch( { path: `${ NAMESPACE }/posts/${ postId }/co-authors` } )
.then( ( data ) => {
Expand Down Expand Up @@ -299,14 +346,19 @@ export default function MultiAuthorPlugin() {
.catch( ( err ) =>
setError(
err?.message ??
__( 'Could not remove co-author.', 'multi-author-posts' )
__(
'Could not remove co-author.',
'multi-author-posts'
)
)
);
},
[ postId ]
);

if ( ! postId ) return null;
if ( ! postId ) {
return null;
}

return (
<PluginDocumentSettingPanel
Expand Down
84 changes: 64 additions & 20 deletions tests/js/MultiAuthorPlugin.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,16 @@ const { useSelect } = require( '@wordpress/data' );

const POST_ID = 42;
const AUTHOR_ID = 1;
const CO_AUTHOR = { id: 2, name: 'Jane Doe', avatar: 'http://example.com/avatar.jpg' };

function setupUseSelect( { currentUserId = AUTHOR_ID, postAuthorId = AUTHOR_ID } = {} ) {
const CO_AUTHOR = {
id: 2,
name: 'Jane Doe',
avatar: 'http://example.com/avatar.jpg',
};

function setupUseSelect( {
currentUserId = AUTHOR_ID,
postAuthorId = AUTHOR_ID,
} = {} ) {
useSelect.mockImplementation( ( selector ) =>
selector( ( storeName ) => {
const map = {
Expand Down Expand Up @@ -76,20 +83,26 @@ describe( 'MultiAuthorPlugin', () => {
it( 'renders the Co-Authors panel heading', async () => {
render( <MultiAuthorPlugin /> );
await waitFor( () =>
expect( screen.getByRole( 'heading', { name: /co-authors/i } ) ).toBeInTheDocument()
expect(
screen.getByRole( 'heading', { name: /co-authors/i } )
).toBeInTheDocument()
);
} );

it( 'shows "No co-authors yet" when the list is empty', async () => {
render( <MultiAuthorPlugin /> );
await waitFor( () =>
expect( screen.getByText( /no co-authors yet/i ) ).toBeInTheDocument()
expect(
screen.getByText( /no co-authors yet/i )
).toBeInTheDocument()
);
} );

it( 'renders co-author names after loading', async () => {
apiFetch.mockImplementation( ( { path } ) => {
if ( path.includes( '/invite' ) ) return Promise.resolve( { active: false } );
if ( path.includes( '/invite' ) ) {
return Promise.resolve( { active: false } );
}
return Promise.resolve( [ CO_AUTHOR ] );
} );

Expand All @@ -111,24 +124,34 @@ describe( 'MultiAuthorPlugin', () => {
it( 'shows "active" state with Regenerate / Revoke when invite exists', async () => {
apiFetch.mockImplementation( ( { path } ) => {
if ( path.includes( '/invite' ) ) {
return Promise.resolve( { active: true, created: 1000, expires: 2000 } );
return Promise.resolve( {
active: true,
created: 1000,
expires: 2000,
} );
}
return Promise.resolve( [] );
} );

render( <MultiAuthorPlugin /> );
await waitFor( () =>
expect(
screen.getByRole( 'button', { name: /regenerate invite link/i } )
screen.getByRole( 'button', {
name: /regenerate invite link/i,
} )
).toBeInTheDocument()
);
expect( screen.getByRole( 'button', { name: /revoke/i } ) ).toBeInTheDocument();
expect(
screen.getByRole( 'button', { name: /revoke/i } )
).toBeInTheDocument();
} );

it( 'reveals the plaintext URL exactly once, after generate', async () => {
apiFetch.mockImplementation( ( { path, method } ) => {
if ( path.includes( '/invite' ) && method === 'POST' ) {
return Promise.resolve( { invite_url: 'http://example.com/?map_invite=plain' } );
return Promise.resolve( {
invite_url: 'http://example.com/?map_invite=plain',
} );
}
if ( path.includes( '/invite' ) ) {
return Promise.resolve( { active: false } );
Expand All @@ -146,7 +169,9 @@ describe( 'MultiAuthorPlugin', () => {
);

await act( async () => {
await user.click( screen.getByRole( 'button', { name: /generate invite link/i } ) );
await user.click(
screen.getByRole( 'button', { name: /generate invite link/i } )
);
} );

expect(
Expand All @@ -160,19 +185,30 @@ describe( 'MultiAuthorPlugin', () => {
render( <MultiAuthorPlugin /> );
await waitFor( () =>
// Co-authors list loads
expect( screen.queryByText( /no co-authors yet/i ) ).toBeInTheDocument()
expect(
screen.queryByText( /no co-authors yet/i )
).toBeInTheDocument()
);

expect( screen.queryByRole( 'button', { name: /generate invite link/i } ) ).toBeNull();
expect(
screen.queryByRole( 'button', { name: /generate invite link/i } )
).toBeNull();
expect( screen.queryByRole( 'searchbox' ) ).toBeNull();
} );

it( 'shows management controls to a co-author of the post', async () => {
const CO_AUTHOR_ID = 99;
setupUseSelect( { currentUserId: CO_AUTHOR_ID, postAuthorId: AUTHOR_ID } );
setupUseSelect( {
currentUserId: CO_AUTHOR_ID,
postAuthorId: AUTHOR_ID,
} );
apiFetch.mockImplementation( ( { path } ) => {
if ( path.includes( '/invite' ) ) return Promise.resolve( { active: false } );
return Promise.resolve( [ { id: CO_AUTHOR_ID, name: 'Me', avatar: 'x' } ] );
if ( path.includes( '/invite' ) ) {
return Promise.resolve( { active: false } );
}
return Promise.resolve( [
{ id: CO_AUTHOR_ID, name: 'Me', avatar: 'x' },
] );
} );

render( <MultiAuthorPlugin /> );
Expand All @@ -186,8 +222,12 @@ describe( 'MultiAuthorPlugin', () => {

it( 'calls DELETE when Remove button is clicked', async () => {
apiFetch.mockImplementation( ( { path, method } ) => {
if ( method === 'DELETE' ) return Promise.resolve( {} );
if ( path.includes( '/invite' ) ) return Promise.resolve( { active: false } );
if ( method === 'DELETE' ) {
return Promise.resolve( {} );
}
if ( path.includes( '/invite' ) ) {
return Promise.resolve( { active: false } );
}
return Promise.resolve( [ CO_AUTHOR ] );
} );

Expand All @@ -199,13 +239,17 @@ describe( 'MultiAuthorPlugin', () => {
);

await act( async () => {
await user.click( screen.getByRole( 'button', { name: /remove jane doe/i } ) );
await user.click(
screen.getByRole( 'button', { name: /remove jane doe/i } )
);
} );

expect( apiFetch ).toHaveBeenCalledWith(
expect.objectContaining( {
method: 'DELETE',
path: expect.stringContaining( `/co-authors/${ CO_AUTHOR.id }` ),
path: expect.stringContaining(
`/co-authors/${ CO_AUTHOR.id }`
),
} )
);
} );
Expand Down
Loading