Person editing WordPress blocks
, ,

Persisting block settings for faster usage in editor

When it comes to working with WordPress editor and blocks, sometimes the existing or recommended solutions doesn’t quite fit the need or intention for your block.
When creating a Products Layout block, as a part of my Mosaic Product Layouts (MPL) plugin, I wanted to add a functionality for fast saving and switching different user created designs.

The “WordPress way” would be to add patterns to a plugin, but patterns are usually intended for usage with multiple blocks, grouped together as one design solution (a pattern), while I wanted to create switching options only inside the single block.

Block patterns are great for shipping curated layouts, but there is also one more issue – non-synced patterns don’t write changes back to the original pattern. The Mosaic Product Layouts plugin benefits from a dedicated “layout and syle setup” control that empowers editors to capture the current block configuration instantly and swap between presets without leaving the canvas.

So, I decided to build my own way. See the final result:

I will provide the most essential methods and workflow for the explanation of logic,

Let’s go:


Registering settings with WordPress

First, since I decided to use a WordPress settings API, some settings needed to be registered first.

	/**
	 * Register plugin settings (for wp_options table).
	 *
	 * These settings are registered with the `mosaic_product_layouts` settings group.
	 * The settings are strings, and are shown in the REST API.
	 * The `sanitize_callback` for the settings is `sanitize_text_field`.
	 *
	 * @return void
	 */
	private function register_settings(): void {

		if ( ! current_user_can( 'manage_options' ) ) {
			return;
		}

		$allowed_settings = array(
			'mosaic_product_layouts_single_setups',
			'mosaic_product_layouts_products_setups',
			'mosaic_product_layouts_categories_setups',
		);

		foreach ( $allowed_settings as $setting ) {
			register_setting( 'options', $setting, array(
				'type'              => 'string',
				'show_in_rest'      => true,
				'sanitize_callback' => 'sanitize_text_field',
			) );
		}
	}
	
    /**
	 * Initialize the plugin.
	 *
     * @return void
	 */
	public function init(): void {
		// ... other code.
		// Register plugin settings.
		add_action( 'admin_init', array( $this, 'register_settings' ) );
		add_action( 'rest_api_init', array( $this, 'register_settings' ) );
	}

These methods are extracted from class, add them in your code as you see fit.


Setting up the React Component

Using @wordpress / packages for everything

All the functionalities and components in this custom component are created using @wordpress packages, so add this imports to beginning of the code:

/**
 * WordPress dependecies.
 */
import { __ } from '@wordpress/i18n';
import apiFetch from '@wordpress/api-fetch';
import {
	Placeholder,
	Spinner,
	Button,
	Modal,
	TextControl,
	__experimentalToggleGroupControl as ToggleGroupControl,
	Icon,
	Dropdown,
	Flex,
	BaseControl,
	Tooltip,
	CardDivider,
	CheckboxControl
} from '@wordpress/components';
import { useState, useEffect } from '@wordpress/element';
import { cleanForSlug } from '@wordpress/url';
import { starFilled, chevronUp, chevronDown, helpFilled } from "@wordpress/icons";

/**
 * Internal dependencies.
 */
import showNotice from '../utils/showNotice';

The custom React component for the new settings control was in order – the SaveCurrentSetup component. The ‘setup‘ is (in this case) a current state of block attributes for given MPL plugin block.

The first “magic” happens with apiFetch and with it’s POST and GET methods.

/**
 * Component for managing plugin settings and layouts.
 *
 * @param {Object} props - Component properties
 * @param {string} props.context - The context where the settings are being rendered. Defaults to 'InspectorControls'.
 * @param {string} props.blockType - The type of block being edited (single product, products grid, or categories grid)
 * @param {Object} props.attributes - Block attributes containing layout and style settings
 * @param {Function} props.setAttributes - Function to update block attributes
 * @return {JSX.Element} The rendered plugin settings component
 */
