diff --git a/projects/js-packages/publicize-components/changelog/add-social-edit-template-modal b/projects/js-packages/publicize-components/changelog/add-social-edit-template-modal
new file mode 100644
index 0000000000000..fa19f4b15cd52
--- /dev/null
+++ b/projects/js-packages/publicize-components/changelog/add-social-edit-template-modal
@@ -0,0 +1,4 @@
+Significance: patch
+Type: added
+
+Add edit template modal.
diff --git a/projects/js-packages/publicize-components/src/components/media-section-v2/index.tsx b/projects/js-packages/publicize-components/src/components/media-section-v2/index.tsx
index c0a22db7b2757..4484030e02271 100644
--- a/projects/js-packages/publicize-components/src/components/media-section-v2/index.tsx
+++ b/projects/js-packages/publicize-components/src/components/media-section-v2/index.tsx
@@ -7,6 +7,7 @@ import { ThemeProvider } from '@automattic/jetpack-components';
import { useAnalytics } from '@automattic/jetpack-shared-extension-utils';
import { MediaUpload } from '@wordpress/block-editor';
import { BaseControl, Button, Notice } from '@wordpress/components';
+import { useDispatch } from '@wordpress/data';
import { useCallback, useMemo, useRef } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import useFeaturedImage from '../../hooks/use-featured-image';
@@ -15,6 +16,7 @@ import useMediaDetails from '../../hooks/use-media-details';
import { SELECTABLE_MEDIA_TYPES } from '../../hooks/use-media-restrictions/restrictions';
import { usePostMeta } from '../../hooks/use-post-meta';
import useSigPreview from '../../hooks/use-sig-preview';
+import { store as socialStore } from '../../social-store';
import CustomMediaToggle from './custom-media-toggle';
import MediaPreview from './media-preview';
import MediaSourceMenu, { getMediaSourceDescription } from './media-source-menu';
@@ -78,6 +80,7 @@ export default function MediaSectionV2( {
const { isEnabled: sigEnabled } = useImageGeneratorConfig();
const { attachedMedia, imageGeneratorSettings, mediaSource, updateJetpackSocialOptions } =
usePostMeta();
+ const { openUnifiedModal } = useDispatch( socialStore );
// Get SIG preview URL when SIG is enabled
const { url: sigPreviewUrl, isLoading: sigIsLoading } = useSigPreview( sigEnabled );
@@ -85,6 +88,11 @@ export default function MediaSectionV2( {
// Ref to store the MediaUpload open function
const openMediaLibraryRef = useRef< () => void >( () => {} );
+ // Open edit template modal
+ const handleEditTemplateClick = useCallback( () => {
+ openUnifiedModal( { initialPath: '/edit-template', isScreenLocked: true } );
+ }, [ openUnifiedModal ] );
+
// Determine current media source
// Priority 1: Explicit user choice (if media_source is set)
// Priority 2: Detect from existing data (backward compatibility)
@@ -291,7 +299,7 @@ export default function MediaSectionV2( {
{ __( 'Edit template', 'jetpack-publicize-components' ) }
diff --git a/projects/js-packages/publicize-components/src/components/social-image-generator/panel/edit-template.tsx b/projects/js-packages/publicize-components/src/components/social-image-generator/panel/edit-template.tsx
new file mode 100644
index 0000000000000..2cb71af799b11
--- /dev/null
+++ b/projects/js-packages/publicize-components/src/components/social-image-generator/panel/edit-template.tsx
@@ -0,0 +1,25 @@
+import { Button } from '@wordpress/components';
+import { useDispatch } from '@wordpress/data';
+import { useCallback } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import { store as socialStore } from '../../../social-store';
+
+/**
+ * Edit Template Button component.
+ *
+ * @return - React element
+ */
+export function EditTemplate() {
+ const { openUnifiedModal } = useDispatch( socialStore );
+
+ const handleOpenModal = useCallback( () => {
+ // When opening modal from the sidebar, we want to lock the screen to prevent navigation.
+ openUnifiedModal( { initialPath: '/edit-template', isScreenLocked: true } );
+ }, [ openUnifiedModal ] );
+
+ return (
+
+ { __( 'Edit template', 'jetpack-publicize-components' ) }
+
+ );
+}
diff --git a/projects/js-packages/publicize-components/src/components/social-image-generator/panel/index.js b/projects/js-packages/publicize-components/src/components/social-image-generator/panel/index.js
index ecd1d321b8adb..bdb1a7302b6d3 100644
--- a/projects/js-packages/publicize-components/src/components/social-image-generator/panel/index.js
+++ b/projects/js-packages/publicize-components/src/components/social-image-generator/panel/index.js
@@ -1,4 +1,5 @@
import { ThemeProvider, useGlobalNotices } from '@automattic/jetpack-components';
+import { siteHasFeature } from '@automattic/jetpack-script-data';
import {
ToggleControl,
Button,
@@ -9,7 +10,9 @@ import { useCallback, useState } from '@wordpress/element';
import { __, _x } from '@wordpress/i18n';
import useImageGeneratorConfig from '../../../hooks/use-image-generator-config';
import { useSaveImageToLibrary } from '../../../hooks/use-save-image-to-library';
+import { features } from '../../../utils';
import GeneratedImagePreview from '../../generated-image-preview';
+import { EditTemplate } from './edit-template';
import SocialImageGeneratorSettingsModal from './modal';
const SocialImageGeneratorPanel = () => {
@@ -92,6 +95,10 @@ const SocialImageGeneratorPanel = () => {
)
: __( 'Save to media library', 'jetpack-publicize-components' ) }
+ { siteHasFeature( features.UNIFIED_UI_V1 ) ? (
+ // TODO: Replace EditTemplate button with full image UI controls integrated with the sidebar
+
+ ) : null }
>
) }
diff --git a/projects/js-packages/publicize-components/src/components/social-image-generator/template-picker/picker/index.js b/projects/js-packages/publicize-components/src/components/social-image-generator/template-picker/picker/index.js
index ceafe5d3236c3..73bf6488eb776 100644
--- a/projects/js-packages/publicize-components/src/components/social-image-generator/template-picker/picker/index.js
+++ b/projects/js-packages/publicize-components/src/components/social-image-generator/template-picker/picker/index.js
@@ -11,12 +11,13 @@ import TEMPLATES_DATA from './templates.js';
* The pure template picker component. Does not save the template changes, just sends it back to the parent component,
* with the onTemplateSelected callback.
*
- * @param {{value: string|null, onTemplateSelected: Function}} props - The component props:
- * Value is the name of the currently selected template, onTemplateSelected is a function that
- * will be called when a template is selected. Receives the name of the selected template as an argument.
+ * @param {{value: string|null, onTemplateSelected: Function, className: string}} props - The component props:
+ * Value is the name of the currently selected template, onTemplateSelected is a function that
+ * will be called when a template is selected. Receives the name of the selected template as an argument.
+ * className is an optional additional class name to apply to the container.
* @return {ReactNode} - The component's rendered output.
*/
-const TemplatePicker = ( { value = null, onTemplateSelected = null } ) => {
+const TemplatePicker = ( { value = null, onTemplateSelected = null, className = null } ) => {
const onTemplateClicked = useCallback(
event => {
const templateName = event.target.id;
@@ -26,7 +27,7 @@ const TemplatePicker = ( { value = null, onTemplateSelected = null } ) => {
);
return (
-
+
{ TEMPLATES_DATA.map( template => (
void ) => void;
+ onClose: () => void;
+}
+
+/**
+ * ImageSourceMenuItem component
+ *
+ * @param props - Component props
+ * @param props.option - Menu option
+ * @param props.isSelected - Whether this option is selected
+ * @param props.onSelect - Selection handler
+ * @param props.onClose - Close handler
+ * @return MenuItem component
+ */
+function ImageSourceMenuItem( {
+ option,
+ isSelected,
+ onSelect,
+ onClose,
+}: ImageSourceMenuItemProps ) {
+ const handleClick = useCallback( () => {
+ onSelect( option.id, onClose );
+ }, [ option.id, onSelect, onClose ] );
+
+ return (
+
+ { option.label }
+
+ );
+}
+
+interface BackgroundImagePickerProps {
+ imageType: ImageType;
+ imageId: number | null;
+ defaultImageId: number | null;
+ featuredImageId: number | null;
+ onImageTypeChange: ( value: ImageType ) => void;
+ onImageIdChange: ( id: number | null ) => void;
+}
+
+/**
+ * Get the label describing the current image source
+ *
+ * @param imageType - Current image type
+ * @return Description label
+ */
+function getImageSourceLabel( imageType: ImageType ): string {
+ switch ( imageType ) {
+ case 'default':
+ return __( 'You are using the default image', 'jetpack-publicize-components' );
+ case 'featured':
+ return __( 'You are using your post featured image', 'jetpack-publicize-components' );
+ case 'custom':
+ return __( 'You are using a custom image', 'jetpack-publicize-components' );
+ case 'none':
+ return __( 'No background image', 'jetpack-publicize-components' );
+ default:
+ return __( 'You are using your post featured image', 'jetpack-publicize-components' );
+ }
+}
+
+/**
+ * Get the image ID to display based on the current image type
+ *
+ * @param imageType - Current image type
+ * @param customImageId - Custom image ID
+ * @param featuredImageId - Featured image ID
+ * @param defaultImageId - Default image ID
+ * @return Image ID to display
+ */
+function getDisplayImageId(
+ imageType: ImageType,
+ customImageId: number | null,
+ featuredImageId: number | null,
+ defaultImageId: number | null
+): number | null {
+ switch ( imageType ) {
+ case 'default':
+ return defaultImageId;
+ case 'featured':
+ return featuredImageId;
+ case 'custom':
+ return customImageId;
+ case 'none':
+ return null;
+ default:
+ return featuredImageId;
+ }
+}
+
+/**
+ * BackgroundImagePicker component
+ *
+ * @param props - Component props
+ * @param props.imageType - Current image type
+ * @param props.imageId - Custom image ID
+ * @param props.defaultImageId - Default image ID
+ * @param props.featuredImageId - Featured image ID
+ * @param props.onImageTypeChange - Image type change handler
+ * @param props.onImageIdChange - Image ID change handler
+ * @return BackgroundImagePicker component
+ */
+export function BackgroundImagePicker( {
+ imageType,
+ imageId,
+ defaultImageId,
+ featuredImageId,
+ onImageTypeChange,
+ onImageIdChange,
+}: BackgroundImagePickerProps ) {
+ // Ref to store the MediaUpload open function
+ const openMediaLibraryRef = useRef< () => void >( () => {} );
+
+ // Get the image ID to display
+ const displayImageId = useMemo(
+ () => getDisplayImageId( imageType, imageId, featuredImageId, defaultImageId ),
+ [ imageType, imageId, featuredImageId, defaultImageId ]
+ );
+
+ const [ mediaDetails ] = useMediaDetails( displayImageId );
+ const imageUrl = mediaDetails?.mediaData?.sourceUrl;
+ const isLoading = Boolean( displayImageId ) && ! imageUrl;
+ const showFeaturedImageNotice = imageType === 'featured' && ! featuredImageId;
+
+ // Build preview data for MediaPreview component
+ const previewData = useMemo( () => {
+ if ( ! imageUrl && ! isLoading ) {
+ return null;
+ }
+ return {
+ id: displayImageId || 0,
+ url: imageUrl || '',
+ type: 'image' as const,
+ };
+ }, [ imageUrl, isLoading, displayImageId ] );
+
+ // Build menu options (no "No Image" - Remove button handles that)
+ const menuOptions = useMemo( () => {
+ const options: MenuOption[] = [];
+
+ if ( defaultImageId ) {
+ options.push( {
+ id: 'default',
+ label: __( 'Default Image', 'jetpack-publicize-components' ),
+ icon: image,
+ } );
+ }
+
+ options.push( {
+ id: 'featured',
+ label: __( 'Featured Image', 'jetpack-publicize-components' ),
+ icon: image,
+ } );
+
+ options.push( {
+ id: 'custom',
+ label: __( 'Media Library', 'jetpack-publicize-components' ),
+ icon: mediaIcon,
+ } );
+
+ return options;
+ }, [ defaultImageId ] );
+
+ // Handle media library selection
+ const handleMediaSelect = useCallback(
+ ( media: { id: number } ) => {
+ onImageTypeChange( 'custom' );
+ onImageIdChange( media?.id || null );
+ },
+ [ onImageTypeChange, onImageIdChange ]
+ );
+
+ const handleMediaLibraryClick = useCallback( () => {
+ setTimeout( () => {
+ openMediaLibraryRef.current();
+ }, 0 );
+ }, [] );
+
+ const renderMediaUpload = useCallback( ( { open }: { open: () => void } ) => {
+ openMediaLibraryRef.current = open;
+ return null;
+ }, [] );
+
+ // Handle menu item selection
+ const handleOptionSelect = useCallback(
+ ( optionId: string, onClose: () => void ) => {
+ if ( optionId === 'custom' ) {
+ handleMediaLibraryClick();
+ } else {
+ onImageTypeChange( optionId as ImageType );
+ }
+ onClose();
+ },
+ [ onImageTypeChange, handleMediaLibraryClick ]
+ );
+
+ // Handle remove - set to 'none'
+ const handleRemove = useCallback( () => {
+ onImageTypeChange( 'none' );
+ onImageIdChange( null );
+ }, [ onImageTypeChange, onImageIdChange ] );
+
+ const renderDropdownContent = useCallback(
+ ( { onClose }: { onClose: () => void } ) => (
+
+ { menuOptions.map( option => (
+
+ ) ) }
+
+ ),
+ [ menuOptions, imageType, handleOptionSelect ]
+ );
+
+ // Render toggle for Select image dropdown
+ const renderSelectToggle = useCallback(
+ ( { onToggle }: { onToggle: () => void } ) => (
+
+ { __( 'Select image', 'jetpack-publicize-components' ) }
+
+ ),
+ []
+ );
+
+ // Render toggle for preview dropdown (wraps MediaPreview)
+ const renderPreviewToggle = useCallback(
+ ( { onToggle }: { onToggle: () => void } ) => (
+
+ ),
+ [ previewData, isLoading, handleRemove ]
+ );
+
+ return (
+
+ { /* Hidden MediaUpload component */ }
+
+
+ { /* Source label */ }
+
{ getImageSourceLabel( imageType ) }
+
+ { /* Image preview - reuses MediaPreview from media-section-v2 */ }
+ { previewData && (
+
+ ) }
+
+ { /* Warning notice for missing featured image */ }
+ { showFeaturedImageNotice && (
+
+ { __( 'Your post does not have a featured image.', 'jetpack-publicize-components' ) }
+
+ ) }
+
+ { /* No image state - show select button */ }
+ { ! previewData && ! isLoading && (
+
+ ) }
+
+ );
+}
diff --git a/projects/js-packages/publicize-components/src/components/unified-modal/edit-template/content.tsx b/projects/js-packages/publicize-components/src/components/unified-modal/edit-template/content.tsx
new file mode 100644
index 0000000000000..f83d812b21af9
--- /dev/null
+++ b/projects/js-packages/publicize-components/src/components/unified-modal/edit-template/content.tsx
@@ -0,0 +1,51 @@
+/**
+ * Content component for Edit Template Modal
+ *
+ * Right side of the modal containing the live preview
+ */
+
+import { Spinner } from '@wordpress/components';
+import { __ } from '@wordpress/i18n';
+import useSigPreview from '../../../hooks/use-sig-preview';
+import styles from './styles.module.scss';
+import { LocalState } from './types';
+
+type ContentProps = {
+ localState: LocalState;
+};
+
+/**
+ * Content component with live preview
+ *
+ * @param props - Component props
+ * @param props.localState - Local state
+ * @return Content component
+ */
+export function Content( { localState }: ContentProps ) {
+ const { url, isLoading } = useSigPreview( true, {
+ shouldDebounce: true,
+ imageType: localState.imageType,
+ imageId: localState.imageId,
+ customText: localState.customText,
+ template: localState.template || undefined,
+ font: localState.font,
+ } );
+
+ return (
+
+
+ { isLoading ? (
+
+ ) : (
+ url && (
+
+ )
+ ) }
+
+
+ );
+}
diff --git a/projects/js-packages/publicize-components/src/components/unified-modal/edit-template/sidebar.tsx b/projects/js-packages/publicize-components/src/components/unified-modal/edit-template/sidebar.tsx
new file mode 100644
index 0000000000000..6d1bc672893ed
--- /dev/null
+++ b/projects/js-packages/publicize-components/src/components/unified-modal/edit-template/sidebar.tsx
@@ -0,0 +1,136 @@
+/**
+ * Sidebar component for Edit Template Modal
+ *
+ * Contains all control sections: Background Image, Template, Text, Font
+ */
+
+import { SelectControl, TextControl } from '@wordpress/components';
+import { useCallback } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import { type ImageType } from '../../../hooks/use-sig-preview/utils';
+import { useSocialImageFontOptions } from '../../../hooks/use-social-image-font-options';
+import TemplatePicker from '../../social-image-generator/template-picker/picker';
+import { BackgroundImagePicker } from './background-image-picker';
+import styles from './styles.module.scss';
+import { LocalState } from './types';
+
+type SidebarProps = {
+ localState: LocalState;
+ setLocalState: React.Dispatch< React.SetStateAction< LocalState > >;
+ defaultImageId: number | null;
+ featuredImageId: number | null;
+};
+
+/**
+ * Sidebar component with all control sections
+ *
+ * @param {SidebarProps} props - Component props
+ * @return Sidebar component
+ */
+export function Sidebar( {
+ localState,
+ setLocalState,
+ defaultImageId,
+ featuredImageId,
+}: SidebarProps ) {
+ const { isLoading: isLoadingFontOptions, fontOptions } = useSocialImageFontOptions();
+
+ const handleImageTypeChange = useCallback(
+ ( value: ImageType ) => {
+ setLocalState( prev => ( { ...prev, imageType: value } ) );
+ },
+ [ setLocalState ]
+ );
+
+ const handleImageIdChange = useCallback(
+ ( id: number | null ) => {
+ setLocalState( prev => ( { ...prev, imageId: id } ) );
+ },
+ [ setLocalState ]
+ );
+
+ const handleCustomTextChange = useCallback(
+ ( value: string ) => {
+ setLocalState( prev => ( { ...prev, customText: value } ) );
+ },
+ [ setLocalState ]
+ );
+
+ const handleTemplateChange = useCallback(
+ ( value: string ) => {
+ setLocalState( prev => ( { ...prev, template: value } ) );
+ },
+ [ setLocalState ]
+ );
+
+ const handleFontChange = useCallback(
+ ( value: string ) => {
+ setLocalState( prev => ( { ...prev, font: value } ) );
+ },
+ [ setLocalState ]
+ );
+
+ return (
+
+ { /* Background Image Section */ }
+
+
+ { __( 'Background image', 'jetpack-publicize-components' ) }
+
+
+
+
+ { /* Template Section */ }
+
+
+ { __( 'Template', 'jetpack-publicize-components' ) }
+
+
+
+
+ { /* Text Section */ }
+
+
+ { __( 'Text', 'jetpack-publicize-components' ) }
+
+
+
+
+ { /* Font Section */ }
+
+
+ { __( 'Font', 'jetpack-publicize-components' ) }
+
+
+
+
+ );
+}
diff --git a/projects/js-packages/publicize-components/src/components/unified-modal/edit-template/styles.module.scss b/projects/js-packages/publicize-components/src/components/unified-modal/edit-template/styles.module.scss
new file mode 100644
index 0000000000000..877e8f72b0d1f
--- /dev/null
+++ b/projects/js-packages/publicize-components/src/components/unified-modal/edit-template/styles.module.scss
@@ -0,0 +1,86 @@
+@use "@automattic/jetpack-base-styles/gutenberg-base-styles" as gb;
+
+.sidebar {
+ padding: 1.5rem;
+ overflow-y: auto;
+}
+
+.section {
+ margin-bottom: 24px;
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+}
+
+.sectionLabel {
+ font-size: 11px;
+ font-weight: 500;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ color: gb.$gray-700;
+ margin-bottom: 12px;
+}
+
+.content {
+ flex: 1;
+ background-color: gb.$gray-100;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 24px;
+ overflow: auto;
+}
+
+// Preview container for the generated image
+.preview {
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ max-width: 800px;
+ aspect-ratio: 1200 / 630; // SIG image aspect-ratio
+ background-color: gb.$white;
+}
+
+.previewImage {
+ max-width: 100%;
+ max-height: 100%;
+ width: auto;
+ height: auto;
+ object-fit: contain;
+}
+
+// Background Image Picker styles
+.backgroundPicker {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.sourceLabel {
+ font-size: 13px;
+ color: gb.$gray-700;
+ margin: 0;
+}
+
+.selectDropdown {
+ width: 100%;
+}
+
+.selectButton {
+ width: 100%;
+ justify-content: center;
+}
+
+.notice {
+ margin: 0;
+}
+
+// Template picker grid - 2x2 layout in the modal sidebar
+// Uses !important to override the picker's default responsive grid
+.templateGrid {
+ grid-template-columns: repeat(2, 1fr) !important;
+ grid-gap: 8px !important;
+}
diff --git a/projects/js-packages/publicize-components/src/components/unified-modal/edit-template/test/sidebar.test.tsx b/projects/js-packages/publicize-components/src/components/unified-modal/edit-template/test/sidebar.test.tsx
new file mode 100644
index 0000000000000..6eaeb77e8561a
--- /dev/null
+++ b/projects/js-packages/publicize-components/src/components/unified-modal/edit-template/test/sidebar.test.tsx
@@ -0,0 +1,282 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import useMediaDetails from '../../../../hooks/use-media-details';
+import * as fontOptions from '../../../../hooks/use-social-image-font-options';
+import { Sidebar } from '../sidebar';
+import { LocalState } from '../types';
+
+// Mock dependencies
+jest.mock( '../../../../hooks/use-media-details', () => jest.fn() );
+jest.spyOn( fontOptions, 'useSocialImageFontOptions' ).mockImplementation();
+
+// Mock TemplatePicker
+jest.mock( '../../../social-image-generator/template-picker/picker', () => {
+ // eslint-disable-next-line jsdoc/require-jsdoc
+ function mockOnTemplateSelected( onTemplateSelected: ( template: string ) => void ) {
+ return function handleClick() {
+ onTemplateSelected( 'new-template' );
+ };
+ }
+
+ return function MockTemplatePicker( {
+ onTemplateSelected,
+ }: {
+ onTemplateSelected: ( template: string ) => void;
+ } ) {
+ return (
+
+ Pick Template
+
+ );
+ };
+} );
+
+// Mock styles
+jest.mock( '../styles.module.scss', () => ( {
+ sidebar: 'sidebar',
+ section: 'section',
+ sectionLabel: 'sectionLabel',
+ backgroundPicker: 'backgroundPicker',
+ sourceLabel: 'sourceLabel',
+ selectDropdown: 'selectDropdown',
+ selectButton: 'selectButton',
+ notice: 'notice',
+ templateGrid: 'templateGrid',
+} ) );
+
+const mockSetLocalState = jest.fn();
+
+const defaultLocalState: LocalState = {
+ imageId: null,
+ imageType: 'featured',
+ customText: '',
+ template: 'highway',
+ font: '',
+};
+
+const setupMocks = () => {
+ ( useMediaDetails as jest.Mock ).mockReturnValue( [
+ {
+ mediaData: {
+ sourceUrl: 'https://example.com/image.jpg',
+ },
+ },
+ ] );
+
+ ( fontOptions.useSocialImageFontOptions as jest.Mock ).mockReturnValue( {
+ isLoading: false,
+ fontOptions: [
+ { label: 'Default', value: '' },
+ { label: 'Font 1', value: 'font-1' },
+ { label: 'Font 2', value: 'font-2' },
+ ],
+ } );
+};
+
+describe( 'Sidebar', () => {
+ beforeEach( () => {
+ jest.clearAllMocks();
+ setupMocks();
+ } );
+
+ it( 'should render all sections', () => {
+ render(
+
+ );
+
+ // Check sections
+ expect( screen.getByText( 'Background image' ) ).toBeInTheDocument();
+ expect( screen.getByText( 'Template' ) ).toBeInTheDocument();
+ expect( screen.getByText( 'Text' ) ).toBeInTheDocument();
+ expect( screen.getByText( 'Font' ) ).toBeInTheDocument();
+ } );
+
+ it( 'should render template picker', () => {
+ render(
+
+ );
+
+ expect( screen.getByTestId( 'template-picker' ) ).toBeInTheDocument();
+ } );
+
+ it( 'should render custom text input', () => {
+ render(
+
+ );
+
+ expect( screen.getByPlaceholderText( 'Custom text' ) ).toBeInTheDocument();
+ } );
+
+ describe( 'Background image picker', () => {
+ it( 'should show current image source label', () => {
+ render(
+
+ );
+
+ expect( screen.getByText( 'You are using your post featured image' ) ).toBeInTheDocument();
+ } );
+
+ it( 'should show Replace and Remove buttons for image preview', () => {
+ render(
+
+ );
+
+ expect( screen.getByText( 'Replace' ) ).toBeInTheDocument();
+ expect( screen.getByText( 'Remove' ) ).toBeInTheDocument();
+ } );
+
+ it( 'should show image options when Replace button is clicked', async () => {
+ const user = userEvent.setup();
+ render(
+
+ );
+
+ const replaceButton = screen.getByText( 'Replace' );
+ await user.click( replaceButton );
+
+ await waitFor( () => {
+ expect( screen.getByText( 'Featured Image' ) ).toBeInTheDocument();
+ expect( screen.getByText( 'Media Library' ) ).toBeInTheDocument();
+ } );
+ } );
+
+ it( 'should show Default Image option when defaultImageId is provided', async () => {
+ const user = userEvent.setup();
+ render(
+
+ );
+
+ const replaceButton = screen.getByText( 'Replace' );
+ await user.click( replaceButton );
+
+ await waitFor( () => {
+ expect( screen.getByText( 'Default Image' ) ).toBeInTheDocument();
+ } );
+ } );
+
+ it( 'should show warning notice when featured image is not set', () => {
+ ( useMediaDetails as jest.Mock ).mockReturnValue( [ {} ] );
+ render(
+
+ );
+
+ const notices = screen.getAllByText( 'Your post does not have a featured image.' );
+ expect( notices.length ).toBeGreaterThanOrEqual( 1 );
+ } );
+ } );
+
+ describe( 'Custom text input', () => {
+ it( 'should call setLocalState when custom text is changed', async () => {
+ const user = userEvent.setup();
+ render(
+
+ );
+
+ const textInput = screen.getByPlaceholderText( 'Custom text' );
+ await user.type( textInput, 'M' );
+
+ await waitFor( () => {
+ expect( mockSetLocalState ).toHaveBeenCalled();
+ // Get the updater function and call it with previous state
+ const updater = mockSetLocalState.mock.calls[ 0 ][ 0 ];
+ const newState = updater( defaultLocalState );
+ expect( newState ).toEqual(
+ expect.objectContaining( {
+ customText: 'M',
+ } )
+ );
+ } );
+ } );
+ } );
+
+ describe( 'Template picker', () => {
+ it( 'should call setLocalState when template is selected', async () => {
+ const user = userEvent.setup();
+ render(
+
+ );
+
+ const pickButton = screen.getByText( 'Pick Template' );
+ await user.click( pickButton );
+
+ await waitFor( () => {
+ expect( mockSetLocalState ).toHaveBeenCalled();
+ // Get the updater function and call it with previous state
+ const updater = mockSetLocalState.mock.calls[ 0 ][ 0 ];
+ const newState = updater( defaultLocalState );
+ expect( newState ).toEqual(
+ expect.objectContaining( {
+ template: 'new-template',
+ } )
+ );
+ } );
+ } );
+ } );
+
+ describe( 'Font selection', () => {
+ it( 'should render font select control', () => {
+ render(
+
+ );
+
+ // Check for a combobox element (the font dropdown)
+ const fontSelect = screen.getByRole( 'combobox' );
+ expect( fontSelect ).toBeInTheDocument();
+ } );
+ } );
+} );
diff --git a/projects/js-packages/publicize-components/src/components/unified-modal/edit-template/types.ts b/projects/js-packages/publicize-components/src/components/unified-modal/edit-template/types.ts
new file mode 100644
index 0000000000000..0d8647d0865c1
--- /dev/null
+++ b/projects/js-packages/publicize-components/src/components/unified-modal/edit-template/types.ts
@@ -0,0 +1,26 @@
+export type LocalState = {
+ /**
+ * ID of the image in the generated image.
+ */
+ imageId: number | null;
+
+ /**
+ * Type of the image in the generated image.
+ */
+ imageType: 'default' | 'featured' | 'custom' | 'none';
+
+ /**
+ * Custom text for the generated image.
+ */
+ customText: string;
+
+ /**
+ * Template for the generated image.
+ */
+ template: string | null;
+
+ /**
+ * Font for the generated image.
+ */
+ font: string;
+};
diff --git a/projects/js-packages/publicize-components/src/components/unified-modal/edit-template/use-modal-screen.tsx b/projects/js-packages/publicize-components/src/components/unified-modal/edit-template/use-modal-screen.tsx
new file mode 100644
index 0000000000000..34107b170db84
--- /dev/null
+++ b/projects/js-packages/publicize-components/src/components/unified-modal/edit-template/use-modal-screen.tsx
@@ -0,0 +1,71 @@
+import { useDispatch, useSelect } from '@wordpress/data';
+import { __ } from '@wordpress/i18n';
+import { useCallback, useMemo, useState } from 'react';
+import useFeaturedImage from '../../../hooks/use-featured-image';
+import useImageGeneratorConfig from '../../../hooks/use-image-generator-config';
+import { store as socialStore } from '../../../social-store';
+import { ScreenDetails } from '../types';
+import { Content } from './content';
+import { Sidebar } from './sidebar';
+import { LocalState } from './types';
+
+/**
+ * Hook to get modal screen details for edit template.
+ *
+ * @return screen details
+ */
+export function useModalScreen(): ScreenDetails {
+ const isScreenLocked = useSelect(
+ select => select( socialStore ).isUnifiedModalScreenLocked(),
+ []
+ );
+ const { closeUnifiedModal } = useDispatch( socialStore );
+
+ const featuredImageId = useFeaturedImage();
+ const { customText, imageType, imageId, template, font, defaultImageId, updateSettings } =
+ useImageGeneratorConfig();
+
+ const [ localState, setLocalState ] = useState< LocalState >( () => ( {
+ imageId: imageId ?? null,
+ imageType: ( imageType ?? 'featured' ) as LocalState[ 'imageType' ],
+ customText: customText ?? '',
+ template: template ?? null,
+ font: font ?? '',
+ } ) );
+
+ const handleSave = useCallback( () => {
+ updateSettings( {
+ image_type: localState.imageType,
+ image_id: localState.imageId,
+ custom_text: localState.customText,
+ template: localState.template,
+ font: localState.font,
+ } );
+ closeUnifiedModal();
+ }, [ localState, updateSettings, closeUnifiedModal ] );
+
+ return useMemo(
+ () => ( {
+ path: '/edit-template',
+ title: __( 'Edit social image template', 'jetpack-publicize-components' ),
+ isScreenLocked,
+ sidebar: (
+
+ ),
+ content: ,
+ footerActions: [
+ {
+ text: __( 'Save Changes', 'jetpack-publicize-components' ),
+ variant: 'primary',
+ onClick: handleSave,
+ },
+ ],
+ } ),
+ [ localState, setLocalState, isScreenLocked, defaultImageId, featuredImageId, handleSave ]
+ );
+}
diff --git a/projects/js-packages/publicize-components/src/components/unified-modal/edit-template/utils.ts b/projects/js-packages/publicize-components/src/components/unified-modal/edit-template/utils.ts
new file mode 100644
index 0000000000000..8eb6c6f615257
--- /dev/null
+++ b/projects/js-packages/publicize-components/src/components/unified-modal/edit-template/utils.ts
@@ -0,0 +1,12 @@
+import { LocalState } from './types';
+
+export const getLocalImageType = (
+ featuredImageId: number,
+ defaultImageId: number
+): LocalState[ 'imageType' ] => {
+ if ( ! featuredImageId && defaultImageId ) {
+ return 'default';
+ }
+
+ return 'featured';
+};
diff --git a/projects/js-packages/publicize-components/src/components/unified-modal/index.tsx b/projects/js-packages/publicize-components/src/components/unified-modal/index.tsx
index 7f7fc2e1ef349..3dcd69c3b6efc 100644
--- a/projects/js-packages/publicize-components/src/components/unified-modal/index.tsx
+++ b/projects/js-packages/publicize-components/src/components/unified-modal/index.tsx
@@ -2,6 +2,7 @@ import { NavigatorModal, ThemeProvider } from '@automattic/jetpack-components';
import { useDispatch, useSelect } from '@wordpress/data';
import { useCallback } from 'react';
import { store as socialStore } from '../../social-store';
+import { useModalScreen as useEditTemplateModalScreen } from './edit-template/use-modal-screen';
import { useModalScreen as useSocialPostPreviewModalScreen } from './social-post-preview/use-modal-screen';
/**
@@ -13,6 +14,7 @@ function ThemedUnifiedModal() {
const initialPath = useSelect( select => select( socialStore ).getUnifiedModalInitialPath(), [] );
const socialPostPreviewModalScreen = useSocialPostPreviewModalScreen();
+ const editTemplateModalScreen = useEditTemplateModalScreen();
const { closeUnifiedModal } = useDispatch( socialStore );
@@ -24,6 +26,7 @@ function ThemedUnifiedModal() {
+
{ /* Generate with AI screen goes here */ }
diff --git a/projects/js-packages/publicize-components/src/components/unified-modal/social-post-preview/sidebar.tsx b/projects/js-packages/publicize-components/src/components/unified-modal/social-post-preview/sidebar.tsx
index 9927ca0bfa599..bfff8e18ddaf7 100644
--- a/projects/js-packages/publicize-components/src/components/unified-modal/social-post-preview/sidebar.tsx
+++ b/projects/js-packages/publicize-components/src/components/unified-modal/social-post-preview/sidebar.tsx
@@ -1,5 +1,5 @@
import { useAnalytics } from '@automattic/jetpack-shared-extension-utils';
-import { Panel, PanelBody, PanelRow } from '@wordpress/components';
+import { Button, Panel, PanelBody, PanelRow, useNavigator } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import { useCallback } from 'react';
import useSocialMediaConnections from '../../../hooks/use-social-media-connections';
@@ -43,6 +43,12 @@ export function Sidebar( { onClickConnection, selectedConnection }: SidebarProps
[ selectedConnection ]
);
+ const navigator = useNavigator();
+
+ const gotoEditTemplate = useCallback( () => {
+ navigator.goTo( '/edit-template' );
+ }, [ navigator ] );
+
return (
@@ -61,6 +67,9 @@ export function Sidebar( { onClickConnection, selectedConnection }: SidebarProps
>
{ /* TODO: Replace Edit template button with full image editor UI when SIG integration is complete. */ }
+
+ { __( 'Edit template', 'jetpack-publicize-components' ) }
+
diff --git a/projects/js-packages/publicize-components/src/hooks/use-image-generator-config/index.js b/projects/js-packages/publicize-components/src/hooks/use-image-generator-config/index.js
index 5877ffefde872..d444600effbdf 100644
--- a/projects/js-packages/publicize-components/src/hooks/use-image-generator-config/index.js
+++ b/projects/js-packages/publicize-components/src/hooks/use-image-generator-config/index.js
@@ -26,6 +26,7 @@ const getCurrentSettings = ( sigSettings, isPostPublished ) => ( {
* @property {number} defaultImageId - Optional. ID of the default image.
* @property {Function} setIsEnabled - Callback to enable or disable the image generator for a post.
* @property {Function} updateProperty - Callback to update various SIG settings.
+ * @property {Function} updateSettings - Callback to update multiple SIG settings at once.
* @property {Function} setToken - Callback to change the token.
*/