+ );
+ }
+
+ // Legacy paste-code block — render as-is without the new UI.
+ if ( ! isApiManaged && ( scriptSrc || hostedButtonId ) ) {
+ return (
+
+ );
+ }
+
+ // Not connected — show the guided connection wizard. A block that already
+ // holds a saved button keeps showing its preview instead (e.g. demo posts in
+ // Playground, or a button created before the site was disconnected), unless
+ // the merchant explicitly asked to reconnect.
+ if ( ! isConnected && ( ! hasButton || showReconnect ) ) {
+ return (
+
+
+
+ );
+ }
+
+ // Toolbar controls for edit/preview toggle (only when button exists).
+ const toolbarControls = hasButton ? (
+
+
+ setIsEditing( false ) }
+ />
+ setIsEditing( true ) }
+ />
+
+
+
+
+
+ ) : null;
+
+ // Inspector sidebar — format switcher, Style preset, and connection info.
+ const inspectorControls = (
+
+ );
+
+ // Shared confirmation dialogs — extracted so they render regardless of which return branch is active.
+ const confirmDialogs = (
+
+ );
+
+ const formatLabel = FORMAT_OPTIONS.find( o => o.value === activeFormat )?.label || activeFormat;
+
+ // The PayPal connection is site-wide, so a block can still hold a working
+ // button after the account was disconnected — from this post, another post,
+ // or the admin. The button keeps paying out; only editing it needs the
+ // connection back, so say so instead of failing on save.
+ const disconnectedNotice = ! isConnected ? (
+ setShowReconnect( true ),
+ variant: 'primary',
+ },
+ ] }
+ >
+ { __(
+ 'Your PayPal account is disconnected. This payment link still works for buyers, but you need to reconnect before you can edit or delete it.',
+ 'jetpack-paypal-payments'
+ ) }
+
+ ) : null;
+
+ const sharedResourceMessage = sprintf(
+ /* translators: %d: number of other blocks on this page using the same PayPal payment */
+ _n(
+ '%d other block on this page uses this PayPal payment. Changing the product or price here changes it there too. To sell something different, add a new block and create a new payment.',
+ '%d other blocks on this page use this PayPal payment. Changing the product or price here changes it there too. To sell something different, add a new block and create a new payment.',
+ sharedResourceCount,
+ 'jetpack-paypal-payments'
+ ),
+ sharedResourceCount
+ );
+ const sharedResourceNotice =
+ sharedResourceCount > 0 ? (
+
+ { sharedResourceMessage }
+
+ ) : null;
+
+ const connectionStatus = (
+
+ );
+
+ const connectionLabel = isConnected ? labelConnected : labelDisconnected;
+
+ // Connected + has button + preview mode — show live button preview.
+ if ( hasButton && ! isEditing ) {
+ return (
+
- );
- }
-
- // Legacy paste-code block — render as-is without the new UI.
- if ( ! isApiManaged && ( scriptSrc || hostedButtonId ) ) {
- return (
-
- );
- }
-
- // Not connected — show the guided connection wizard. A block that already
- // holds a saved button keeps showing its preview instead (e.g. demo posts in
- // Playground, or a button created before the site was disconnected), unless
- // the merchant explicitly asked to reconnect.
- if ( ! isConnected && ( ! hasButton || showReconnect ) ) {
- return (
-
-
-
- );
- }
-
- // Toolbar controls for edit/preview toggle (only when button exists).
- const toolbarControls = hasButton ? (
-
-
- setIsEditing( false ) }
- />
- setIsEditing( true ) }
- />
-
-
-
-
-
- ) : null;
-
- // Inspector sidebar — format switcher, Style preset, and connection info.
- const inspectorControls = (
-
- );
-
- // Shared confirmation dialogs — extracted so they render regardless of which return branch is active.
- const confirmDialogs = (
-
- );
-
- const formatLabel = FORMAT_OPTIONS.find( o => o.value === activeFormat )?.label || activeFormat;
-
- // The PayPal connection is site-wide, so a block can still hold a working
- // button after the account was disconnected — from this post, another post,
- // or the admin. The button keeps paying out; only editing it needs the
- // connection back, so say so instead of failing on save.
- const disconnectedNotice = ! isConnected ? (
- setShowReconnect( true ),
- variant: 'primary',
- },
- ] }
- >
- { __(
- 'Your PayPal account is disconnected. This payment link still works for buyers, but you need to reconnect before you can edit or delete it.',
- 'jetpack-paypal-payments'
- ) }
-
- ) : null;
-
- const sharedResourceMessage = sprintf(
- /* translators: %d: number of other blocks on this page using the same PayPal payment */
- _n(
- '%d other block on this page uses this PayPal payment. Changing the product or price here changes it there too. To sell something different, add a new block and create a new payment.',
- '%d other blocks on this page use this PayPal payment. Changing the product or price here changes it there too. To sell something different, add a new block and create a new payment.',
- sharedResourceCount,
- 'jetpack-paypal-payments'
- ),
- sharedResourceCount
- );
- const sharedResourceNotice =
- sharedResourceCount > 0 ? (
+ return (
+
- { sharedResourceMessage }
+ { __(
+ 'This button is managed through your PayPal account and cannot be edited right now.',
+ 'jetpack-paypal-payments'
+ ) }
- ) : null;
-
- const connectionStatus = (
-
+
+
+
+
);
+}
- const connectionLabel = isConnected ? labelConnected : labelDisconnected;
-
- // Connected + has button + preview mode — show live button preview.
- if ( hasButton && ! isEditing ) {
- return (
-
+ );
+ },
+ __experimentalToggleGroupControlOption: () => null, // We're not using the actual implementation
+ __experimentalItemGroup: ( { children } ) =>
{ children }
,
+ __experimentalItem: ( { children } ) =>
{ children }
,
+ SVG: props => ,
+ Path: props => ,
+} ) );
+
+// Mock @wordpress/ui
+jest.mock( '@wordpress/ui', () => ( {
+ Link: ( { href, children } ) => (
+
+ { children }
+
+ ),
+} ) );
+
+// Mock i18n
+jest.mock( '@wordpress/i18n', () => ( {
+ __: text => text,
+ _x: text => text,
+} ) );
+
+// Mock element
+jest.mock( '@wordpress/element', () => {
+ const React = require( 'react' );
+ return {
+ createElement: React.createElement,
+ useState: jest.fn().mockImplementation( initialValue => {
+ const [ state, setState ] = React.useState( initialValue );
+ return [ state, setState ];
+ } ),
+ useEffect: jest.fn().mockImplementation( ( callback, deps ) => {
+ React.useEffect( () => callback(), deps ); // eslint-disable-line react-hooks/exhaustive-deps
+ } ),
+ createInterpolateElement: ( text, elements ) => {
+ // Simple mock implementation for createInterpolateElement
+ // Replace the text with actual React elements
+ let result = text;
+
+ // Replace SignupLink and LoginLink with actual Link components
+ if ( elements.SignupLink ) {
+ result = React.createElement(
+ React.Fragment,
+ null,
+ '1. ',
+ React.cloneElement(
+ elements.SignupLink,
+ { 'data-testid': 'link' },
+ React.createElement( 'strong', null, 'Sign up' )
+ ),
+ ' or ',
+ React.cloneElement(
+ elements.LoginLink,
+ { 'data-testid': 'link' },
+ React.createElement( 'strong', null, 'log in' )
+ ),
+ ' to PayPal to get your Payment Button code.'
+ );
+ }
+
+ return result;
+ },
+ };
+} );
+
+describe( 'Edit', () => {
+ const defaultProps = {
+ attributes: {
+ buttonType: 'stacked',
+ scriptSrc: '',
+ hostedButtonId: '',
+ buttonText: '',
+ },
+ setAttributes: jest.fn(),
+ isSelected: true,
+ };
+
+ beforeEach( () => {
+ jest.clearAllMocks();
+ } );
+
+ it( 'renders without crashing', () => {
+ render( );
+ expect( screen.getByTestId( 'placeholder' ) ).toBeInTheDocument();
+ } );
+
+ it( 'displays the button type toggle control', () => {
+ render( );
+ expect( screen.getByTestId( 'toggle-group' ) ).toBeInTheDocument();
+ expect( screen.getByTestId( 'toggle-option-stacked' ) ).toBeInTheDocument();
+ expect( screen.getByTestId( 'toggle-option-single' ) ).toBeInTheDocument();
+ } );
+
+ it( 'shows head code input only when stacked button type is selected', () => {
+ const { rerender } = render( );
+
+ // With stacked button type, should have 2 PlainText inputs (head and body)
+ const inputs = screen.getAllByTestId( 'plain-text' );
+ expect( inputs ).toHaveLength( 2 );
+
+ // Rerender with single button type
+ rerender(
+
+ );
+
+ // With single button type, should have only 1 PlainText input (body)
+ const singleInputs = screen.getAllByTestId( 'plain-text' );
+ expect( singleInputs ).toHaveLength( 1 );
+ } );
+
+ it( 'updates buttonType when toggle is clicked', () => {
+ const setAttributes = jest.fn();
+ render(
+
+ );
+
+ fireEvent.click( screen.getByTestId( 'toggle-option-single' ) ); // eslint-disable-line testing-library/prefer-user-event
+ expect( setAttributes ).toHaveBeenCalledWith( {
+ buttonType: 'single',
+ scriptSrc: '',
+ buttonText: '',
+ hostedButtonId: '',
+ } );
+ } );
+
+ it( 'updates scriptSrc when head code is entered', () => {
+ const setAttributes = jest.fn();
+ render(
+
+ );
+
+ const inputs = screen.getAllByTestId( 'plain-text' );
+ // First input should be the head code for stacked buttons
+ // eslint-disable-next-line testing-library/prefer-user-event
+ fireEvent.change( inputs[ 0 ], {
+ target: { value: '' },
+ } );
+
+ expect( setAttributes ).toHaveBeenCalledWith( {
+ scriptSrc: 'https://www.paypal.com/sdk/js?client-id=test',
+ } );
+ } );
+
+ it( 'updates hostedButtonId when body code is entered', () => {
+ const setAttributes = jest.fn();
+ render(
+
+ );
+
+ const inputs = screen.getAllByTestId( 'plain-text' );
+ // For stacked buttons, body code is the second input
+ // eslint-disable-next-line testing-library/prefer-user-event
+ fireEvent.change( inputs[ 1 ], {
+ target: {
+ value:
+ '(window.paypal_payment_buttons || window.paypal).HostedButtons({ hostedButtonId: "ABC123DEF", }).render("#paypal-container-ABC123DEF")',
+ },
+ } );
+
+ expect( setAttributes ).toHaveBeenCalledWith( {
+ hostedButtonId: 'ABC123DEF',
+ buttonText: '',
+ } );
+ } );
+
+ it( 'extracts payment ID and button text from single button code', () => {
+ const setAttributes = jest.fn();
+ render(
+
+ );
+
+ const inputs = screen.getAllByTestId( 'plain-text' );
+ // For single buttons, there's only one input (no head code)
+ // eslint-disable-next-line testing-library/prefer-user-event
+ fireEvent.change( inputs[ 0 ], {
+ target: {
+ value:
+ '',
+ },
+ } );
+
+ expect( setAttributes ).toHaveBeenCalledWith( {
+ hostedButtonId: '9J2U2LUWM4SUY',
+ buttonText: 'Pay Now',
+ } );
+ } );
+
+ describe( 'PayPal Code Snippet Parsing', () => {
+ it( 'parses original PayPal single button snippet correctly', () => {
+ const setAttributes = jest.fn();
+ render(
+
+ );
+
+ const originalSnippet = ` `;
+
+ const inputs = screen.getAllByTestId( 'plain-text' );
+ // eslint-disable-next-line testing-library/prefer-user-event
+ fireEvent.change( inputs[ 0 ], {
+ target: { value: originalSnippet },
+ } );
+
+ expect( setAttributes ).toHaveBeenCalledWith( {
+ hostedButtonId: 'HLDQA6NDL5TLG',
+ buttonText: 'Buy Now',
+ } );
+ } );
+
+ it( 'parses PayPal snippet with query parameters and div wrapper', () => {
+ const setAttributes = jest.fn();
+ render(
+
+ );
+
+ const snippetWithQueryParams = ``;
+
+ const inputs = screen.getAllByTestId( 'plain-text' );
+ // eslint-disable-next-line testing-library/prefer-user-event
+ fireEvent.change( inputs[ 0 ], {
+ target: { value: snippetWithQueryParams },
+ } );
+
+ expect( setAttributes ).toHaveBeenCalledWith( {
+ hostedButtonId: 'HLDQA6NDL5TLG',
+ buttonText: 'Buy Now',
+ } );
+ } );
+
+ it( 'parses non-self-terminating input tags correctly', () => {
+ const setAttributes = jest.fn();
+ render(
+
+ );
+
+ const snippetNonSelfTerminating = ``;
+
+ const inputs = screen.getAllByTestId( 'plain-text' );
+ // eslint-disable-next-line testing-library/prefer-user-event
+ fireEvent.change( inputs[ 0 ], {
+ target: { value: snippetNonSelfTerminating },
+ } );
+
+ expect( setAttributes ).toHaveBeenCalledWith( {
+ hostedButtonId: 'ABC123DEF',
+ buttonText: 'Purchase Item',
+ } );
+ } );
+
+ it( 'handles URL with multiple query parameters', () => {
+ const setAttributes = jest.fn();
+ render(
+
+ );
+
+ const snippetMultipleParams = ``;
+
+ const inputs = screen.getAllByTestId( 'plain-text' );
+ // eslint-disable-next-line testing-library/prefer-user-event
+ fireEvent.change( inputs[ 0 ], {
+ target: { value: snippetMultipleParams },
+ } );
+
+ expect( setAttributes ).toHaveBeenCalledWith( {
+ hostedButtonId: 'XYZ789GHI',
+ buttonText: 'Subscribe',
+ } );
+ } );
+
+ it( 'handles multi-line input with various formatting', () => {
+ const setAttributes = jest.fn();
+ render(
+
+ );
+
+ const multiLineSnippet = `
+ `;
+
+ const inputs = screen.getAllByTestId( 'plain-text' );
+ // eslint-disable-next-line testing-library/prefer-user-event
+ fireEvent.change( inputs[ 0 ], {
+ target: { value: multiLineSnippet },
+ } );
+
+ expect( setAttributes ).toHaveBeenCalledWith( {
+ hostedButtonId: 'MULTILINE123',
+ buttonText: 'Multi Line Button',
+ } );
+ } );
+ } );
+
+ describe( 'Edge Cases Handling', () => {
+ it( 'handles lowercase button IDs', () => {
+ const setAttributes = jest.fn();
+ render(
+
+ );
+
+ const snippet = ``;
+
+ const inputs = screen.getAllByTestId( 'plain-text' );
+ // eslint-disable-next-line testing-library/prefer-user-event
+ fireEvent.change( inputs[ 0 ], {
+ target: { value: snippet },
+ } );
+
+ expect( setAttributes ).toHaveBeenCalledWith( {
+ hostedButtonId: 'abc123def',
+ buttonText: 'Buy',
+ } );
+ } );
+
+ it( 'handles button IDs with hyphens and underscores', () => {
+ const setAttributes = jest.fn();
+ render(
+
+ );
+
+ const snippet = `
+ `;
+
+ const inputs = screen.getAllByTestId( 'plain-text' );
+ // eslint-disable-next-line testing-library/prefer-user-event
+ fireEvent.change( inputs[ 0 ], {
+ target: { value: snippet },
+ } );
+
+ expect( setAttributes ).toHaveBeenCalledWith( {
+ hostedButtonId: 'ABC-123_DEF',
+ buttonText: 'Purchase',
+ } );
+ } );
+
+ it( 'handles international PayPal domains', () => {
+ const setAttributes = jest.fn();
+ render(
+
+ );
+
+ const snippetUK = ``;
+
+ const inputs = screen.getAllByTestId( 'plain-text' );
+ // eslint-disable-next-line testing-library/prefer-user-event
+ fireEvent.change( inputs[ 0 ], {
+ target: { value: snippetUK },
+ } );
+
+ expect( setAttributes ).toHaveBeenCalledWith( {
+ hostedButtonId: 'UK123ABC',
+ buttonText: 'Buy from UK',
+ } );
+ } );
+
+ it( 'handles German PayPal domain', () => {
+ const setAttributes = jest.fn();
+ render(
+
+ );
+
+ const snippetDE = ``;
+
+ const inputs = screen.getAllByTestId( 'plain-text' );
+ // eslint-disable-next-line testing-library/prefer-user-event
+ fireEvent.change( inputs[ 0 ], {
+ target: { value: snippetDE },
+ } );
+
+ expect( setAttributes ).toHaveBeenCalledWith( {
+ hostedButtonId: 'DE789XYZ',
+ buttonText: 'Jetzt kaufen',
+ } );
+ } );
+
+ it( 'handles sandbox PayPal domain', () => {
+ const setAttributes = jest.fn();
+ render(
+
+ );
+
+ const snippetSandbox = `
+`;
+
+ const inputs = screen.getAllByTestId( 'plain-text' );
+ // eslint-disable-next-line testing-library/prefer-user-event
+ fireEvent.change( inputs[ 0 ], {
+ target: { value: snippetSandbox },
+ } );
+
+ expect( setAttributes ).toHaveBeenCalledWith( {
+ hostedButtonId: 'FHK6SXBKZXM4A',
+ buttonText: 'Buy Now',
+ } );
+ } );
+
+ it( 'handles spaces around equals sign in attributes', () => {
+ const setAttributes = jest.fn();
+ render(
+
+ );
+
+ const snippet = ``;
+
+ const inputs = screen.getAllByTestId( 'plain-text' );
+ // eslint-disable-next-line testing-library/prefer-user-event
+ fireEvent.change( inputs[ 0 ], {
+ target: { value: snippet },
+ } );
+
+ expect( setAttributes ).toHaveBeenCalledWith( {
+ hostedButtonId: 'SPACES123',
+ buttonText: 'Buy Now',
+ } );
+ } );
+
+ it( 'trims whitespace from extracted values', () => {
+ const setAttributes = jest.fn();
+ render(
+
+ );
+
+ const snippet = ``;
+
+ const inputs = screen.getAllByTestId( 'plain-text' );
+ // eslint-disable-next-line testing-library/prefer-user-event
+ fireEvent.change( inputs[ 0 ], {
+ target: { value: snippet },
+ } );
+
+ expect( setAttributes ).toHaveBeenCalledWith( {
+ hostedButtonId: 'TRIM123',
+ buttonText: 'Buy Now',
+ } );
+ } );
+
+ it( 'handles multiple buttons - extracts only the first', () => {
+ const setAttributes = jest.fn();
+ render(
+
+ );
+
+ const snippet = `
+ `;
+
+ const inputs = screen.getAllByTestId( 'plain-text' );
+ // eslint-disable-next-line testing-library/prefer-user-event
+ fireEvent.change( inputs[ 0 ], {
+ target: { value: snippet },
+ } );
+
+ // Should extract only the first button
+ expect( setAttributes ).toHaveBeenCalledWith( {
+ hostedButtonId: 'FIRST123',
+ buttonText: 'First Button',
+ } );
+ } );
+
+ it( 'handles protocol-relative URLs', () => {
+ const setAttributes = jest.fn();
+ render(
+
+ );
+
+ const snippet = ``;
+
+ const inputs = screen.getAllByTestId( 'plain-text' );
+ // eslint-disable-next-line testing-library/prefer-user-event
+ fireEvent.change( inputs[ 0 ], {
+ target: { value: snippet },
+ } );
+
+ expect( setAttributes ).toHaveBeenCalledWith( {
+ hostedButtonId: 'PROTOCOL123',
+ buttonText: 'Buy',
+ } );
+ } );
+
+ it( 'handles mixed case in domain names', () => {
+ const setAttributes = jest.fn();
+ render(
+
+ );
+
+ const snippet = ``;
+
+ const inputs = screen.getAllByTestId( 'plain-text' );
+ // eslint-disable-next-line testing-library/prefer-user-event
+ fireEvent.change( inputs[ 0 ], {
+ target: { value: snippet },
+ } );
+
+ expect( setAttributes ).toHaveBeenCalledWith( {
+ hostedButtonId: 'MIXEDCASE123',
+ buttonText: 'Buy',
+ } );
+ } );
+ } );
+
+ describe( 'Validation Notices', () => {
+ it( 'shows error notice for invalid script URL', () => {
+ render(
+
+ );
+
+ expect( screen.getByTestId( 'notice' ) ).toBeInTheDocument();
+ expect( screen.getByTestId( 'notice' ) ).toHaveAttribute( 'data-status', 'error' );
+ expect( screen.getByText( 'Invalid PayPal script URL.' ) ).toBeInTheDocument();
+ } );
+
+ it( 'shows no notice for valid stacked button data', () => {
+ render(
+
+ );
+
+ expect( screen.queryByTestId( 'notice' ) ).not.toBeInTheDocument();
+ } );
+
+ it( 'shows error notice for invalid hosted button ID', () => {
+ render(
+
+ );
+
+ expect( screen.getByTestId( 'notice' ) ).toBeInTheDocument();
+ expect( screen.getByTestId( 'notice' ) ).toHaveAttribute( 'data-status', 'error' );
+ expect( screen.getByText( 'Invalid PayPal button ID.' ) ).toBeInTheDocument();
+ } );
+
+ it( 'shows error notice for invalid button text', () => {
+ render(
+
+ );
+
+ expect( screen.getByTestId( 'notice' ) ).toBeInTheDocument();
+ expect( screen.getByTestId( 'notice' ) ).toHaveAttribute( 'data-status', 'error' );
+ expect(
+ screen.getByText( 'Button text must be between 1 and 50 characters.' )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'shows validation errors even when block is selected', () => {
+ render(
+
+ );
+
+ expect( screen.getByTestId( 'notice' ) ).toBeInTheDocument();
+ expect( screen.getByTestId( 'notice' ) ).toHaveAttribute( 'data-status', 'error' );
+ } );
+ } );
+
+ it( 'renders external links to PayPal signup and login pages for WordPress.org', () => {
+ render( );
+ const links = screen.getAllByTestId( 'link' );
+ expect( links ).toHaveLength( 2 );
+
+ // Check signup link
+ expect( links[ 0 ] ).toHaveAttribute(
+ 'href',
+ 'https://www.paypal.com/bizsignup/entry?product=payment_button&utm_source=wp_org&at_code=wp_org'
+ );
+ expect( links[ 0 ] ).toHaveTextContent( 'Sign up' );
+
+ // Check login link
+ expect( links[ 1 ] ).toHaveAttribute(
+ 'href',
+ 'https://www.paypal.com/ncp/buttons/create?utm_source=wp_org&at_code=wp_org'
+ );
+ expect( links[ 1 ] ).toHaveTextContent( 'log in' );
+ } );
+
+ it( 'renders external links to PayPal signup and login pages for WordPress.com', () => {
+ // Mock WordPress.com platform
+ const { isWpcomPlatformSite } = require( '@automattic/jetpack-script-data' );
+ isWpcomPlatformSite.mockReturnValue( true );
+
+ render( );
+ const links = screen.getAllByTestId( 'link' );
+ expect( links ).toHaveLength( 2 );
+
+ // Check signup link
+ expect( links[ 0 ] ).toHaveAttribute(
+ 'href',
+ 'https://www.paypal.com/bizsignup/entry?product=payment_button&utm_source=wp_com&at_code=wp_com'
+ );
+ expect( links[ 0 ] ).toHaveTextContent( 'Sign up' );
+
+ // Check login link
+ expect( links[ 1 ] ).toHaveAttribute(
+ 'href',
+ 'https://www.paypal.com/ncp/buttons/create?utm_source=wp_com&at_code=wp_com'
+ );
+ expect( links[ 1 ] ).toHaveTextContent( 'log in' );
+
+ // Reset mock
+ isWpcomPlatformSite.mockReturnValue( false );
+ } );
+
+ describe( 'Parameter Clearing on Button Type Toggle', () => {
+ it( 'clears all parameters when switching from stacked to single', () => {
+ const setAttributes = jest.fn();
+ render(
+
+ );
+
+ fireEvent.click( screen.getByTestId( 'toggle-option-single' ) ); // eslint-disable-line testing-library/prefer-user-event
+
+ expect( setAttributes ).toHaveBeenCalledWith( {
+ buttonType: 'single',
+ scriptSrc: '',
+ buttonText: '',
+ hostedButtonId: '',
+ } );
+ } );
+
+ it( 'clears all parameters when switching from single to stacked', () => {
+ const setAttributes = jest.fn();
+ render(
+
+ );
+
+ fireEvent.click( screen.getByTestId( 'toggle-option-stacked' ) ); // eslint-disable-line testing-library/prefer-user-event
+
+ expect( setAttributes ).toHaveBeenCalledWith( {
+ buttonType: 'stacked',
+ scriptSrc: '',
+ buttonText: '',
+ hostedButtonId: '',
+ } );
+ } );
+
+ it( 'clears all parameters when switching to the same type', () => {
+ const setAttributes = jest.fn();
+ render(
+
+ );
+
+ fireEvent.click( screen.getByTestId( 'toggle-option-stacked' ) ); // eslint-disable-line testing-library/prefer-user-event
+
+ // Should clear all parameters even when switching to the same type
+ expect( setAttributes ).toHaveBeenCalledWith( {
+ buttonType: 'stacked',
+ scriptSrc: '',
+ buttonText: '',
+ hostedButtonId: '',
+ } );
+ } );
+ } );
+
+ describe( 'Preview Functionality', () => {
+ it( 'shows no preview for stacked buttons', () => {
+ render(
+
+ );
+
+ // Should show the configuration form since stacked button previews are disabled
+ expect( screen.getByTestId( 'placeholder' ) ).toBeInTheDocument();
+ expect( screen.queryByTitle( 'PayPal Button Preview' ) ).not.toBeInTheDocument();
+ } );
+
+ it( 'shows direct button preview when block is not selected and has valid single button data', () => {
+ render(
+
+ );
+
+ // Single button should render directly, not in iframe
+ const button = screen.getByDisplayValue( 'Buy Now' );
+ expect( button ).toBeInTheDocument();
+ expect( button ).toHaveAttribute( 'type', 'button' );
+ expect( screen.queryByTitle( 'PayPal Button Preview' ) ).not.toBeInTheDocument();
+ } );
+
+ it( 'shows placeholder when block is not selected but data is invalid', () => {
+ render(
+
+ );
+
+ // Should show the configuration form, not the preview
+ expect( screen.getByTestId( 'placeholder' ) ).toBeInTheDocument();
+ expect( screen.queryByTitle( 'PayPal Button Preview' ) ).not.toBeInTheDocument();
+ } );
+
+ it( 'shows settings form when block is selected', () => {
+ render(
+
+ );
+
+ expect( screen.getByTestId( 'placeholder' ) ).toBeInTheDocument();
+ expect( screen.queryByTitle( 'PayPal Button Preview' ) ).not.toBeInTheDocument();
+ } );
+
+ it( 'shows preview placeholder message when data is incomplete', () => {
+ // Mock the preview component to show up, but with incomplete data
+ render(
+
+ );
+
+ // Should show the configuration form since data is incomplete
+ expect( screen.getByTestId( 'placeholder' ) ).toBeInTheDocument();
+ expect( screen.queryByTitle( 'PayPal Button Preview' ) ).not.toBeInTheDocument();
+ } );
+ } );
+} );
diff --git a/projects/packages/paypal-payments/tests/js/paypal-payment-buttons-block-tests/edit.test.jsx b/projects/packages/paypal-payments/tests/js/paypal-payment-buttons-block-tests/edit.test.jsx
index 6691a56aa79e..49d76b04e831 100644
--- a/projects/packages/paypal-payments/tests/js/paypal-payment-buttons-block-tests/edit.test.jsx
+++ b/projects/packages/paypal-payments/tests/js/paypal-payment-buttons-block-tests/edit.test.jsx
@@ -14,6 +14,14 @@ import Edit from '../../../src/paypal-payment-buttons/edit';
// apiFetch mock — controls what the component receives from the REST API.
const apiFetch = require( '@wordpress/api-fetch' );
+// The API-managed editor only renders while the feature flag is on.
+jest.mock( '@automattic/jetpack-shared-extension-utils', () => ( {
+ hasFeatureFlag: () => true,
+} ) );
+
+// The paste-code editor has its own suite; keep its imports out of this one.
+jest.mock( '../../../src/paypal-payment-buttons/edit-paste-code', () => () => null );
+
// Mock WordPress element with real React hooks.
jest.mock( '@wordpress/element', () => {
const React = require( 'react' );
diff --git a/projects/packages/paypal-payments/tests/php/PayPal_Admin_Page_Test.php b/projects/packages/paypal-payments/tests/php/PayPal_Admin_Page_Test.php
index aadd6e481614..de81dd9c161d 100644
--- a/projects/packages/paypal-payments/tests/php/PayPal_Admin_Page_Test.php
+++ b/projects/packages/paypal-payments/tests/php/PayPal_Admin_Page_Test.php
@@ -7,6 +7,7 @@
namespace Automattic\Jetpack\PaypalPayments;
+use Automattic\Jetpack\Feature_Flags\Feature_Flags;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
@@ -18,12 +19,39 @@
#[CoversClass( PayPal_Admin_Page::class )]
class PayPal_Admin_Page_Test extends TestCase {
+ /**
+ * Per-flag filter that forces the API-managed buttons on.
+ */
+ private const FLAG_FILTER = 'jetpack_feature_flag_enabled_' . PayPal_Payment_Buttons::API_MANAGED_BUTTONS_FLAG;
+
+ public function test_maybe_init_does_nothing_while_the_flag_is_off() {
+ remove_all_actions( 'admin_menu' );
+
+ PayPal_Admin_Page::maybe_init();
+
+ $this->assertFalse( has_action( 'admin_menu', array( PayPal_Admin_Page::class, 'register_menu' ) ) );
+ }
+
+ public function test_maybe_init_hooks_up_while_the_flag_is_on() {
+ remove_all_actions( 'admin_menu' );
+ add_filter( self::FLAG_FILTER, '__return_true' );
+
+ PayPal_Admin_Page::maybe_init();
+
+ $this->assertNotFalse( has_action( 'admin_menu', array( PayPal_Admin_Page::class, 'register_menu' ) ) );
+
+ remove_all_actions( 'admin_menu' );
+ }
+
/**
* Clean up after each test.
*/
protected function tearDown(): void {
parent::tearDown();
+ remove_all_filters( self::FLAG_FILTER );
+ Feature_Flags::reset();
+
delete_option( PayPal_OAuth::CREDENTIALS_OPTION_KEY );
delete_option( PayPal_OAuth::ENVIRONMENT_OPTION_KEY );
delete_transient( PayPal_OAuth::TOKEN_TRANSIENT_KEY );
diff --git a/projects/packages/paypal-payments/tests/php/PayPal_Email_Sender_Test.php b/projects/packages/paypal-payments/tests/php/PayPal_Email_Sender_Test.php
index 5ff70f1799bb..d4cee80f2712 100644
--- a/projects/packages/paypal-payments/tests/php/PayPal_Email_Sender_Test.php
+++ b/projects/packages/paypal-payments/tests/php/PayPal_Email_Sender_Test.php
@@ -7,6 +7,7 @@
namespace Automattic\Jetpack\PaypalPayments;
+use Automattic\Jetpack\Feature_Flags\Feature_Flags;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
@@ -18,12 +19,39 @@
#[CoversClass( PayPal_Email_Sender::class )]
class PayPal_Email_Sender_Test extends TestCase {
+ /**
+ * Per-flag filter that forces the API-managed buttons on.
+ */
+ private const FLAG_FILTER = 'jetpack_feature_flag_enabled_' . PayPal_Payment_Buttons::API_MANAGED_BUTTONS_FLAG;
+
+ public function test_maybe_init_does_nothing_while_the_flag_is_off() {
+ remove_all_actions( 'wp_ajax_' . PayPal_Email_Sender::AJAX_ACTION );
+
+ PayPal_Email_Sender::maybe_init();
+
+ $this->assertFalse( has_action( 'wp_ajax_' . PayPal_Email_Sender::AJAX_ACTION, array( PayPal_Email_Sender::class, 'handle_send' ) ) );
+ }
+
+ public function test_maybe_init_hooks_up_while_the_flag_is_on() {
+ remove_all_actions( 'wp_ajax_' . PayPal_Email_Sender::AJAX_ACTION );
+ add_filter( self::FLAG_FILTER, '__return_true' );
+
+ PayPal_Email_Sender::maybe_init();
+
+ $this->assertNotFalse( has_action( 'wp_ajax_' . PayPal_Email_Sender::AJAX_ACTION, array( PayPal_Email_Sender::class, 'handle_send' ) ) );
+
+ remove_all_actions( 'wp_ajax_' . PayPal_Email_Sender::AJAX_ACTION );
+ }
+
/**
* Clean up after each test.
*/
protected function tearDown(): void {
parent::tearDown();
+ remove_all_filters( self::FLAG_FILTER );
+ Feature_Flags::reset();
+
delete_option( PayPal_Email_Sender::LOG_OPTION_KEY );
wp_set_current_user( 0 );
diff --git a/projects/packages/paypal-payments/tests/php/Paypal_Payment_Buttons_Test.php b/projects/packages/paypal-payments/tests/php/Paypal_Payment_Buttons_Test.php
index c3a517d9350f..bff9d9cc7619 100644
--- a/projects/packages/paypal-payments/tests/php/Paypal_Payment_Buttons_Test.php
+++ b/projects/packages/paypal-payments/tests/php/Paypal_Payment_Buttons_Test.php
@@ -7,6 +7,7 @@
namespace Automattic\Jetpack\PaypalPayments;
+use Automattic\Jetpack\Feature_Flags\Feature_Flags;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
@@ -30,6 +31,171 @@ protected function tearDown(): void {
$wp_scripts = null;
\WP_Block_Supports::$block_to_render = null;
+
+ remove_all_filters( self::FLAG_FILTER );
+ Feature_Flags::reset();
+ }
+
+ /**
+ * Per-flag filter that forces the API-managed buttons on.
+ */
+ private const FLAG_FILTER = 'jetpack_feature_flag_enabled_' . PayPal_Payment_Buttons::API_MANAGED_BUTTONS_FLAG;
+
+ /**
+ * Register the PayPal routes the way production does -- on rest_api_init --
+ * and return the resulting route table.
+ *
+ * @return array The REST server's route table.
+ */
+ private function build_rest_routes() {
+ global $wp_rest_server;
+ $wp_rest_server = null;
+
+ remove_all_actions( 'rest_api_init' );
+ PayPal_Payment_Buttons::init_rest_api();
+
+ $routes = rest_get_server()->get_routes();
+
+ remove_all_actions( 'rest_api_init' );
+
+ return $routes;
+ }
+
+ public function test_feature_flag_registers_off_by_default() {
+ Feature_Flags::reset();
+
+ PayPal_Payment_Buttons::register_feature_flags();
+
+ $definition = Feature_Flags::get( PayPal_Payment_Buttons::API_MANAGED_BUTTONS_FLAG );
+ $this->assertIsArray( $definition );
+ $this->assertFalse( $definition['default'] );
+ $this->assertFalse( PayPal_Payment_Buttons::is_api_managed_enabled() );
+ }
+
+ public function test_is_api_managed_enabled_honours_the_flag_filter() {
+ PayPal_Payment_Buttons::register_feature_flags();
+ add_filter( self::FLAG_FILTER, '__return_true' );
+
+ $this->assertTrue( PayPal_Payment_Buttons::is_api_managed_enabled() );
+ }
+
+ public function test_add_editor_feature_flags_reports_the_flag_state() {
+ PayPal_Payment_Buttons::register_feature_flags();
+
+ $flags = PayPal_Payment_Buttons::add_editor_feature_flags( array( 'other-flag' => true ) );
+ $this->assertSame(
+ array(
+ 'other-flag' => true,
+ PayPal_Payment_Buttons::API_MANAGED_BUTTONS_FLAG => false,
+ ),
+ $flags
+ );
+
+ add_filter( self::FLAG_FILTER, '__return_true' );
+
+ $flags = PayPal_Payment_Buttons::add_editor_feature_flags( array() );
+ $this->assertTrue( $flags[ PayPal_Payment_Buttons::API_MANAGED_BUTTONS_FLAG ] );
+ }
+
+ public function test_register_rest_routes_registers_nothing_while_the_flag_is_off() {
+ PayPal_Payment_Buttons::register_feature_flags();
+
+ $routes = $this->build_rest_routes();
+
+ $this->assertArrayNotHasKey( '/wpcom/v2/paypal/connection', $routes );
+ $this->assertArrayNotHasKey( '/wpcom/v2/paypal/buttons', $routes );
+ }
+
+ public function test_register_rest_routes_registers_the_routes_while_the_flag_is_on() {
+ PayPal_Payment_Buttons::register_feature_flags();
+ add_filter( self::FLAG_FILTER, '__return_true' );
+
+ $routes = $this->build_rest_routes();
+
+ $this->assertArrayHasKey( '/wpcom/v2/paypal/connection', $routes );
+ $this->assertArrayHasKey( '/wpcom/v2/paypal/buttons', $routes );
+ }
+
+ /**
+ * Gutenberg omits attributes matching the default, so buttons saved as stacked carry
+ * no buttonType -- the default must stay 'stacked' or they re-render as single.
+ */
+ public function test_a_legacy_stacked_button_still_renders_the_sdk_widget() {
+ register_block_type_from_metadata(
+ dirname( __DIR__, 2 ) . '/src/paypal-payment-buttons',
+ array( 'render_callback' => array( PayPal_Payment_Buttons::class, 'render_block' ) )
+ );
+
+ $html = do_blocks( '' );
+
+ unregister_block_type( 'jetpack/paypal-payment-buttons' );
+
+ $this->assertStringContainsString( 'paypal-container-ABC123XYZ', $html );
+ $this->assertStringNotContainsString( '/ncp/payment/', $html );
+ }
+
+ /**
+ * A `file:` asset field here makes core register the editor bundle a second time,
+ * on top of the copy load_editor_scripts() already enqueues.
+ */
+ public function test_block_json_declares_no_asset_fields() {
+ $metadata = json_decode(
+ file_get_contents( dirname( __DIR__, 2 ) . '/src/paypal-payment-buttons/block.json' ),
+ true
+ );
+
+ $this->assertArrayNotHasKey( 'editorScript', $metadata );
+ $this->assertArrayNotHasKey( 'editorStyle', $metadata );
+ $this->assertArrayNotHasKey( 'style', $metadata );
+ }
+
+ public function test_register_block_style_registers_the_front_end_handle() {
+ wp_deregister_script( PayPal_Payment_Buttons::STYLE_HANDLE );
+
+ PayPal_Payment_Buttons::register_block_style();
+
+ $this->assertTrue( wp_script_is( PayPal_Payment_Buttons::STYLE_HANDLE, 'registered' ) );
+
+ wp_deregister_script( PayPal_Payment_Buttons::STYLE_HANDLE );
+ }
+
+ public function test_init_admin_registers_nothing_while_the_flag_is_off() {
+ remove_all_actions( 'init' );
+ remove_all_actions( 'admin_menu' );
+ remove_all_actions( 'wp_ajax_' . PayPal_Email_Sender::AJAX_ACTION );
+
+ PayPal_Payment_Buttons::register_feature_flags();
+
+ PayPal_Payment_Buttons::init_admin();
+ do_action( 'init' );
+
+ // Covers the outcome, not the guard: both maybe_init() methods gate on the flag
+ // themselves, so this still passes if init_admin()'s own check is removed. The
+ // guard's only other effect -- keeping both classes off the autoloader -- is
+ // process-global, so no in-process assertion can pin it.
+ $this->assertFalse( has_action( 'admin_menu', array( PayPal_Admin_Page::class, 'register_menu' ) ) );
+ $this->assertFalse( has_action( 'wp_ajax_' . PayPal_Email_Sender::AJAX_ACTION, array( PayPal_Email_Sender::class, 'handle_send' ) ) );
+
+ remove_all_actions( 'init' );
+ }
+
+ public function test_init_admin_wires_the_admin_page_up_once_the_flag_is_on() {
+ remove_all_actions( 'init' );
+ remove_all_actions( 'admin_menu' );
+ remove_all_actions( 'wp_ajax_' . PayPal_Email_Sender::AJAX_ACTION );
+
+ PayPal_Payment_Buttons::register_feature_flags();
+ add_filter( self::FLAG_FILTER, '__return_true' );
+
+ PayPal_Payment_Buttons::init_admin();
+ do_action( 'init' );
+
+ $this->assertNotFalse( has_action( 'admin_menu', array( PayPal_Admin_Page::class, 'register_menu' ) ) );
+ $this->assertNotFalse( has_action( 'wp_ajax_' . PayPal_Email_Sender::AJAX_ACTION, array( PayPal_Email_Sender::class, 'handle_send' ) ) );
+
+ remove_all_actions( 'init' );
+ remove_all_actions( 'admin_menu' );
+ remove_all_actions( 'wp_ajax_' . PayPal_Email_Sender::AJAX_ACTION );
}
/**
@@ -279,53 +445,33 @@ public function test_init_rest_api_registers_the_routes() {
PayPal_Payment_Buttons::init_rest_api();
$this->assertNotFalse(
- has_action( 'rest_api_init', array( PayPal_REST_Controller::class, 'register_routes' ) )
+ has_action( 'rest_api_init', array( PayPal_Payment_Buttons::class, 'register_rest_routes' ) )
);
remove_all_actions( 'rest_api_init' );
}
/**
- * Test that init_rest_api() does not register the standalone script stubs.
- *
- * Script_Data registers the real jetpack-script-data handle on wp_loaded, after
- * init. A stub registered first wins, and the block editor is then left without
- * window.JetpackScriptData -- which breaks the editor for every block, not just
- * this one.
+ * The editor bundle imports isWpcomPlatformSite from @automattic/jetpack-script-data,
+ * which only exists in the real jetpack-script-data.js. Script_Data registers that file
+ * on wp_loaded; anything that claims the handle on init wins, and the editor is left with
+ * a module missing the export.
*/
- public function test_init_rest_api_does_not_register_script_stubs() {
+ public function test_init_api_leaves_the_script_data_handle_alone() {
remove_all_actions( 'init' );
remove_all_actions( 'rest_api_init' );
+ wp_deregister_script( 'jetpack-script-data' );
- PayPal_Payment_Buttons::init_rest_api();
+ PayPal_Payment_Buttons::init_api();
+ do_action( 'init' );
- $this->assertFalse(
- has_action( 'init', array( PayPal_Payment_Buttons::class, 'register_standalone_script_stubs' ) )
- );
+ $this->assertFalse( wp_script_is( 'jetpack-script-data', 'registered' ) );
+ $this->assertNotFalse( has_action( 'rest_api_init', array( PayPal_Payment_Buttons::class, 'register_rest_routes' ) ) );
remove_all_actions( 'init' );
remove_all_actions( 'rest_api_init' );
}
- /**
- * Test that the script stub is registered when the Jetpack runtime is absent.
- *
- * This is the standalone and Playground case the stub exists for. The Jetpack
- * case is covered by init_rest_api() not hooking the stub at all, since
- * defining a stand-in Jetpack class here would leak into every other test.
- */
- public function test_script_stub_registers_without_jetpack() {
- $this->assertFalse( class_exists( 'Jetpack' ), 'Precondition: no Jetpack runtime in this suite.' );
-
- wp_deregister_script( 'jetpack-script-data' );
-
- PayPal_Payment_Buttons::register_standalone_script_stubs();
-
- $this->assertTrue( wp_script_is( 'jetpack-script-data', 'registered' ) );
-
- wp_deregister_script( 'jetpack-script-data' );
- }
-
/**
* Test that render_block includes product image when imageUrl is set.
*/
diff --git a/projects/plugins/jetpack/changelog/add-paypal-api-managed-buttons-flag b/projects/plugins/jetpack/changelog/add-paypal-api-managed-buttons-flag
new file mode 100644
index 000000000000..a8f8f3a53b3a
--- /dev/null
+++ b/projects/plugins/jetpack/changelog/add-paypal-api-managed-buttons-flag
@@ -0,0 +1,5 @@
+Significance: patch
+Type: other
+Comment: Add a feature flag for the API-managed PayPal payment buttons, off by default.
+
+
diff --git a/projects/plugins/jetpack/changelog/add-paypal-payment-buttons-v2 b/projects/plugins/jetpack/changelog/add-paypal-payment-buttons-v2
index 5f7c9e590031..01c22e8a5656 100644
--- a/projects/plugins/jetpack/changelog/add-paypal-payment-buttons-v2
+++ b/projects/plugins/jetpack/changelog/add-paypal-payment-buttons-v2
@@ -1,4 +1,4 @@
Significance: minor
Type: enhancement
-PayPal Payment Buttons: connect a PayPal account from WordPress, create and manage payment links without leaving the editor, choose a Button, Link, or QR format, and pick a Light, Auto, or Dark style preset.
+PayPal Payment Buttons: add API-managed buttons behind a feature flag that is not yet enabled. Once it is on, you can connect a PayPal account from WordPress, create and manage payment links without leaving the editor, choose a Button, Link, or QR format, and pick a Light, Auto, or Dark style preset.
diff --git a/projects/plugins/jetpack/class.jetpack-gutenberg.php b/projects/plugins/jetpack/class.jetpack-gutenberg.php
index 676954ae7d1e..3dfda5654dd4 100644
--- a/projects/plugins/jetpack/class.jetpack-gutenberg.php
+++ b/projects/plugins/jetpack/class.jetpack-gutenberg.php
@@ -172,8 +172,9 @@ class Jetpack_Gutenberg {
* @var array Feature slug => minimum WordPress.com plan slug.
*/
private static $wpcom_minimum_plan_fallbacks = array(
- 'donations' => 'value_bundle',
- 'payment-buttons' => 'value_bundle',
+ 'donations' => 'value_bundle',
+ 'payment-buttons' => 'value_bundle',
+ 'paypal-payment-buttons' => 'value_bundle',
);
/**
diff --git a/projects/plugins/jetpack/composer.lock b/projects/plugins/jetpack/composer.lock
index fd2c278929d1..d0d54a2f41e8 100644
--- a/projects/plugins/jetpack/composer.lock
+++ b/projects/plugins/jetpack/composer.lock
@@ -2662,12 +2662,13 @@
"dist": {
"type": "path",
"url": "../../packages/paypal-payments",
- "reference": "7a8b44c4c3054eb4cf2c846b5aa1b6ffcc074be0"
+ "reference": "b69608477af0d1dec58aaff82dd8d0744836b500"
},
"require": {
"automattic/jetpack-assets": "@dev",
"automattic/jetpack-blocks": "@dev",
"automattic/jetpack-connection": "@dev",
+ "automattic/jetpack-feature-flags": "@dev",
"automattic/jetpack-plans": "@dev",
"automattic/jetpack-status": "@dev",
"php": ">=7.4"
diff --git a/projects/plugins/jetpack/extensions/blocks/paypal-payment-buttons/paypal-payment-buttons.php b/projects/plugins/jetpack/extensions/blocks/paypal-payment-buttons/paypal-payment-buttons.php
index ec3fd071be11..6e00947d5126 100644
--- a/projects/plugins/jetpack/extensions/blocks/paypal-payment-buttons/paypal-payment-buttons.php
+++ b/projects/plugins/jetpack/extensions/blocks/paypal-payment-buttons/paypal-payment-buttons.php
@@ -13,6 +13,10 @@
exit( 0 );
}
+// The API-managed buttons ship behind a flag; register it before anything reads it.
+PayPal_Payment_Buttons::register_feature_flags();
+add_filter( 'jetpack_block_editor_feature_flags', array( PayPal_Payment_Buttons::class, 'add_editor_feature_flags' ) );
+
// Register the block.
add_action( 'init', array( PayPal_Payment_Buttons::class, 'register_block' ), 9 );
@@ -21,9 +25,9 @@
* to connect an account and manage payment links. Without them the block renders
* but every request the editor makes -- onboarding, connect, button CRUD -- 404s.
*
- * Only the routes: init_api() would also register the standalone script stubs, which
- * exist for hosts without the Jetpack runtime and would shadow Jetpack's own
- * jetpack-script-data handle.
+ * Only the routes: init_api() also hooks sharing and the email sender, neither of
+ * which is wired up on Jetpack today. Both this and init_admin() no-op while the
+ * flag is off.
*/
PayPal_Payment_Buttons::init_rest_api();
diff --git a/projects/plugins/paypal-payment-buttons/changelog/add-paypal-api-managed-buttons-flag b/projects/plugins/paypal-payment-buttons/changelog/add-paypal-api-managed-buttons-flag
new file mode 100644
index 000000000000..5c6f5d8bbf9d
--- /dev/null
+++ b/projects/plugins/paypal-payment-buttons/changelog/add-paypal-api-managed-buttons-flag
@@ -0,0 +1,5 @@
+Significance: patch
+Type: changed
+Comment: Add a feature flag for the API-managed payment buttons, off by default.
+
+
diff --git a/projects/plugins/paypal-payment-buttons/changelog/add-paypal-payment-buttons-v2 b/projects/plugins/paypal-payment-buttons/changelog/add-paypal-payment-buttons-v2
index 1232e0e08dd3..0f00330f4047 100644
--- a/projects/plugins/paypal-payment-buttons/changelog/add-paypal-payment-buttons-v2
+++ b/projects/plugins/paypal-payment-buttons/changelog/add-paypal-payment-buttons-v2
@@ -1,4 +1,4 @@
Significance: minor
Type: added
-Add API-driven payment buttons: connect a PayPal account from WordPress, create and manage payment links without leaving the editor, choose a Button, Link, or QR format, and pick a Light, Auto, or Dark style preset.
+Add API-managed payment buttons behind a feature flag that is not yet enabled. Once it is on, you can connect a PayPal account from WordPress, create and manage payment links without leaving the editor, choose a Button, Link, or QR format, and pick a Light, Auto, or Dark style preset.
diff --git a/projects/plugins/paypal-payment-buttons/changelog/fix-jetpack-script-data-stub b/projects/plugins/paypal-payment-buttons/changelog/fix-jetpack-script-data-stub
deleted file mode 100644
index 4abba0bfe45e..000000000000
--- a/projects/plugins/paypal-payment-buttons/changelog/fix-jetpack-script-data-stub
+++ /dev/null
@@ -1,4 +0,0 @@
-Significance: patch
-Type: fixed
-
-Stop the standalone plugin registering a jetpack-script-data stub when the Jetpack plugin is active, which left the block editor blank.
diff --git a/projects/plugins/paypal-payment-buttons/composer.lock b/projects/plugins/paypal-payment-buttons/composer.lock
index 6f43504ad057..03ff7235d912 100644
--- a/projects/plugins/paypal-payment-buttons/composer.lock
+++ b/projects/plugins/paypal-payment-buttons/composer.lock
@@ -535,6 +535,64 @@
"relative": true
}
},
+ {
+ "name": "automattic/jetpack-feature-flags",
+ "version": "dev-trunk",
+ "dist": {
+ "type": "path",
+ "url": "../../packages/feature-flags",
+ "reference": "d5def8cd2f0e9da4f405329c2d0304d37d0ed08c"
+ },
+ "require": {
+ "php": ">=7.4"
+ },
+ "require-dev": {
+ "automattic/phpunit-select-config": "@dev",
+ "brain/monkey": "^2.6.2",
+ "yoast/phpunit-polyfills": "^4.0.0"
+ },
+ "suggest": {
+ "automattic/jetpack-autoloader": "Allow for better interoperability with other plugins that use this package."
+ },
+ "type": "jetpack-library",
+ "extra": {
+ "autotagger": true,
+ "mirror-repo": "Automattic/jetpack-feature-flags",
+ "changelogger": {
+ "link-template": "https://github.com/Automattic/jetpack-feature-flags/compare/v${old}...v${new}"
+ },
+ "branch-alias": {
+ "dev-trunk": "0.2.x-dev"
+ },
+ "textdomain": "jetpack-feature-flags",
+ "version-constants": {
+ "::PACKAGE_VERSION": "src/class-feature-flags.php"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "scripts": {
+ "phpunit": [
+ "phpunit-select-config phpunit.#.xml.dist --colors=always"
+ ],
+ "test-php": [
+ "@composer phpunit"
+ ],
+ "test-php-coverage": [
+ "php -dpcov.directory=. ./vendor/bin/phpunit-select-config phpunit.#.xml.dist --coverage-php \"$COVERAGE_DIR/php.cov\""
+ ]
+ },
+ "license": [
+ "GPL-2.0-or-later"
+ ],
+ "description": "Shared utilities for registering and checking lightweight Jetpack feature flags.",
+ "transport-options": {
+ "relative": true
+ }
+ },
{
"name": "automattic/jetpack-ip",
"version": "dev-trunk",
@@ -599,12 +657,13 @@
"dist": {
"type": "path",
"url": "../../packages/paypal-payments",
- "reference": "7a8b44c4c3054eb4cf2c846b5aa1b6ffcc074be0"
+ "reference": "b69608477af0d1dec58aaff82dd8d0744836b500"
},
"require": {
"automattic/jetpack-assets": "@dev",
"automattic/jetpack-blocks": "@dev",
"automattic/jetpack-connection": "@dev",
+ "automattic/jetpack-feature-flags": "@dev",
"automattic/jetpack-plans": "@dev",
"automattic/jetpack-status": "@dev",
"php": ">=7.4"
diff --git a/projects/plugins/paypal-payment-buttons/src/class-paypal-payment-buttons.php b/projects/plugins/paypal-payment-buttons/src/class-paypal-payment-buttons.php
index 9133ee0aee88..e4ce0cef1863 100644
--- a/projects/plugins/paypal-payment-buttons/src/class-paypal-payment-buttons.php
+++ b/projects/plugins/paypal-payment-buttons/src/class-paypal-payment-buttons.php
@@ -60,8 +60,8 @@ private function __construct() {
* @return void
*/
public function init_hooks() {
- // Register standalone script stubs for Jetpack dependencies not available outside the monorepo.
- add_action( 'init', array( $this, 'register_standalone_script_stubs' ), 1 );
+ // The API-managed buttons ship behind a flag; register it before anything reads it.
+ Jetpack_PayPal_Payment_Buttons::register_feature_flags();
// Initialize PayPal Payment Buttons block with correct dist path
add_action( 'init', array( $this, 'register_paypal_block' ), 9 );
@@ -86,68 +86,6 @@ public function init_hooks() {
}
}
- /**
- * Register script stubs for Jetpack dependencies that are not available in standalone mode.
- *
- * The editor.js bundle declares `jetpack-script-data` as a dependency (from
- *
- * @automattic/jetpack-script-data). In the Jetpack plugin this is registered by the
- * Assets package, but in standalone mode it does not exist. WordPress silently
- * refuses to enqueue scripts with unregistered dependencies, so we register an
- * empty stub to satisfy the dependency chain.
- */
- public function register_standalone_script_stubs() {
- /*
- * The Jetpack plugin registers the real handle from Script_Data on wp_loaded,
- * which fires after init. Registering a stub first therefore wins, and the
- * editor is left without window.JetpackScriptData.
- */
- if ( class_exists( 'Jetpack' ) ) {
- return;
- }
-
- if ( ! wp_script_is( 'jetpack-script-data', 'registered' ) ) {
- wp_register_script( 'jetpack-script-data', false, array(), '1.0.0', false );
-
- // The webpack build externalizes @automattic/jetpack-script-data to
- // window.JetpackScriptDataModule (UMD global). The module's getScriptData()
- // returns window.JetpackScriptData. Without these globals the editor.js
- // bundle crashes at module init time in connection/state/store.jsx.
- $current_user = wp_get_current_user();
- $script_data = wp_json_encode(
- array(
- 'site' => array(
- 'icon' => get_site_icon_url(),
- 'title' => get_bloginfo( 'name' ),
- 'admin_url' => admin_url(),
- 'rest_root' => esc_url_raw( rest_url() ),
- 'rest_nonce' => wp_create_nonce( 'wp_rest' ),
- 'wp_version' => get_bloginfo( 'version' ),
- ),
- 'user' => array(
- 'current_user' => array(
- 'id' => $current_user->ID,
- 'display_name' => $current_user->display_name,
- 'capabilities' => array(
- 'manage_options' => current_user_can( 'manage_options' ),
- 'manage_modules' => current_user_can( 'manage_options' ),
- ),
- ),
- ),
- ),
- JSON_HEX_TAG | JSON_HEX_AMP
- );
-
- $inline_js = sprintf(
- 'window.JetpackScriptData = %s;'
- . 'window.JetpackScriptDataModule = { getScriptData: function() { return window.JetpackScriptData; } };',
- $script_data
- );
-
- wp_add_inline_script( 'jetpack-script-data', $inline_js, 'before' );
- }
- }
-
/**
* Register the PayPal Payment Buttons block with the correct dist path.
*/
@@ -160,11 +98,14 @@ public function register_paypal_block() {
return false;
}
+ Jetpack_PayPal_Payment_Buttons::register_block_style();
+
// Register the block using the Blocks package with the correct dist path
Blocks::jetpack_register_block(
$dist_dir,
array(
'render_callback' => array( Jetpack_PayPal_Payment_Buttons::class, 'render_block' ),
+ 'style' => Jetpack_PayPal_Payment_Buttons::STYLE_HANDLE,
)
);
}
@@ -190,6 +131,7 @@ public function enqueue_block_availability_data() {
'available' => true,
),
),
+ 'feature_flags' => Jetpack_PayPal_Payment_Buttons::add_editor_feature_flags( array() ),
);
wp_localize_script(
diff --git a/projects/plugins/paypal-payment-buttons/tests/e2e/package.json b/projects/plugins/paypal-payment-buttons/tests/e2e/package.json
index 861101e3684b..3a83f814501a 100644
--- a/projects/plugins/paypal-payment-buttons/tests/e2e/package.json
+++ b/projects/plugins/paypal-payment-buttons/tests/e2e/package.json
@@ -7,8 +7,8 @@
"config:decrypt": "openssl enc -md sha1 -aes-256-cbc -pbkdf2 -iter 100000 -d -pass env:CONFIG_KEY -in ./node_modules/@automattic/_jetpack-e2e-commons/config/encrypted.enc -out ./config/local.cjs",
"distclean": "rm -rf node_modules",
"env:down": "e2e-env stop",
- "env:reset": "e2e-env reset --activate-plugins paypal-payment-buttons",
- "env:up": "e2e-env start --activate-plugins paypal-payment-buttons",
+ "env:reset": "e2e-env reset --activate-plugins paypal-payment-buttons e2e-paypal-feature-flag",
+ "env:up": "e2e-env start --activate-plugins paypal-payment-buttons e2e-paypal-feature-flag",
"pretest:run": "pnpm run clean",
"test:run": "playwright install chromium && NODE_CONFIG_DIR='./config' ALLURE_RESULTS_DIR=./output/allure-results NODE_PATH=\"$PWD/node_modules\" playwright test",
"tunnel:down": "tunnel down",
diff --git a/projects/plugins/paypal-payment-buttons/tests/e2e/plugins/e2e-paypal-feature-flag.php b/projects/plugins/paypal-payment-buttons/tests/e2e/plugins/e2e-paypal-feature-flag.php
new file mode 100644
index 000000000000..1326135eed5f
--- /dev/null
+++ b/projects/plugins/paypal-payment-buttons/tests/e2e/plugins/e2e-paypal-feature-flag.php
@@ -0,0 +1,15 @@
+