Skip to content
Draft
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

- Fixed the block editor going blank with the Core Framework Gutenberg integration enabled. The editor script was loaded with an outdated, hard-coded dependency list that left out WordPress's `react-jsx-runtime`, so rendering the Core Framework class panel threw on an undefined `ReactJSXRuntime` and took the whole editor down. The script now loads the dependencies its build declares.
- Fixed saving from the Figma plugin to a connected WordPress site, which waited 15 seconds and then reported "Failed to sync css with Figma" and "Failed to update project" even though the changes had reached the site. The plugin handed WordPress's reply back under the wrong message type, so the editor never recognised it. Builder synchronization now also waits for the stylesheet and project to finish saving, so Bricks global variables, color swatches and the Style Manager show the values you just saved, including on projects that define variables but no classes. An empty class list no longer creates a blank Oxygen selector.
- Fixed saving from the Figma plugin to a connected WordPress site on projects with a class or variable prefix. The builder sync sent Bricks and Oxygen unprefixed class names and color variables, so they pointed at classes and custom properties that did not exist. They now carry the project's prefixes, as they already did when saving from WordPress.
- Fixed the Figma plugin's save button spinning forever when syncing with WordPress failed partway through, and saves ending with no message when WordPress rejected the stylesheet or project. A failed save now always stops the spinner and shows an error.
- Stopped disabled shades and tints from appearing as variables in the Oxygen and Bricks builders. The generated CSS already left out a color's shades or tints once you switched them off, but the builder variable dropdowns and Oxygen Classic's Alt-click variable panel still listed them, so picking one pointed at a custom property that did not exist.
- Fixed the block editor and Site Editor marking a clean post, template or template part as modified ("Review changes") as soon as it opened. The Core Framework class panel wrote an empty class name to every block before loading its saved classes, which counted as an edit. The panel now starts from the block's saved classes and only writes when a class actually changes.
- Restored the Core Framework dark and light preview toggle in the Bricks 2.4 toolbar. Bricks 2.4 replaced its single toolbar with several position-specific ones, so the toggle never appeared. It now sits last in the toolbar, comes back when Bricks rebuilds its toolbars, works from the keyboard, and follows an "auto" theme preference.
Expand Down
28 changes: 28 additions & 0 deletions packages/www/src/__tests__/renderHook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Provider, createStore } from "jotai";
import { act, createElement } from "react";
import { createRoot } from "react-dom/client";

// Minimal renderHook for hooks that read Jotai atoms. The repo has no
// @testing-library/react, and these tests only need the hook's return value.
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;

export function renderHook<T>(useHook: () => T, store = createStore()) {
const result: { current: T | undefined } = { current: undefined };

function Probe() {
result.current = useHook();
return null;
}

const container = document.createElement("div");
const root = createRoot(container);

act(() => {
root.render(createElement(Provider, { store }, createElement(Probe)));
});

return {
result: result as { current: T },
unmount: () => act(() => root.unmount()),
};
}
118 changes: 118 additions & 0 deletions packages/www/src/__tests__/usePushFigma.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { createStore } from "jotai";
import { syncCSSWithFigma, updatePresetWithFigma } from "functions/wpdb-proxy";
import { usePushFigma } from "hooks/usePushFigma";
import { toast } from "sonner";
import { figmaAtom } from "state/figmaAtom";
import { renderHook } from "./renderHook";

// Saving from the Figma plugin to a connected WordPress site. The save button
// shows a spinner until setIsLoading(false); if the builder sync threw, or
// either WordPress write came back falsy, the spinner stuck or the save ended
// with no feedback at all.

const handleFigmaPushSync = jest.fn();

jest.mock("functions/wpdb-proxy", () => ({
syncCSSWithFigma: jest.fn(),
updatePresetWithFigma: jest.fn(),
}));

jest.mock("hooks/usePushFigmaSync", () => ({
usePushFigmaSync: () => ({ handleFigmaPushSync }),
}));

jest.mock("sonner", () => ({
toast: { success: jest.fn(), error: jest.fn() },
}));

const mockedSyncCss = syncCSSWithFigma as jest.MockedFunction<typeof syncCSSWithFigma>;
const mockedUpdatePreset = updatePresetWithFigma as jest.MockedFunction<typeof updatePresetWithFigma>;

function renderPushFigma() {
const store = createStore();
store.set(figmaAtom, { apiKey: `${"p".repeat(24)}${encodeURIComponent("https://example.test")}` });
return renderHook(() => usePushFigma(), store);
}