const SaveCurrentSetup = ({ context = 'InspectorControls', blockType, attributes, setAttributes }) => {

	// If block settings are for a products/categories grid, and NOT for single product.
	const isBlockTypeGrid = (blockType === 'mosaic_product_layouts_products_setups' || blockType === 'mosaic_product_layouts_categories_setups') ?? false;

	const {
		savedLayouts,
		itemZindexes,
		gridSettings,
		productElementSettings,
		featuredImageSize,
		grouped,
		itemStyleOverrides // Products and categories grid only.
	} = attributes;

  // Initial state setup.
	const [userSetups, setUserSetups] = useState([]);
	const [isAPILoaded, setIsAPILoaded] = useState(false);
	const [isModalOpen, setIsModalOpen] = useState(false);
	const [newSetupName, setNewSetupName] = useState('');
	const [isSaving, setIsSaving] = useState(false);
	const [error, setError] = useState(null);


	useEffect(() => {
		const controller = new AbortController();

		const fetchSettings = async () => {
			setError(null);
			try {
				const response = await apiFetch({
					path: '/wp/v2/settings',
					signal: controller.signal
				});
				const serializedSetups = response[blockType];
				setUserSetups(serializedSetups ? JSON.parse(serializedSetups) : []);
			} catch (error) {
				if (error.name === 'AbortError') {
					// Request was aborted, do nothing
					return;
				}
				console.error('Failed to fetch settings:', error);
				setError(__('Failed to load settings. Please refresh the page.', 'mosaic-product-layouts'));
				setUserSetups([]);
			} finally {
				setIsAPILoaded(true);
			}
		};

		fetchSettings();

		return () => controller.abort();
	}, [blockType]);

Main component properties are:
context – acceptable either ‘InspectorControls’ or ‘BlockControls’ – where to add the control
blockType – should be one of block types (string) defined in settings registration
attributes – prop from main block with attributes
setAttributes – main WordPress block attributes state management.

The userSetupsis a state variable we are going to manipulate and it’s an array of JSON data, with multiple setups (selection of block attributes). The userSetupsis handled internally with React useState hook (setUserSetups).

/**
 * Saves user setups to the database with proper state management.
 *
 * @param {Array} setupsToSave - The setups array to save
 * @return {Promise<boolean>} Returns true if save was successful
 */
const saveSetups = async (setupsToSave) => {
	setIsSaving(true);
	setError(null);

	try {
		await apiFetch({
			path: '/wp/v2/settings',
			method: 'POST',
			data: {
				[blockType]: JSON.stringify(setupsToSave),
			},
		});
		showNotice(__('Layout settings saved successfully!', 'mosaic-product-layouts'));
		return true;
	} catch (error) {
		const errorMessage = error.message || __('Failed to save layout settings.', 'mosaic-product-layouts');
		showNotice(errorMessage, 'error');
		console.error('Save error:', error);
		setError(errorMessage);
		return false;
	} finally {
		setIsSaving(false);
	}
};

The saveSetups contains centralized save logic with apiFetch POST method, state management, error handling and notices, for saving userSetups to the database. In case of error the error notice will be displayed in notification and browser console.

/**
 * Handles saving a layout setup with validation.
 * If a setup with the same name exists, prompts for confirmation before overwriting.
 * Updates the userSetups state and triggers a save to the database.
 *
 * @return {Promise<void>} No value is returned.
 */
const handleSetupSave = async () => {

	if (!newSetupName) {
		alert(__('Please enter a name for the setup.', 'mosaic-product-layouts'));
		return;
	}

	const idFromNameSlug = cleanForSlug(newSetupName);

	const newSetup = {
		id: idFromNameSlug,
		name: newSetupName.trim(),
		// ... change the following attributes to yout own:
		layout: savedLayouts,
		zIndex: itemZindexes,
		...(gridSettings && { grid: gridSettings }),
		prodElSettings: productElementSettings,
		...(itemStyleOverrides && isBlockTypeGrid && { overrides: itemStyleOverrides }),
		featImgSize: featuredImageSize,
		group: grouped
	};

	const existingSetupIndex = userSetups.findIndex((setup) => setup.id === idFromNameSlug);

	if (existingSetupIndex !== -1) {
		if (!confirm(`${__('Setup with name', 'mosaic-product-layouts')} "${newSetupName}" ${__('already exists. Overwrite it?', 'mosaic-product-layouts')}`)) {
			return;
		}
	}

	const updatedSetups = existingSetupIndex !== -1
		? userSetups.map((setup, index) => index === existingSetupIndex ? newSetup : setup)
		: [...userSetups, newSetup];

	const success = await saveSetups(updatedSetups);

	if (success) {
		setUserSetups(updatedSetups);
		setNewSetupName('');
		setIsModalOpen(false);
		showNotice(
			existingSetupIndex !== -1
				? __('Layout setup updated successfully!', 'mosaic-product-layouts')
				: __('New layout setup saved successfully!', 'mosaic-product-layouts')
		);
	}
};

The procedure in handleSetupSave is following:

  • if new setup name is set and cleaned for slug – proceed
  • set new object newSetup with current attributes state and new setup id and name
  • get existing setup(s) from the database ([set]userSetups in fetchSettings)
  • check if new setup name already exist (existingSetupIndex) and confirm overwrite if it does
  • create new array of setups by adding a newSetup to the userSetups in updatedSetups
  • set success using async saveSetups if it returns true. No need for additional error handling, as the saveSetups already handles that.
    Success sets state variables for updatedSetups, clears newSetupName, isModalOpen to close the modal, and notifies user on succesful setup update.

Saved userSetups, therefore, will have the following structure:

[
  {"id":"first-setup","name":"First setup", ...block attributes for the first setup},
  {"id":"second-setup","name":"Second setup", ... block attributes for the second setup},
  ...other setups
]

Applying saved setup(s)

As seen in video above, the saved setups are being switched by clicking on buttons with previously saved setups. This is handled by the onChangeSetup function triggered by onClick on each saved setup button (rendered by mapping userSetups property). By clicking on each button saved setup will instantly apply to a block.

<ToggleGroupControl
	className="switch-button-group"
	children={
		userSetups && (userSetups.map((setup) => {
			const setupOptions = {
				itemsInactive: setup.itemsInactive ?? null,
				layout: setup.layout ?? null,
				zIndex: setup.zIndex ?? null,
				grid: setup.grid ?? null,
				prodElSettings: setup.prodElSettings ?? null,
				overrides: setup.overrides ?? null,
				featImgSize: setup.featImgSize ?? null,
				group: setup.group ?? null
			}
			return (
				<div className='button-wrap'>
					<Button
						key={setup.id}
						className='setup-button'
						variant="primary"
						size='small'
						text={setup.name}
						onClick={() => {
							setNewSetupName(setup.name);
							onChangeSetup(setupOptions);
						}}
					/>
					<Icon icon={'no'} onClick={() => handleRemoveSetup(setup.id)} />

				</div>
			)
		}))}
/>

As you can see, each button has a text prop defined by setup.name, the same property is used to set state of newSetupName.
The <ToggleGroupControl> is a part of more complex structured return JSX markup, nested inside the <BaseControl>and <Dropdown> , all WordPress block editor components. For detailed structure, I will share a complete code in a separate Gist.

The onChangeSetup function is a simple wrapper for setAttributes, a WordPress editor main state managing function

/**
 * Sets the block attributes for the selected setup.
 *
 * @param {{ layout: string, zIndex: string, prodElSettings: string, overrides: string, featImgSize: string, group: string }} setup The selected setup.
 */
const onChangeSetup = ({ layout, zIndex, grid, prodElSettings, overrides, featImgSize, group }) => {
	setAttributes({
		...overrides && { itemStyleOverrides: overrides },
		savedLayouts: layout,
		itemZindexes: zIndex,
		...grid && { gridSettings: grid },
		productElementSettings: prodElSettings,
		...featImgSize && { featuredImageSize: featImgSize },
		...group && { grouped: group }
	})
};

Deleting saved setups

Each button also has a small (X) for permanently removing the setup from database, using id property and confirmation dialog.

// REMOVING SETUPS WITH CONFIRMATION.
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [layoutToDelete, setLayoutToDelete] = useState(null);

const shouldShowDeleteConfirm = () => {
	const skipConfirm = localStorage.getItem('mpl_skip_delete_confirm');
	return skipConfirm !== 'true';
};


/**
 * Checks if delete confirmation should be shown based on localStorage setting.
 *
 * @returns {boolean} True if delete confirmation should be shown, false otherwise.
 */
const handleRemoveSetup = (idToRemove) => {
	if (shouldShowDeleteConfirm()) {
		setLayoutToDelete(idToRemove);
		setShowDeleteConfirm(true);
	} else {
		deleteLayout(idToRemove);
	}
};

/**
 * Deletes a layout setup by ID.
 *
 * @param {string} idToRemove - The ID of the layout setup to delete
 * @return {Promise<void>}
 */
const deleteLayout = async (idToRemove) => {
	const updatedUserSetups = userSetups.filter((setup) => setup.id !== idToRemove);

	const success = await saveSetups(updatedUserSetups);

	if (success) {
		setUserSetups(updatedUserSetups);
		showNotice(__('Layout setup removed successfully!', 'mosaic-product-layouts'));
	}
};

Notices

The SaveCurrentSetup component also uses another, small utility component throughout the code, to provide a visual confirmation of saving, applying, or deleting setup (slightly modified for shorted timeout):

import { dispatch } from '@wordpress/data';
import { store as noticesStore } from '@wordpress/notices';

/**
 * Shows a notice message in the WordPress admin interface.
 *
 * @param {string} message - The message to display in the notice.
 * @param {number} [timeout=2000] - Time in milliseconds before the notice is automatically dismissed.
 * @param {string} [type='success'] - The type of notice to show ('success' or 'error').
 * @return {Promise<void>} A promise that resolves when the notice is created.
 */
const showNotice = async (message, timeout = 2000, type = 'success') => {
	const { createSuccessNotice, createErrorNotice, removeNotice } = dispatch(noticesStore);

	const noticePromise = type === 'success'
		? createSuccessNotice(message, {
			type: 'snackbar',
			isDismissible: true,
		})
		: createErrorNotice(message, {
			type: 'snackbar',
			isDismissible: true,
		});

	const result = await noticePromise;
	const noticeId = result.notice.id;

	// Force dismiss after 2 seconds
	setTimeout(() => {
		removeNotice(noticeId);
	}, timeout);
};

export default showNotice;

This is it, in a nutshell – main idea an functions are covered here, not in a great detail (if you need some more detail, feel free to contact me).

For a full component code go on and visit the Gist here


Post tagged with:

Comments

Leave a Reply

Discover more from Micemade

Subscribe now to keep reading and get access to the full archive.

Continue reading