Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions client/components/web-preview/content.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,7 @@ export default class WebPreviewContent extends Component {
isSticky={ this.props.isStickyToolbar }
themeId={ themeId }
previewSource={ themeOptions?.previewSource }
persistStyleVariation={ themeOptions?.persistStyleVariation }
/>
{ this.props.showExternal && this.props.isModalWindow && ! this.props.isPrivateAtomic && (
<DomainUpsellCallout trackEvent="site_preview_domain_upsell_callout" />
Expand Down
8 changes: 7 additions & 1 deletion client/components/web-preview/toolbar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ class PreviewToolbar extends Component {
isLivePreviewSupported: PropTypes.bool,
siteEditorUrl: PropTypes.string,
themeInstallId: PropTypes.string,
setStyleVariation: PropTypes.func,
};

static defaultProps = {
Expand All @@ -87,7 +88,12 @@ class PreviewToolbar extends Component {

this.props.recordTracksEvent( 'calypso_editor_preview_edit_header_click' );

const { isAtomic, selectedSiteId, siteEditorUrl, themeInstallId } = this.props;
const { isAtomic, selectedSiteId, siteEditorUrl, themeInstallId, persistStyleVariation } =
this.props;

if ( persistStyleVariation ) {
await persistStyleVariation();
}

// For atomic sites, we need to install theme before navigating to site editor
// If theme is already installed, installation will silently fail, and we just switch to the site-editor.
Expand Down
146 changes: 139 additions & 7 deletions client/my-sites/theme/main.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ import ThemePreview from 'calypso/my-sites/themes/theme-preview';
import { useSelector } from 'calypso/state';
import { recordTracksEvent } from 'calypso/state/analytics/actions';
import { getCurrentUserSiteCount, isUserLoggedIn } from 'calypso/state/current-user/selectors';
import {
getGlobalStyles,
getGlobalStylesId,
updateGlobalStyles,
} from 'calypso/state/global-styles/actions';
import { successNotice, errorNotice } from 'calypso/state/notices/actions';
import { getProductsList } from 'calypso/state/products-list/selectors';
import { canCurrentUser } from 'calypso/state/selectors/can-current-user';
Expand Down Expand Up @@ -134,6 +139,23 @@ const { unlock } = __dangerousOptInToUnstableAPIsOnlyForCoreModules(

const { Badge } = unlock( privateApis );

// Lazy-load cleanEmptyObject from @wordpress/block-editor to avoid build error.
// The @wordpress/block-editor package tries to access `window` at module load time,
// so we defer the import until runtime when we know we're on the client side.
let cleanEmptyObject;
const getCleanEmptyObject = async () => {
if ( ! cleanEmptyObject ) {
const { privateApis: blockEditorPrivateApis } = await import( '@wordpress/block-editor' );
const { unlock: unlockBlockEditor } = __dangerousOptInToUnstableAPIsOnlyForCoreModules(
'I acknowledge private features are not for use in themes or plugins and doing so will break in the next version of WordPress.',
'@wordpress/block-editor'
);
cleanEmptyObject = unlockBlockEditor( blockEditorPrivateApis ).cleanEmptyObject;
}

return cleanEmptyObject;
};

const SiteIntent = Onboard.SiteIntent;

class ThemeSheet extends Component {
Expand Down Expand Up @@ -186,6 +208,9 @@ class ThemeSheet extends Component {
canUserEditThemeOptions: PropTypes.bool,
siteEditorUrl: PropTypes.string,
themeInstallId: PropTypes.string,
getGlobalStyles: PropTypes.func,
getGlobalStylesId: PropTypes.func,
updateGlobalStyles: PropTypes.func,
};

static defaultProps = {
Expand All @@ -203,6 +228,9 @@ class ThemeSheet extends Component {
isReviewsModalVisible: false,
isSiteSelectorModalVisible: false,
isWide: isWithinBreakpoint( '>960px' ),
currentGlobalStyles: null,
globalStylesId: null,
styleVariation: null,
};

// This is a plain instance property because we only want to know the state of the
Expand Down Expand Up @@ -230,6 +258,7 @@ class ThemeSheet extends Component {
} );

this.maybeAutoActivate();
this.fetchCurrentGlobalStyles();
}

componentDidUpdate( prevProps ) {
Expand Down Expand Up @@ -265,6 +294,20 @@ class ThemeSheet extends Component {
this.unsubscribeBreakpoint();
}

fetchCurrentGlobalStyles = async () => {
const { siteId, themeId } = this.props;

if ( ! siteId ) {
return;
}

const globalStylesId = await this.props.getGlobalStylesId( siteId, themeId );
this.setState( { globalStylesId } );

const currentGlobalStyles = await this.props.getGlobalStyles( siteId, globalStylesId );
this.setState( { currentGlobalStyles } );
};

maybeAutoActivate() {
const { defaultOption } = this.props;
if ( defaultOption?.key === 'activate' && hasQueryArg( window.location.href, 'activating' ) ) {
Expand Down Expand Up @@ -306,7 +349,10 @@ class ThemeSheet extends Component {
this.props.themeId,
this.props.defaultOption,
this.props.secondaryOption,
{ styleVariation: this.getSelectedStyleVariation() }
{
styleVariation: this.getSelectedStyleVariation(),
persistStyleVariation: this.persistStyleVariation,
}
);
};

Expand All @@ -333,24 +379,43 @@ class ThemeSheet extends Component {
};

onStyleVariationClick = ( variation ) => {
// eslint-disable-next-line no-unused-vars -- We want inline_css out of the styleVariation
const { inline_css, ...styleVariation } = variation;
this.setState( { styleVariation } );

this.props.recordTracksEvent( 'calypso_theme_sheet_style_variation_click', {
theme_name: this.props.themeId,
style_variation: variation.slug,
} );

if ( typeof window !== 'undefined' ) {
const params = new URLSearchParams( window.location.search );
if ( variation.slug !== DEFAULT_GLOBAL_STYLES_VARIATION_SLUG ) {
params.set( 'style_variation', variation.slug );
} else {
params.delete( 'style_variation' );
}
params.set( 'style_variation', variation.slug );

const paramsString = params.toString().length ? `?${ params.toString() }` : '';
page( `${ window.location.pathname }${ paramsString }` );
}
};

persistStyleVariation = async () => {
const { siteId } = this.props;
const { globalStylesId, styleVariation } = this.state;

if ( ! styleVariation ) {
return;
}

const { _links, settings, styles } = styleVariation;

const cleanEmptyObjectFn = await getCleanEmptyObject();

await this.props.updateGlobalStyles( siteId, globalStylesId, {
settings: cleanEmptyObjectFn( settings ) || {},
styles: cleanEmptyObjectFn( styles ) || {},
_links: cleanEmptyObjectFn( _links ) || {},
} );
};

getValidSections = () => {
const validSections = [];
validSections.push( '' ); // Default section
Expand Down Expand Up @@ -404,6 +469,7 @@ class ThemeSheet extends Component {
{
styleVariation: this.getSelectedStyleVariation(),
previewSource: previewSource,
persistStyleVariation: this.persistStyleVariation,
}
);

Expand Down Expand Up @@ -984,6 +1050,8 @@ class ThemeSheet extends Component {

this.props.recordTracksEvent( 'calypso_theme_sheet_editor_preview_click' );

await this.persistStyleVariation();

// For atomic sites, we need to install theme before navigating to site editor
// If theme is already installed, installation will silently fail, and we just switch to the site-editor.
try {
Expand Down Expand Up @@ -1081,9 +1149,70 @@ class ThemeSheet extends Component {
);
};

/**
* Helper function to check if two style objects match
* Compares color palettes and font families as primary indicators
*/
doStylesMatch = ( globalStyles, variation ) => {
if ( ! globalStyles || ! variation ) {
return false;
}

// Extract color palettes
const globalColors = globalStyles.settings?.color?.palette?.theme || [];
const variationColors = variation.settings?.color?.palette?.theme || [];

// Extract font families
const globalFonts = globalStyles.settings?.typography?.fontFamilies?.theme || [];
const variationFonts = variation.settings?.typography?.fontFamilies?.theme || [];

// Compare color palettes by checking if the first 3 colors match (base, contrast, accent-1)
const colorsMatch =
globalColors.length === variationColors.length &&
globalColors.slice( 0, 3 ).every( ( color, index ) => {
const varColor = variationColors[ index ];
return color?.slug === varColor?.slug && color?.color === varColor?.color;
} );

// Compare font families by checking if the font slugs match
const fontsMatch =
globalFonts.length === variationFonts.length &&
globalFonts.every( ( font, index ) => {
const varFont = variationFonts[ index ];
return font?.slug === varFont?.slug;
} );

return colorsMatch && fontsMatch;
};

getSelectedStyleVariation = () => {
const { selectedStyleVariationSlug, styleVariations } = this.props;
return styleVariations.find( ( variation ) => variation.slug === selectedStyleVariationSlug );
const { currentGlobalStyles } = this.state;

// First, try to match by URL slug (user's selection)
if ( selectedStyleVariationSlug ) {
const variationBySlug = styleVariations.find(
( variation ) => variation.slug === selectedStyleVariationSlug
);
if ( variationBySlug ) {
return variationBySlug;
}
}

// If we have current global styles from the API, try to match by comparing styles
if ( currentGlobalStyles ) {
const matchingVariation = styleVariations.find( ( variation ) =>
this.doStylesMatch( currentGlobalStyles, variation )
);
if ( matchingVariation ) {
return matchingVariation;
}
}

// Fall back to default variation
return styleVariations.find( ( variation ) =>
isDefaultGlobalStylesVariationSlug( variation.slug )
);
};

getBackLink = () => {
Expand Down Expand Up @@ -1522,6 +1651,9 @@ export default connect(
recordTracksEvent,
themeStartActivationSync: themeStartActivationSyncAction,
errorNotice,
getGlobalStyles,
getGlobalStylesId,
updateGlobalStyles,
}
)(
withCompleteLaunchpadTasksWithNotice(
Expand Down
46 changes: 46 additions & 0 deletions client/my-sites/theme/test/main.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,52 @@ jest.mock( '@automattic/odie-client/src/data', () => ( {
broadcastOdieMessage: jest.fn(),
} ) );

// Mock the private APIs before other imports
// This needs to happen before @wordpress/data is imported
jest.mock( '@wordpress/private-apis', () => {
const mockPrivateApis = new Map();
return {
__dangerousOptInToUnstableAPIsOnlyForCoreModules: () => ( {
lock: ( api, privateExports ) => {
mockPrivateApis.set( api, privateExports );
},
unlock: ( api ) => {
// If we have the actual locked API, return it
if ( mockPrivateApis.has( api ) ) {
return mockPrivateApis.get( api );
}
// Otherwise return a mock based on the module
// For @wordpress/block-editor
if ( api && typeof api === 'object' && 'privateApis' in api ) {
return {
cleanEmptyObject: ( obj ) => {
if ( ! obj ) {
return obj;
}
const cleaned = {};
Object.keys( obj ).forEach( ( key ) => {
if ( obj[ key ] !== null && obj[ key ] !== undefined && obj[ key ] !== '' ) {
cleaned[ key ] = obj[ key ];
}
} );
return Object.keys( cleaned ).length > 0 ? cleaned : undefined;
},
Badge: ( { children, style } ) => ( { children, style } ),
};
}
// Fallback for components
return {
Badge: ( { children, style } ) => ( { children, style } ),
};
},
} ),
};
} );

jest.mock( '@wordpress/block-editor', () => ( {
privateApis: {},
} ) );

jest.mock( 'calypso/lib/analytics/tracks', () => ( {} ) );
jest.mock( 'calypso/my-sites/themes/theme-preview', () =>
require( 'calypso/components/empty-component' )
Expand Down
11 changes: 11 additions & 0 deletions client/state/global-styles/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,14 @@ export function updateGlobalStyles(
return updatedGlobalStyles;
};
}

export function getGlobalStyles( siteIdOrSlug: number | string, globalStylesId: number ) {
return async () => {
const globalStyles: GlobalStyles = await wpcom.req.get( {
path: `/sites/${ encodeURIComponent( siteIdOrSlug ) }/global-styles/${ globalStylesId }`,
apiNamespace: 'wp/v2',
} );

return globalStyles;
};
}