function push(handleFigmaPush: ReturnType<typeof usePushFigma>["handleFigmaPush"]) {
const setIsLoading = jest.fn();
const done = handleFigmaPush({
newPresetData: { id: "preset" } as unknown as Preset,
setIsLoading,
cssString: ":root{}",
colorVariables: [],
});
return { setIsLoading, done };
}

beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(console, "error").mockImplementation(() => {});
});

afterEach(() => {
jest.restoreAllMocks();
});

test("reports success and clears loading when every write succeeds", async () => {
mockedSyncCss.mockResolvedValue(true);
mockedUpdatePreset.mockResolvedValue(true);
handleFigmaPushSync.mockResolvedValue(undefined);

const { result, unmount } = renderPushFigma();
const { setIsLoading, done } = push(result.current.handleFigmaPush);
await done;

expect(handleFigmaPushSync).toHaveBeenCalledTimes(1);
expect(toast.success).toHaveBeenCalledWith("Synced successfully");
expect(toast.error).not.toHaveBeenCalled();
expect(setIsLoading).toHaveBeenLastCalledWith(false);
unmount();
});

test("clears loading and reports an error when the builder sync throws", async () => {
mockedSyncCss.mockResolvedValue(true);
mockedUpdatePreset.mockResolvedValue(true);
handleFigmaPushSync.mockRejectedValue(new Error("builder sync failed"));

const { result, unmount } = renderPushFigma();
const { setIsLoading, done } = push(result.current.handleFigmaPush);
await expect(done).resolves.toBeUndefined();

expect(setIsLoading).toHaveBeenLastCalledWith(false);
expect(toast.error).toHaveBeenCalled();
expect(toast.success).not.toHaveBeenCalled();
unmount();
});

test("clears loading and reports an error when a WordPress write throws", async () => {
mockedSyncCss.mockRejectedValue(new Error("network"));
mockedUpdatePreset.mockResolvedValue(true);

const { result, unmount } = renderPushFigma();
const { setIsLoading, done } = push(result.current.handleFigmaPush);
await expect(done).resolves.toBeUndefined();

expect(setIsLoading).toHaveBeenLastCalledWith(false);
expect(toast.error).toHaveBeenCalled();
expect(handleFigmaPushSync).not.toHaveBeenCalled();
unmount();
});

test.each([
["stylesheet", false, true],
["project", true, false],
])("reports an error when the %s write returns a falsy result", async (_name, cssOk, presetOk) => {
mockedSyncCss.mockResolvedValue(cssOk);
mockedUpdatePreset.mockResolvedValue(presetOk);

const { result, unmount } = renderPushFigma();
const { setIsLoading, done } = push(result.current.handleFigmaPush);
await done;

expect(setIsLoading).toHaveBeenLastCalledWith(false);
expect(toast.error).toHaveBeenCalled();
expect(toast.success).not.toHaveBeenCalled();
expect(handleFigmaPushSync).not.toHaveBeenCalled();
unmount();
});
81 changes: 81 additions & 0 deletions packages/www/src/__tests__/usePushFigmaSync.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { updateClasses, updateColors } from "functions/wpdb-proxy";
import { usePushFigmaSync } from "hooks/usePushFigmaSync";
import { renderHook } from "./renderHook";

// Saving from the Figma plugin refreshes the connected site's Bricks/Oxygen
// class and color lists. The WordPress plugin's own save passes the project's
// class and variable prefixes to that refresh; the Figma path did not, so a
// prefixed project synced unprefixed class names and color variables.

jest.mock("state", () => {
const { atom } = jest.requireActual("jotai");
return {
joinedStylesAtom: atom([
{ selector: ".btn", properties: {} },
{ selector: ".card", properties: {} },
{ selector: ":root", properties: {} },
]),
presetPreferencesSelector: atom({ min_screen_width: 320, max_screen_width: 1400 }),
colorSystemFormDataAtom: atom({ groups: [{ colors: [{ isDarkMode: true }] }] }),
};
});

jest.mock("functions/wpdb-proxy", () => ({
updateClasses: jest.fn().mockResolvedValue(true),
updateColors: jest.fn().mockResolvedValue(true),
updateGroupedClasses: jest.fn().mockResolvedValue(true),
updatePrefixedCssFile: jest.fn().mockResolvedValue(true),
saveOxygenCssHelper: jest.fn().mockResolvedValue(true),
}));

