diff --git a/projects/packages/publicize/_inc/components/connection-icon/index.tsx b/projects/packages/publicize/_inc/components/connection-icon/index.tsx index 451da6654ed6..f51d8bcbff62 100644 --- a/projects/packages/publicize/_inc/components/connection-icon/index.tsx +++ b/projects/packages/publicize/_inc/components/connection-icon/index.tsx @@ -27,6 +27,11 @@ export type ConnectionIconProps = { profilePicture: string; disabled?: boolean; className?: string; + // Visual size of the avatar + overlapping service icon. `small` (default) + // is 28×28 avatar + 14×14 service icon for compact rows / dataviews; + // `medium` is 32×32 avatar + 16×16 service icon for roomier rows such as + // the chassis Overview "Connected accounts" list. + size?: 'small' | 'medium'; }; /** @@ -40,6 +45,7 @@ export function ConnectionIcon( { profilePicture, disabled, className, + size = 'small', }: ConnectionIconProps ) { const [ imageErrorFor, setImageErrorFor ] = useState( null ); @@ -55,6 +61,7 @@ export function ConnectionIcon( {
` slot) — still renders at + // a sensible avatar size. + &.medium { + block-size: 32px; + inline-size: 32px; + + img, + .avatar { + block-size: 32px; + border: 0; + inline-size: 32px; + } + + .social-icon { + block-size: 16px; + inline-size: 16px; + } } } diff --git a/projects/packages/publicize/_inc/components/connection-management/connection-info-modern.tsx b/projects/packages/publicize/_inc/components/connection-management/connection-info-modern.tsx new file mode 100644 index 000000000000..efac45880a98 --- /dev/null +++ b/projects/packages/publicize/_inc/components/connection-management/connection-info-modern.tsx @@ -0,0 +1,142 @@ +import { useSelect } from '@wordpress/data'; +import { useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { chevronDown, info } from '@wordpress/icons'; +import { Collapsible, Icon, IconButton, Stack, Text } from '@wordpress/ui'; +import { store as socialStore } from '../../social-store'; +import ConnectionIcon from '../connection-icon'; +import { XNotice } from '../services/x-notice'; +import { ConnectionName } from './connection-name'; +import { ConnectionStatus, ConnectionStatusProps } from './connection-status'; +import { ConnectionTemplateEditor } from './connection-template'; +import { Disconnect } from './disconnect'; +import { MarkAsShared } from './mark-as-shared'; +import styles from './style-modern.module.scss'; +import type { SyntheticEvent } from 'react'; + +type ConnectionInfoProps = ConnectionStatusProps & { + canMarkAsShared: boolean; +}; + +const stopPropagation = ( event: SyntheticEvent ) => event.stopPropagation(); + +/** + * Connection info component + * + * @param {ConnectionInfoProps} props - component props + * + * @return React element + */ +export function ModernConnectionInfo( { + connection, + service, + canMarkAsShared, +}: ConnectionInfoProps ) { + const [ isPanelOpen, setIsPanelOpen ] = useState( false ); + + const { canManageConnection, isUnsupported } = useSelect( + select => { + const { canUserManageConnection, getServicesBy } = select( socialStore ); + + return { + canManageConnection: canUserManageConnection( connection ), + isUnsupported: getServicesBy( 'status', 'unsupported' ).some( + ( { id } ) => id === connection.service_name + ), + }; + }, + [ connection ] + ); + + const hasStatus = + connection.status === 'broken' || connection.status === 'must_reauth' || isUnsupported; + + const markAsSharedHelp = __( + 'If enabled, the connection will be available to all administrators, editors, and authors.', + 'jetpack-publicize-pkg' + ); + + return ( + + } + > + + + { /* + * The profile-name link lives inside the row, which doubles as + * the Collapsible.Trigger. Without stopping propagation a click + * on the link would also toggle the disclosure (and an anchor + * inside role="button" is invalid nesting). Mirror the + * connection-status-wrap below so the link opens the profile + * without toggling the panel. + */ } + + + + { hasStatus ? ( + + + + ) : ( + + { service?.label } + + ) } + + + + +
+ { canMarkAsShared && ( + + + + + ) } +
+ +
+ { canManageConnection ? ( + + ) : ( + + { __( 'This connection is added by a site administrator.', 'jetpack-publicize-pkg' ) } + + ) } + { service?.id === 'x' && } +
+
+
+ ); +} diff --git a/projects/packages/publicize/_inc/components/connection-management/connection-name.tsx b/projects/packages/publicize/_inc/components/connection-management/connection-name.tsx index 4188ee85115f..abb6fc5268a3 100644 --- a/projects/packages/publicize/_inc/components/connection-management/connection-name.tsx +++ b/projects/packages/publicize/_inc/components/connection-management/connection-name.tsx @@ -8,16 +8,20 @@ import styles from './style.module.scss'; type ConnectionNameProps = { connection: Connection; + /** Link tone. Defaults to the WPDS link default; the modernized chassis passes "neutral". */ + tone?: 'neutral'; }; /** * Connection name component * - * @param {ConnectionNameProps} props - component props + * @param {ConnectionNameProps} props - component props + * @param {Connection} props.connection - the connection to render + * @param {string} props.tone - optional WPDS link tone * * @return {import('react').ReactNode} - React element */ -export function ConnectionName( { connection }: ConnectionNameProps ) { +export function ConnectionName( { connection, tone }: ConnectionNameProps ) { const isUpdating = useSelect( select => { return select( socialStore ).getUpdatingConnections().includes( connection.connection_id ); @@ -30,7 +34,12 @@ export function ConnectionName( { connection }: ConnectionNameProps ) { { ! connection.profile_link ? ( { connection.display_name } ) : ( - + { connection.display_name } ) } diff --git a/projects/packages/publicize/_inc/components/connection-management/connection-template/index.tsx b/projects/packages/publicize/_inc/components/connection-management/connection-template/index.tsx index 563fa4ef617b..b6773e742fde 100644 --- a/projects/packages/publicize/_inc/components/connection-management/connection-template/index.tsx +++ b/projects/packages/publicize/_inc/components/connection-management/connection-template/index.tsx @@ -1,8 +1,18 @@ -import { siteHasFeature } from '@automattic/jetpack-script-data'; +import { getRedirectUrl } from '@automattic/jetpack-components'; +import { isSimpleSite, siteHasFeature } from '@automattic/jetpack-script-data'; +import { getSiteFragment, useAnalytics } from '@automattic/jetpack-shared-extension-utils'; import { useDebounce } from '@wordpress/compose'; import { useDispatch, useSelect } from '@wordpress/data'; -import { useCallback, useEffect, useRef, useState } from '@wordpress/element'; +import { + createInterpolateElement, + useCallback, + useEffect, + useRef, + useState, +} from '@wordpress/element'; import { __ } from '@wordpress/i18n'; +import { Link } from '@wordpress/ui'; +import { useIsModernized } from '../../../hooks/use-is-modernized'; import { store as socialStore } from '../../../social-store'; import { Connection } from '../../../social-store/types'; import { features } from '../../../utils/constants'; @@ -25,28 +35,46 @@ const HELP_TEXT = __( 'jetpack-publicize-pkg' ); +const LABEL = __( 'Custom message for this connection', 'jetpack-publicize-pkg' ); + +const NOOP = () => {}; + /** * Per-connection message template editor. * - * Renders only when the site has the `social-message-templates` feature - * AND the `social-enhanced-publishing` paid plan, AND the user can manage - * the connection. Auto-saves through `updateConnectionById` after the user - * pauses typing. + * Renders the live editor when the site has the `social-message-templates` + * feature AND the `social-enhanced-publishing` paid plan, AND the user can + * manage the connection. On Jetpack sites that don't qualify, renders a + * disabled-textarea variant with an Upgrade link instead, so free-plan users + * still see what unlocks with an upgrade. * * @param {ConnectionTemplateEditorProps} props - The component's props. - * @return The rendered editor, or `null` when gated out. + * @return The rendered editor or its locked upsell variant. */ export function ConnectionTemplateEditor( props: ConnectionTemplateEditorProps ) { const { connection } = props; - const featureEnabled = siteHasFeature( features.MESSAGE_TEMPLATES ); - const planEnabled = siteHasFeature( features.ENHANCED_PUBLISHING ); - - const canManageConnection = useSelect( - select => select( socialStore ).canUserManageConnection( connection ), - [ connection ] + const isModernized = useIsModernized(); + + const { canManageConnection, globalTemplate } = useSelect( + select => ( { + canManageConnection: select( socialStore ).canUserManageConnection( connection ), + // Only the modernized chassis renders the gated upsell, which is the + // only consumer of the global default message. Keeping this read out + // of the legacy path preserves the trunk data dependencies exactly. + globalTemplate: isModernized + ? select( socialStore ).getSocialSettings().messageTemplate ?? '' + : '', + } ), + [ connection, isModernized ] ); + const { recordEvent } = useAnalytics(); + + const onUpgradeClick = useCallback( () => { + recordEvent( 'jetpack_social_per_network_customization_upgrade_click' ); + }, [ recordEvent ] ); + const savedTemplate = connection.template ?? ''; const [ draft, setDraft ] = useState( savedTemplate ); @@ -83,14 +111,60 @@ export function ConnectionTemplateEditor( props: ConnectionTemplateEditorProps ) [ debouncedSave ] ); - if ( ! featureEnabled || ! planEnabled || ! canManageConnection ) { + if ( ! canManageConnection ) { return null; } + const featureEnabled = siteHasFeature( features.MESSAGE_TEMPLATES ); + const planEnabled = siteHasFeature( features.ENHANCED_PUBLISHING ); + + if ( ! featureEnabled || ! planEnabled ) { + // The free-plan upsell ships only in the modernized chassis. The legacy + // admin page and block editor keep the trunk behavior (no editor when + // the plan/feature is missing). + if ( ! isModernized || isSimpleSite() ) { + return null; + } + + const upgradeUrl = getRedirectUrl( 'jetpack-social-per-connection-template-upsell', { + site: getSiteFragment() || '', + query: 'redirect_to=' + encodeURIComponent( window.location.href ), + } ); + + const upsellHelp = createInterpolateElement( + __( + 'Showing your default share message. To customize it for this account, upgrade your plan.', + 'jetpack-publicize-pkg' + ), + { + a: ( + + { null } + + ), + } + ); + + return ( +
+ +
+ ); + } + return (
! state, false ); const { deleteConnectionById } = useDispatch( socialStore ); @@ -94,7 +107,8 @@ export function Disconnect( { connection, variant = 'outline' }: DisconnectProps ) : ( , +} ) ); +jest.mock( '../../mark-as-shared', () => ( { + MarkAsShared: () => , +} ) ); + +const CONNECTION = { + service_name: 'tumblr', + connection_id: '5', + display_name: 'My blog', + status: 'connected', + profile_link: 'https://example.com', + profile_picture: 'https://example.com/pic.jpg', +}; + +const SERVICE = { id: 'tumblr', label: 'Tumblr' }; + +const renderInfo = ( props = {} ) => { + setup(); + // `getServicesBy` is consulted for the "unsupported" lookup but isn't part + // of the shared factory's stubbed selectors, so pin it to an empty result. + let storeSelect; + renderHook( () => useSelect( select => ( storeSelect = select( store ) ) ) ); + jest.spyOn( storeSelect, 'getServicesBy' ).mockReturnValue( [] ); + + return render( + + ); +}; + +describe( 'ModernConnectionInfo', () => { + afterEach( () => { + jest.clearAllMocks(); + } ); + + test( 'renders the connection name and the network label collapsed', () => { + renderInfo(); + + expect( screen.getByText( 'My blog' ) ).toBeInTheDocument(); + expect( screen.getByText( 'Tumblr' ) ).toBeInTheDocument(); + // The disclosure starts closed, so the trigger reports collapsed. + expect( screen.getByRole( 'button', { expanded: false } ) ).toBeInTheDocument(); + } ); + + test( 'clicking the profile name link does not toggle the disclosure row', async () => { + const user = userEvent.setup(); + renderInfo(); + + const nameLink = screen.getByRole( 'link', { name: 'My blog' } ); + await user.click( nameLink ); + + // The row stays collapsed — the link click must not bubble to the trigger. + expect( screen.getByRole( 'button', { expanded: false } ) ).toBeInTheDocument(); + } ); + + test( 'clicking the row toggles the disclosure open', async () => { + const user = userEvent.setup(); + renderInfo(); + + await user.click( screen.getByRole( 'button', { expanded: false } ) ); + + expect( screen.getByRole( 'button', { expanded: true } ) ).toBeInTheDocument(); + } ); +} ); diff --git a/projects/packages/publicize/_inc/components/connection-management/tests/specs/connection-template.test.jsx b/projects/packages/publicize/_inc/components/connection-management/tests/specs/connection-template.test.jsx index 8e4149a414ce..2476d24ecfc7 100644 --- a/projects/packages/publicize/_inc/components/connection-management/tests/specs/connection-template.test.jsx +++ b/projects/packages/publicize/_inc/components/connection-management/tests/specs/connection-template.test.jsx @@ -1,5 +1,6 @@ import { act, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { ModernizationProvider } from '../../../../hooks/use-is-modernized'; import { setup } from '../../../../utils/test-factory'; import { clearMockedScriptData, mockScriptData } from '../../../../utils/test-utils'; import { ConnectionTemplateEditor } from '../../connection-template'; @@ -17,6 +18,17 @@ const setupFeatures = ( ...active ) => { } ); }; +const setupGated = ( overrides = {} ) => { + mockScriptData( { + site: { plan: { features: { active: [] } }, ...overrides.site }, + social: overrides.social, + } ); +}; + +// Renders inside the chassis modernization context, where the gated upsell +// variant is shown. +const renderModernized = ui => render( { ui } ); + describe( 'ConnectionTemplateEditor', () => { afterEach( () => { clearMockedScriptData(); @@ -82,4 +94,54 @@ describe( 'ConnectionTemplateEditor', () => { expect( container ).toBeEmptyDOMElement(); } ); + + describe( 'modernized chassis (gated upsell)', () => { + test( 'renders the locked upsell variant when the message-templates feature is off', () => { + setupFeatures( 'social-enhanced-publishing' ); + setup(); + + renderModernized( ); + + const textarea = screen.getByRole( 'textbox', { + name: /Custom message for this connection/i, + } ); + expect( textarea ).toBeDisabled(); + expect( screen.getByRole( 'link', { name: /upgrade your plan/i } ) ).toBeInTheDocument(); + } ); + + test( 'renders the locked upsell variant when the enhanced-publishing plan is off', () => { + setupFeatures( 'social-message-templates' ); + setup(); + + renderModernized( ); + + const textarea = screen.getByRole( 'textbox', { + name: /Custom message for this connection/i, + } ); + expect( textarea ).toBeDisabled(); + expect( screen.getByRole( 'link', { name: /upgrade your plan/i } ) ).toBeInTheDocument(); + } ); + + test( 'surfaces the global default message inside the locked textarea', () => { + setupGated( { + social: { settings: { messageTemplate: 'Read my latest: {url}' } }, + } ); + setup(); + + renderModernized( ); + + expect( + screen.getByRole( 'textbox', { name: /Custom message for this connection/i } ) + ).toHaveValue( 'Read my latest: {url}' ); + } ); + + test( 'renders nothing on Simple sites when the editor is gated', () => { + setupGated( { site: { host: 'wpcom' } } ); + setup(); + + const { container } = renderModernized( ); + + expect( container ).toBeEmptyDOMElement(); + } ); + } ); } ); diff --git a/projects/packages/publicize/_inc/components/manage-connections-modal/index-modern.tsx b/projects/packages/publicize/_inc/components/manage-connections-modal/index-modern.tsx new file mode 100644 index 000000000000..9310f04cb87f --- /dev/null +++ b/projects/packages/publicize/_inc/components/manage-connections-modal/index-modern.tsx @@ -0,0 +1,101 @@ +import { getRedirectUrl } from '@automattic/jetpack-components'; +import { useViewportMatch } from '@wordpress/compose'; +import { useDispatch, useSelect } from '@wordpress/data'; +import { useCallback } from '@wordpress/element'; +import { __, _x } from '@wordpress/i18n'; +import { Dialog, Link, Text, Tooltip } from '@wordpress/ui'; +import { useUserCanShareConnection } from '../../hooks/use-user-can-share-connection'; +import { store } from '../../social-store'; +import { ModernServicesList } from '../services/services-list-modern'; +import { ConfirmationForm } from './confirmation-form'; +import styles from './style-modern.module.scss'; + +// Split the two titles into constants rather than picking them inline with a +// ternary: interpolating translatable strings inside JSX expressions can break +// the way our build extracts/bundles them for translation. +const CONFIRMATION_TITLE = () => __( 'Connection confirmation', 'jetpack-publicize-pkg' ); +const MANAGE_TITLE = () => _x( 'Manage Jetpack Social connections', '', 'jetpack-publicize-pkg' ); + +export const ModernManageConnectionsModal = () => { + const { keyringResult } = useSelect( select => { + const { getKeyringResult } = select( store ); + + return { + keyringResult: getKeyringResult(), + }; + }, [] ); + + const { setKeyringResult, closeConnectionsModal, setReconnectingAccount } = useDispatch( store ); + + const isSmall = useViewportMatch( 'small', '<' ); + + const closeModal = useCallback( () => { + setKeyringResult( null ); + setReconnectingAccount( undefined ); + closeConnectionsModal(); + }, [ closeConnectionsModal, setKeyringResult, setReconnectingAccount ] ); + + // The modal only mounts while open, so any close intent (Esc, backdrop + // click, close button) routes through here to tear down the store state. + const onOpenChange = useCallback( + ( open: boolean ) => { + if ( ! open ) { + closeModal(); + } + }, + [ closeModal ] + ); + + const hasKeyringResult = Boolean( keyringResult?.ID ); + + const title = hasKeyringResult ? CONFIRMATION_TITLE() : MANAGE_TITLE(); + + const canMarkAsShared = useUserCanShareConnection(); + + return ( + + + { /* + * `large` (960px) replaces the previous custom 65rem width; on + * small viewports `full` gives the edge-to-edge treatment the + * legacy Modal had. While listing services we pin the frame to its + * full height (`services-list`): the Dialog is vertically centered, + * so a content-sized frame would shift its contents up/down as a + * disclosure row expands — pinning it makes the row scroll inside + * the popup instead. The short confirmation view keeps its natural + * height, and `full` already fills the viewport on mobile. + */ } + + + { title } + + + { hasKeyringResult ? ( + + ) : ( + <> + + }> + { __( + 'Want to share to other networks? Use our Manual Sharing feature from the editor.', + 'jetpack-publicize-pkg' + ) } +   + + { __( 'Learn more', 'jetpack-publicize-pkg' ) } + + + + ) } + + + + ); +}; diff --git a/projects/packages/publicize/_inc/components/manage-connections-modal/index.tsx b/projects/packages/publicize/_inc/components/manage-connections-modal/index.tsx index 582524c00db8..34997bcbdd0b 100644 --- a/projects/packages/publicize/_inc/components/manage-connections-modal/index.tsx +++ b/projects/packages/publicize/_inc/components/manage-connections-modal/index.tsx @@ -10,10 +10,12 @@ import { useCallback } from '@wordpress/element'; import { __, _x } from '@wordpress/i18n'; import { Link } from '@wordpress/ui'; import clsx from 'clsx'; +import { useIsModernized } from '../../hooks/use-is-modernized'; import { useUserCanShareConnection } from '../../hooks/use-user-can-share-connection'; import { store } from '../../social-store'; import { ServicesList } from '../services/services-list'; import { ConfirmationForm } from './confirmation-form'; +import { ModernManageConnectionsModal } from './index-modern'; import styles from './style.module.scss'; export const ManageConnectionsModal = () => { @@ -100,13 +102,16 @@ export const ManageConnectionsModal = () => { * @return {import('react').ReactNode} - React element */ export function ThemedConnectionsModal() { + const isModernized = useIsModernized(); const shouldModalBeOpen = useSelect( select => { return select( store ).isConnectionsModalOpen(); }, [] ); + const Connections = isModernized ? ModernManageConnectionsModal : ManageConnectionsModal; + return ( - { shouldModalBeOpen ? : null } + { shouldModalBeOpen ? : null } ); } diff --git a/projects/packages/publicize/_inc/components/manage-connections-modal/style-modern.module.scss b/projects/packages/publicize/_inc/components/manage-connections-modal/style-modern.module.scss new file mode 100644 index 000000000000..48b9c027d4e4 --- /dev/null +++ b/projects/packages/publicize/_inc/components/manage-connections-modal/style-modern.module.scss @@ -0,0 +1,30 @@ +// While listing services we pin the Dialog frame so expanding a disclosure row +// scrolls inside the popup instead of growing the (otherwise vertically +// centered) frame and shifting its contents. + +// We pin with explicit top/bottom insets rather than the Dialog's default +// `top: 50%` + `translateY(-50%)` centering, for two reasons: +// 1. The Dialog is built for the block editor and is unaware of wp-admin's +// fixed `#wpadminbar`; centering a near-full-height frame tucks its top +// under the bar. `--wp-admin--admin-bar--height` (kept in sync by +// WordPress — 32px desktop, 46px ≤782px, 0 when absent) plus the Dialog's +// usual 24px gutter gives the top inset. +// 2. Centering a tall frame lands it on a half-pixel (odd viewport height), +// which misaligns the native scrollbar by ~1px. Integer insets avoid that. +// `translateX(-50%)` is kept so the frame stays horizontally centered against +// the base `left: 50%`. +.services-list { + top: calc(var(--wp-admin--admin-bar--height, 0px) + var(--wpds-dimension-padding-2xl, 24px)); + bottom: var(--wpds-dimension-padding-2xl, 24px); + height: auto; + max-height: none; + transform: translateX(-50%); + + // Reserve the scrollbar gutter on both sides so the rows don't shift when a + // disclosure (e.g. the Instagram preview) makes the frame overflow + scroll. + scrollbar-gutter: stable both-edges; +} + +.manual-share { + padding-block: 2rem; +} diff --git a/projects/packages/publicize/_inc/components/manage-connections-modal/tests/index-modern.test.js b/projects/packages/publicize/_inc/components/manage-connections-modal/tests/index-modern.test.js new file mode 100644 index 000000000000..965beed34688 --- /dev/null +++ b/projects/packages/publicize/_inc/components/manage-connections-modal/tests/index-modern.test.js @@ -0,0 +1,77 @@ +import { render, screen, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { setup } from '../../../utils/test-factory'; +import { ModernManageConnectionsModal } from '../index-modern'; + +jest.mock( '../confirmation-form', () => ( { + ConfirmationForm: () =>
Confirmation Form
, +} ) ); +jest.mock( '../../services/services-list-modern', () => ( { + ModernServicesList: () =>
Services List
, +} ) ); +jest.mock( '../../../hooks/use-user-can-share-connection', () => ( { + useUserCanShareConnection: jest.fn( () => true ), +} ) ); + +describe( 'ModernManageConnectionsModal', () => { + let stubSetKeyringResult, stubGetKeyringResult; + + beforeEach( () => { + jest.clearAllMocks(); + ( { stubSetKeyringResult, stubGetKeyringResult } = setup() ); + jest.useFakeTimers(); + } ); + + afterEach( () => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + } ); + + // The Dialog/Tooltip primitives schedule async positioning effects after + // mount, so flush them inside act before asserting. + const renderModal = async () => { + render( ); + // Settle the floating-ui/tooltip effects (some resolve via microtasks, + // some via timers) so no state update escapes act. + await act( async () => { + await Promise.resolve(); + jest.runAllTimers(); + } ); + await act( async () => { + jest.runAllTimers(); + } ); + }; + + it( 'renders the services list in a dialog when there is no keyringResult', async () => { + await renderModal(); + + expect( screen.queryByText( 'Confirmation Form' ) ).not.toBeInTheDocument(); + expect( screen.getByText( 'Services List' ) ).toBeInTheDocument(); + expect( screen.getByText( 'Manage Jetpack Social connections' ) ).toBeInTheDocument(); + expect( screen.getByRole( 'dialog' ) ).toBeInTheDocument(); + } ); + + it( 'renders the ConfirmationForm when there is a keyringResult', async () => { + stubGetKeyringResult.mockReturnValue( { ID: 'facebook' } ); + + await renderModal(); + + expect( screen.getByText( 'Confirmation Form' ) ).toBeInTheDocument(); + expect( screen.getByText( 'Connection confirmation' ) ).toBeInTheDocument(); + } ); + + it( 'resets the keyring result when the dialog is closed', async () => { + const user = userEvent.setup( { advanceTimers: jest.advanceTimersByTime } ); + + await renderModal(); + + await user.click( screen.getByRole( 'button', { name: /close/i } ) ); + + // Dialog has a close animation. Wait for it to finish. + await act( () => { + jest.runAllTimers(); + } ); + + expect( stubSetKeyringResult ).toHaveBeenCalledWith( null ); + } ); +} ); diff --git a/projects/packages/publicize/_inc/components/message-template-editor/index.tsx b/projects/packages/publicize/_inc/components/message-template-editor/index.tsx index b399b81084ec..04e23d370b6d 100644 --- a/projects/packages/publicize/_inc/components/message-template-editor/index.tsx +++ b/projects/packages/publicize/_inc/components/message-template-editor/index.tsx @@ -20,6 +20,8 @@ export type MessageTemplateEditorProps = { disabled?: boolean; /** Number of textarea rows. Defaults to 4. */ rows?: number; + /** Whether to render the placeholders dropdown. Defaults to true. */ + showPlaceholders?: boolean; }; const getDefaultPlaceholder = () => @@ -57,7 +59,16 @@ const getDefaultHelpText = () => * @return Element. */ export function MessageTemplateEditor( props: MessageTemplateEditorProps ) { - const { value, onChange, label, placeholder, helpText, disabled, rows = 4 } = props; + const { + value, + onChange, + label, + placeholder, + helpText, + disabled, + rows = 4, + showPlaceholders = true, + } = props; const resolvedLabel = label ?? __( 'Message template', 'jetpack-publicize-pkg' ); const resolvedPlaceholder = placeholder ?? getDefaultPlaceholder(); const resolvedHelpText = helpText ?? getDefaultHelpText(); @@ -74,7 +85,7 @@ export function MessageTemplateEditor( props: MessageTemplateEditorProps ) { help={ resolvedHelpText } __nextHasNoMarginBottom={ true } /> - + { showPlaceholders && }
); } diff --git a/projects/packages/publicize/_inc/components/overview-tab/index.tsx b/projects/packages/publicize/_inc/components/overview-tab/index.tsx index cce24d2e7919..4251757c8e50 100644 --- a/projects/packages/publicize/_inc/components/overview-tab/index.tsx +++ b/projects/packages/publicize/_inc/components/overview-tab/index.tsx @@ -82,13 +82,21 @@ export default function OverviewTab(): JSX.Element { { ! hasConnections && } { hasConnections && ( - + { __( 'Connected accounts', 'jetpack-publicize-pkg' ) } ) } - + { hasConnections ? ( - + ) : ( ) } diff --git a/projects/packages/publicize/_inc/components/overview-tab/style.scss b/projects/packages/publicize/_inc/components/overview-tab/style.scss index 24d809ca8fa1..637bd1fb5b2e 100644 --- a/projects/packages/publicize/_inc/components/overview-tab/style.scss +++ b/projects/packages/publicize/_inc/components/overview-tab/style.scss @@ -32,3 +32,28 @@ .jetpack-social-jitm-card:empty { display: none; } + +// Tighten just the Card.Header's bottom padding so the divider sits +// visually equidistant from the title text and the first row's content. +// The 20px lands the divider symmetrically once the title's line-height +// padding is accounted for — pure 16px reads as tighter above than below. +// The top padding stays at Card.Header's default 24px so the title still +// has comfortable headroom from the card edge. +.jetpack-social-overview__accounts-card-header { + padding-block-end: var(--wpds-dimension-padding-xl, 20px); +} + +// Strip the Card.Content padding when the body is the flat connections +// list — the list rows own their own paddings and need to extend edge to +// edge of the outer Card, with thin separators between them. +.jetpack-social-overview__accounts-card-content { + padding: 0; +} + +// ConnectionManagement's own `.wrapper` ships a `padding-top: 16px` that +// makes sense for the standalone admin context but is double-padding +// inside the chassis Card. Zero it here (doubled-class selector for a +// safe specificity bump over the package-internal `.wrapper` rule). +.jetpack-social-overview__connections-wrapper.jetpack-social-overview__connections-wrapper { + padding-top: 0; +} diff --git a/projects/packages/publicize/_inc/components/services/connect-form.tsx b/projects/packages/publicize/_inc/components/services/connect-form.tsx index 94600acded6a..c7831681c9e6 100644 --- a/projects/packages/publicize/_inc/components/services/connect-form.tsx +++ b/projects/packages/publicize/_inc/components/services/connect-form.tsx @@ -3,9 +3,11 @@ import { useCallback, useState } from '@wordpress/element'; import { __, _x } from '@wordpress/i18n'; import { Button } from '@wordpress/ui'; import clsx from 'clsx'; +import { useIsModernized } from '../../hooks/use-is-modernized'; import { store } from '../../social-store'; import { KeyringResult } from '../../social-store/types'; import { CustomInputs } from './custom-inputs'; +import { ModernCustomInputs } from './custom-inputs-modern'; import styles from './style.module.scss'; import { SupportedService } from './types'; import { useRequestAccess } from './use-request-access'; @@ -18,6 +20,8 @@ type ConnectFormProps = { displayInputs?: boolean; hasConnections?: boolean; buttonLabel?: string; + /** When true, the modernized chassis sizes the submit button to sit flush in a disclosure row. */ + compact?: boolean; }; /** @@ -34,9 +38,19 @@ export function ConnectForm( { displayInputs, hasConnections, buttonLabel, + compact, }: ConnectFormProps ) { + const isModernized = useIsModernized(); const { setKeyringResult } = useDispatch( store ); + // In the modernized chassis the submit button sits flush in a compact + // disclosure row unless it accompanies the custom-input fields. Legacy + // passes no `size` (undefined) to keep the trunk button sizing. + let buttonSize: 'default' | 'compact' | undefined; + if ( compact ) { + buttonSize = displayInputs ? 'default' : 'compact'; + } + const { isConnectionsModalOpen } = useSelect( select => select( store ), [] ); const [ isConnecting, setIsConnecting ] = useState( false ); @@ -87,13 +101,18 @@ export function ConnectForm( { > { displayInputs ? (
- + { isModernized ? ( + + ) : ( + + ) }
) : null }
, +} ) ); +jest.mock( '../../connection-management/mark-as-shared', () => ( { + MarkAsShared: () => , +} ) ); + +describe( 'ModernServiceConnectionInfo', () => { + const connection = { + profile_picture: 'https://example.com/profile.jpg', + display_name: 'Example User', + status: 'connected', + }; + + const renderComponent = ( connOverrides = {}, serviceOverrides = {}, props = {} ) => { + setup( { canUserManageConnection: props.canUserManageConnection ?? true } ); + render( + + ); + }; + + afterEach( () => { + jest.clearAllMocks(); + } ); + + test( 'renders the profile picture and connection name', () => { + renderComponent(); + + const profilePic = screen.getByAltText( 'Example User' ); + expect( profilePic ).toHaveAttribute( 'src', 'https://example.com/profile.jpg' ); + expect( screen.getByText( 'Example User' ) ).toBeInTheDocument(); + expect( screen.getByText( 'Disconnect Example User' ) ).toBeInTheDocument(); + } ); + + test( 'renders the mark-as-shared toggle with the info IconButton', () => { + renderComponent( {}, {}, { canMarkAsShared: true } ); + + expect( screen.getByText( 'Mark as Shared' ) ).toBeInTheDocument(); + // The IconButton exposes its help text as an accessible name. + expect( + screen.getByRole( 'button', { name: /available to all administrators/i } ) + ).toBeInTheDocument(); + } ); + + test( 'renders the connection status when broken and manageable', () => { + renderComponent( { status: 'broken' } ); + + expect( screen.getByText( 'Status: broken' ) ).toBeInTheDocument(); + } ); + + test( 'tells non-admins the connection was added by a site administrator', () => { + renderComponent( {}, {}, { canUserManageConnection: false } ); + + expect( + screen.getByText( 'This connection is added by a site administrator.' ) + ).toBeInTheDocument(); + } ); +} ); diff --git a/projects/packages/publicize/_inc/components/services/tests/service-item-modern.test.js b/projects/packages/publicize/_inc/components/services/tests/service-item-modern.test.js new file mode 100644 index 000000000000..2112b8433a3b --- /dev/null +++ b/projects/packages/publicize/_inc/components/services/tests/service-item-modern.test.js @@ -0,0 +1,63 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { setup } from '../../../utils/test-factory'; +import { ModernServiceItem } from '../service-item-modern'; + +jest.mock( '../connect-form', () => ( { + ConnectForm: () =>
Connect Form
, +} ) ); +jest.mock( '../service-item-details', () => ( { + ServiceItemDetails: () =>
Service Details
, +} ) ); +jest.mock( '../service-item-notice', () => ( { + ServiceItemNotice: () =>
Service Notice
, +} ) ); +jest.mock( '../service-status', () => ( { + ServiceStatus: () =>
Service Status
, +} ) ); + +const SERVICE = { + id: 'mastodon', + label: 'Mastodon', + description: 'Share with your network.', + needsCustomInputs: true, + icon: () => , +}; + +describe( 'ModernServiceItem', () => { + beforeEach( () => { + setup(); + // jsdom doesn't implement scrollIntoView, which the auto-open path calls. + // eslint-disable-next-line jest/prefer-spy-on + Element.prototype.scrollIntoView = jest.fn(); + } ); + + afterEach( () => { + jest.clearAllMocks(); + } ); + + test( 'renders the service heading collapsed by default', () => { + render( ); + + expect( screen.getByText( 'Mastodon' ) ).toBeInTheDocument(); + expect( screen.getByRole( 'button', { expanded: false } ) ).toBeInTheDocument(); + } ); + + test( 'expands the panel to reveal the service details when the row is clicked', async () => { + const user = userEvent.setup(); + render( ); + + await user.click( screen.getByRole( 'button', { expanded: false } ) ); + + expect( screen.getByRole( 'button', { expanded: true } ) ).toBeInTheDocument(); + expect( screen.getByText( 'Service Details' ) ).toBeInTheDocument(); + } ); + + test( 'opens with the panel already expanded when isPanelDefaultOpen is set', () => { + render( + + ); + + expect( screen.getByRole( 'button', { expanded: true } ) ).toBeInTheDocument(); + } ); +} ); diff --git a/projects/packages/publicize/_inc/components/services/tests/services-list-modern.test.js b/projects/packages/publicize/_inc/components/services/tests/services-list-modern.test.js new file mode 100644 index 000000000000..db1312236709 --- /dev/null +++ b/projects/packages/publicize/_inc/components/services/tests/services-list-modern.test.js @@ -0,0 +1,34 @@ +import { render, renderHook, screen } from '@testing-library/react'; +import { useSelect } from '@wordpress/data'; +import { store } from '../../../social-store'; +import { setup } from '../../../utils/test-factory'; +import { ModernServicesList } from '../services-list-modern'; + +// Keep the list shallow — each row is covered by service-item-modern's own test. +jest.mock( '../service-item-modern', () => ( { + ModernServiceItem: ( { service } ) =>
Row: { service.label }
, +} ) ); + +const prepareStore = () => { + setup(); + let storeSelect; + renderHook( () => useSelect( select => ( storeSelect = select( store ) ) ) ); + jest.spyOn( storeSelect, 'getReconnectingAccount' ).mockReturnValue( undefined ); +}; + +describe( 'ModernServicesList', () => { + afterEach( () => { + jest.clearAllMocks(); + } ); + + test( 'renders a row for each supported service', () => { + prepareStore(); + render( ); + + // The shared factory seeds Facebook, LinkedIn and Instagram Business. + expect( screen.getByText( 'Row: Facebook' ) ).toBeInTheDocument(); + expect( screen.getByText( 'Row: LinkedIn' ) ).toBeInTheDocument(); + expect( screen.getByText( 'Row: Instagram Business' ) ).toBeInTheDocument(); + expect( screen.getAllByRole( 'listitem' ) ).toHaveLength( 3 ); + } ); +} ); diff --git a/projects/packages/publicize/_inc/components/social-page.tsx b/projects/packages/publicize/_inc/components/social-page.tsx index bb55a8eed396..86a300fe3f0f 100644 --- a/projects/packages/publicize/_inc/components/social-page.tsx +++ b/projects/packages/publicize/_inc/components/social-page.tsx @@ -3,7 +3,8 @@ import { getSiteData } from '@automattic/jetpack-script-data'; import { useCallback } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { useNavigate } from '@wordpress/route'; -import { Tabs } from '@wordpress/ui'; +import { Tabs, Tooltip } from '@wordpress/ui'; +import { ModernizationProvider } from '../hooks/use-is-modernized'; // Define the `--color-facebook`, `--color-twitter`, ... custom properties // that `SocialServiceIcon` (and friends) consume to paint per-service // brand colours. The legacy `social-admin-page` webpack bundle inlines @@ -67,24 +68,32 @@ export default function SocialPage( { activeTab, actions, children }: Props ): J ); return ( - - -
- - { __( 'Overview', 'jetpack-publicize-pkg' ) } - { __( 'Settings', 'jetpack-publicize-pkg' ) } - -
-
- { children } -
-
-
+ + + + +
+ + { __( 'Overview', 'jetpack-publicize-pkg' ) } + { __( 'Settings', 'jetpack-publicize-pkg' ) } + +
+
+ { children } +
+
+
+
+
); } diff --git a/projects/packages/publicize/_inc/hooks/use-is-modernized/index.tsx b/projects/packages/publicize/_inc/hooks/use-is-modernized/index.tsx new file mode 100644 index 000000000000..512783740418 --- /dev/null +++ b/projects/packages/publicize/_inc/hooks/use-is-modernized/index.tsx @@ -0,0 +1,28 @@ +import { createContext, useContext } from '@wordpress/element'; +import type { ReactNode } from 'react'; + +const ModernizationContext = createContext( false ); + +/** + * Marks its subtree as the modernized (chassis) Social UI so shared + * connection/modal components render their WPDS variants. The legacy admin + * page and the block editor mount these components without this provider, so + * they keep rendering the original UI. + * + * @param props - Props. + * @param props.children - Subtree rendered with the modernized components. + * @return The provider element. + */ +export function ModernizationProvider( { children }: { children: ReactNode } ) { + return { children }; +} + +/** + * Whether the current subtree is the modernized (chassis) Social UI. Defaults + * to false outside a ModernizationProvider (legacy admin page / block editor). + * + * @return True when rendered inside a ModernizationProvider. + */ +export function useIsModernized(): boolean { + return useContext( ModernizationContext ); +} diff --git a/projects/packages/publicize/_inc/hooks/use-is-modernized/test/index.test.tsx b/projects/packages/publicize/_inc/hooks/use-is-modernized/test/index.test.tsx new file mode 100644 index 000000000000..255671b90226 --- /dev/null +++ b/projects/packages/publicize/_inc/hooks/use-is-modernized/test/index.test.tsx @@ -0,0 +1,27 @@ +import { render, screen } from '@testing-library/react'; +import { ModernizationProvider, useIsModernized } from '..'; + +/** + * Renders the resolved value of `useIsModernized()` as text for assertions. + * + * @return A span containing "true" or "false". + */ +function Probe() { + return { useIsModernized() ? 'true' : 'false' }; +} + +describe( 'useIsModernized', () => { + test( 'defaults to false with no provider (legacy / block editor)', () => { + render( ); + expect( screen.getByText( 'false' ) ).toBeInTheDocument(); + } ); + + test( 'is true inside a ModernizationProvider (chassis)', () => { + render( + + + + ); + expect( screen.getByText( 'true' ) ).toBeInTheDocument(); + } ); +} ); diff --git a/projects/packages/publicize/changelog/update-social-modernization-modal-wpds b/projects/packages/publicize/changelog/update-social-modernization-modal-wpds new file mode 100644 index 000000000000..ada4faa19276 --- /dev/null +++ b/projects/packages/publicize/changelog/update-social-modernization-modal-wpds @@ -0,0 +1,3 @@ +Significance: patch +Type: changed +Comment: Social modernization (flag-gated behind rsm_jetpack_ui_modernization_social): refresh the connected-accounts list and Add-account modal onto the WordPress Design System. No user-facing change until the flag flips. diff --git a/projects/plugins/jetpack/changelog/update-social-modernization-modal-wpds b/projects/plugins/jetpack/changelog/update-social-modernization-modal-wpds new file mode 100644 index 000000000000..07ec70267773 --- /dev/null +++ b/projects/plugins/jetpack/changelog/update-social-modernization-modal-wpds @@ -0,0 +1,3 @@ +Significance: patch +Type: other +Comment: Dependencies: Update lock file to keep root requirements in sync. diff --git a/projects/plugins/social/changelog/update-social-modernization-modal-wpds b/projects/plugins/social/changelog/update-social-modernization-modal-wpds new file mode 100644 index 000000000000..4103429785d1 --- /dev/null +++ b/projects/plugins/social/changelog/update-social-modernization-modal-wpds @@ -0,0 +1,3 @@ +Significance: patch +Type: changed +Comment: Social modernization (flag-gated behind rsm_jetpack_ui_modernization_social): pulls in the publicize package's WPDS refresh of the connected-accounts list and Add-account modal. No user-facing change until the flag flips.