jest.mock("functions/getClassNamesGroupedByGroups", () => ({
getClassNamesGroupedByGroups: () => ({}),
}));

jest.mock("components/modules/components/Components.editor", () => ({
getFirstSelector: (selector: string) => selector.split(",")[0].trim(),
}));

jest.mock("components/modules/colorSystem/functions/generateColorSystemVariables", () => ({
generateColorSystemVariables: jest.fn().mockResolvedValue([]),
getTransparentVariable: jest.fn(),
}));

jest.mock("cssGenerator", () => ({
cssGenerator: jest.fn().mockResolvedValue(""),
}));

const preset = {
id: "preset",
classPrefix: "cf-",
variablePrefix: "v-",
modulesData: {
COLOR_SYSTEM: {
groups: [{ colors: [{ id: "c1", name: "primary", value: "#ff0000" }] }],
},
},
} as unknown as Preset;

test("syncs builder classes with the project's class prefix", async () => {
const { result, unmount } = renderHook(() => usePushFigmaSync());

await result.current.handleFigmaPushSync({ preset, url: "https://example.test", apiKey: "key" });

expect(updateClasses).toHaveBeenCalledWith(
expect.objectContaining({ classes: "cf-btn,cf-card,cf-theme-inverted" }),
);
unmount();
});

test("syncs builder colors with the project's variable prefix", async () => {
const { result, unmount } = renderHook(() => usePushFigmaSync());

await result.current.handleFigmaPushSync({ preset, url: "https://example.test", apiKey: "key" });

expect(updateColors).toHaveBeenCalledWith(
expect.objectContaining({
colors: [expect.objectContaining({ name: "primary", raw: "var(--v-primary)" })],
}),
);
unmount();
});
52 changes: 31 additions & 21 deletions packages/www/src/hooks/usePushFigma.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,35 +23,45 @@ export function usePushFigma() {
}

const syncWp = async (props: HandleFigmaPushProps) => {
const { url } = extractApiKey(figma.apiKey);
// The save button spins until setIsLoading(false), so every exit must reach
// the finally, and every failure must say so rather than end in silence.
try {
const { url } = extractApiKey(figma.apiKey);

window.parent.postMessage(
{
type: "cf-push",
payload: {
preset: props.newPresetData,
colorVariables: props.colorVariables,
window.parent.postMessage(
{
type: "cf-push",
payload: {
preset: props.newPresetData,
colorVariables: props.colorVariables,
},
},
},
"*",
);
"*",
);

const wpApiProxyProps: WpApiProxyProps = {
apiKey: figma.apiKey,
url,
};

const wpApiProxyProps: WpApiProxyProps = {
apiKey: figma.apiKey,
url,
};
const [cssResponse, presetResponse] = await Promise.all([
syncCSSWithFigma({ cssString: props.cssString, ...wpApiProxyProps }),
updatePresetWithFigma({ newPresetData: props.newPresetData, ...wpApiProxyProps }),
]);

const [cssResponse, presetResponse] = await Promise.all([
syncCSSWithFigma({ cssString: props.cssString, ...wpApiProxyProps }),
updatePresetWithFigma({ newPresetData: props.newPresetData, ...wpApiProxyProps }),
]);
if (!(cssResponse && presetResponse)) {
toast.error("Failed to save to WordPress. Your changes were not applied.");
return;
}

if (cssResponse && presetResponse) {
await handleFigmaPushSync({ preset: props.newPresetData, ...wpApiProxyProps });
toast.success("Synced successfully");
} catch (error) {
console.error(error);
toast.error("Failed to sync with WordPress.");
} finally {
props.setIsLoading(false);
}

props.setIsLoading(false);
};

const handleFigmaPush = async (props: HandleFigmaPushProps) => {
Expand Down
9 changes: 7 additions & 2 deletions packages/www/src/hooks/usePushFigmaSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -397,8 +397,13 @@ export function usePushFigmaSync() {
const preferences = getPresetPreferences();

await Promise.allSettled([
handleClassesRefresh({ cssObjects, preset, ...wpApiProxyProps }),
handleColorsRefresh({ preset, ...wpApiProxyProps }),
handleClassesRefresh({ cssObjects, classPrefix: preset.classPrefix, preset, ...wpApiProxyProps }),
handleColorsRefresh({
classPrefix: preset.classPrefix,
variablePrefix: preset.variablePrefix,
preset,
...wpApiProxyProps,
}),
handleCssGeneratorPrefixed({
cssObjects,
classPrefix: preset.classPrefix,
Expand Down
Loading