diff --git a/test-environment/getFigmaMock.js b/test-environment/getFigmaMock.js index 21be9ee..9827bd0 100644 --- a/test-environment/getFigmaMock.js +++ b/test-environment/getFigmaMock.js @@ -15,6 +15,18 @@ const getNodeWithDefaults = (jestFn, type) => ({ remove: jestFn(() => {}), }); +const getVariableBase = (jestFn) => ({ + id: randomId(), + description: "", + key: "", + remote: false, + scopes: [], + valuesByMode: {}, + remove: jestFn(), + resolveForConsumer: jestFn(), + setValueForMode: jestFn(), +}); + /** @type {(() => void) => {}} */ module.exports.getFigmaMock = (jestFn) => { return { @@ -24,6 +36,7 @@ module.exports.getFigmaMock = (jestFn) => { }), currentPage: { selection: [], + children: [], }, viewport: { scrollAndZoomIntoView: jestFn(), @@ -56,6 +69,29 @@ module.exports.getFigmaMock = (jestFn) => { widget: { useEffect: jestFn((callback) => callback()), }, + variables: { + createVariable: jestFn((name, collectionId, resolvedType) => ({ + name, + collectionId, + resolvedType, + ...getVariableBase(jestFn), + })), + createVariableAlias: jestFn(() => ({ + id: randomId(), + type: "VARIABLE_ALIAS", + })), + createVariableCollection: jestFn((name) => ({ + id: randomId(), + defaultModeId: randomId(), + name, + })), + getLocalVariableCollections: jestFn(() => []), + getLocalVariables: jestFn(() => []), + getVariableById: jestFn(() => null), + getVariableCollectionById: jestFn(() => null), + importVariableByKeyAsync: jestFn(), + setBoundVariableForPaint: jestFn(), + }, getLocalPaintStyles: jestFn(() => []), getLocalTextStyles: jestFn(() => []), getLocalEffectStyles: jestFn(() => []), diff --git a/widget-src/components/Radii/RadiiSlice.tsx b/widget-src/components/Radii/RadiiSlice.tsx index 7d95e1c..cf977ce 100644 --- a/widget-src/components/Radii/RadiiSlice.tsx +++ b/widget-src/components/Radii/RadiiSlice.tsx @@ -9,6 +9,7 @@ interface Props extends RadiiSliceItem { } export const RadiiSlice = ({ store, ...slice }: Props) => { + const inputName = slice.name.replace("Radius/", ""); return ( { fill="#000" fontFamily="Inter" fontWeight={700} - value={slice.name} - width={40} + value={inputName} + width={100} inputBehavior="truncate" inputFrameProps={{ direction: "horizontal" }} onTextEditEnd={() => {}} diff --git a/widget-src/constants.ts b/widget-src/constants.ts index 0d3dd96..ae11cf1 100644 --- a/widget-src/constants.ts +++ b/widget-src/constants.ts @@ -15,6 +15,10 @@ export const defaultColorSliceItems: ColorSliceItem[] = [ }, ]; -export enum ComponentNames { - Box = "BOX", -} +export const defaultRadiiSliceItems = [ + { name: "Radius/none", value: 0 }, + { name: "Radius/XS", value: 5 }, + { name: "Radius/S", value: 10 }, +]; + +export const MORFEO_COLLECTION_NAME = "Morfeo tokens"; diff --git a/widget-src/hooks/useInitCollection.test.ts b/widget-src/hooks/useInitCollection.test.ts new file mode 100644 index 0000000..72edb50 --- /dev/null +++ b/widget-src/hooks/useInitCollection.test.ts @@ -0,0 +1,71 @@ +import { MORFEO_COLLECTION_NAME } from "../constants"; +import { useInitCollection } from "./useInitCollection"; + +describe("useInitCollection", () => { + it("should do nothing if the collection already exist", () => { + jest + .spyOn(figma.variables, "getVariableCollectionById") + .mockReturnValue({} as any); + const mockSetCollection = jest.fn(); + + useInitCollection( + { id: "", defaultModeId: "", name: "" }, + mockSetCollection + ); + + expect(mockSetCollection).not.toBeCalled(); + expect(figma.variables.createVariableCollection).not.toBeCalled(); + }); + + it("should set the state if getVariableCollectionById doesn't find anything and there's a collection with matching name", () => { + jest + .spyOn(figma.variables, "getVariableCollectionById") + .mockReturnValue(null); + + jest.spyOn(figma.variables, "getLocalVariableCollections").mockReturnValue([ + { + id: "morfeo:collection:id", + defaultModeId: "default:mode:id", + name: MORFEO_COLLECTION_NAME, + } as any, + ]); + const mockSetCollection = jest.fn(); + + useInitCollection( + { id: "", defaultModeId: "", name: "" }, + mockSetCollection + ); + + expect(mockSetCollection).toBeCalledWith({ + id: "morfeo:collection:id", + defaultModeId: "default:mode:id", + name: MORFEO_COLLECTION_NAME, + }); + expect(figma.variables.createVariableCollection).not.toBeCalled(); + }); + + it("should set the collection if getVariableCollectionById doesn't find anything and there's NOT any collection with matching name", () => { + jest + .spyOn(figma.variables, "getVariableCollectionById") + .mockReturnValue(null); + + jest + .spyOn(figma.variables, "getLocalVariableCollections") + .mockReturnValue([]); + const mockSetCollection = jest.fn(); + + useInitCollection( + { id: "", defaultModeId: "", name: "" }, + mockSetCollection + ); + + expect(mockSetCollection).toBeCalledWith({ + id: expect.any(String), + defaultModeId: expect.any(String), + name: MORFEO_COLLECTION_NAME, + }); + expect(figma.variables.createVariableCollection).toBeCalledWith( + MORFEO_COLLECTION_NAME + ); + }); +}); diff --git a/widget-src/hooks/useInitCollection.ts b/widget-src/hooks/useInitCollection.ts new file mode 100644 index 0000000..d8e039c --- /dev/null +++ b/widget-src/hooks/useInitCollection.ts @@ -0,0 +1,31 @@ +import { MORFEO_COLLECTION_NAME } from "../constants"; +import { MorfeoCollection } from "../types"; + +const { widget } = figma; +const { useEffect } = widget; + +export const useInitCollection = ( + collection: MorfeoCollection, + setCollection: (newCollection: MorfeoCollection) => void +) => { + useEffect(() => { + if (figma.variables.getVariableCollectionById(collection.id)) { + return; + } + + const documentCollections = figma.variables.getLocalVariableCollections(); + + const morfeoCollection = documentCollections.find( + (collection) => collection.name === MORFEO_COLLECTION_NAME + ); + + if (morfeoCollection) { + const { id, name, defaultModeId } = morfeoCollection; + setCollection({ id, name, defaultModeId }); + } else { + const { id, name, defaultModeId } = + figma.variables.createVariableCollection(MORFEO_COLLECTION_NAME); + setCollection({ id, name, defaultModeId }); + } + }); +}; diff --git a/widget-src/hooks/useInitTheme.test.ts b/widget-src/hooks/useInitTheme.test.ts index e4ec771..2be5716 100644 --- a/widget-src/hooks/useInitTheme.test.ts +++ b/widget-src/hooks/useInitTheme.test.ts @@ -3,72 +3,125 @@ import { mockSyncedMap } from "../test-utils/mockSyncedMap"; import { RadiiSliceItem, ColorSliceItem, Slice } from "../types"; import { useInitTheme } from "./useInitTheme"; -describe.skip("useInitTheme", () => { - it("should init radii with defaults if the state is empty and there are no radius variables", () => { +describe("useInitTheme", () => { + it("should init radii with defaults and call createVariable if the state is empty and there are no radius variables", () => { + jest + .spyOn(figma.variables, "getVariableCollectionById") + .mockReturnValue({} as any); const mockState = mockSyncedMap(); useInitTheme({ [Slice.Radii]: mockState, [Slice.Colors]: mockSyncedMap(), + morfeoCollection: { id: "collection:id", defaultModeId: "", name: "" }, }); + // set the state expect(mockState.set).toBeCalledWith(expect.any(String), { id: expect.any(String), - name: "none", + name: "Radius/none", value: 0, - refIds: expect.any(Array), }); expect(mockState.set).toBeCalledWith(expect.any(String), { id: expect.any(String), - name: "XS", - value: 1, - refIds: expect.any(Array), + name: "Radius/XS", + value: 5, }); expect(mockState.set).toBeCalledWith(expect.any(String), { id: expect.any(String), - name: "L", + name: "Radius/S", value: 10, - refIds: expect.any(Array), }); + + // create variables + expect(figma.variables.createVariable).toBeCalledWith( + "Radius/none", + "collection:id", + "FLOAT" + ); + expect(figma.variables.createVariable).toBeCalledWith( + "Radius/XS", + "collection:id", + "FLOAT" + ); + expect(figma.variables.createVariable).toBeCalledWith( + "Radius/S", + "collection:id", + "FLOAT" + ); }); - it("should not set radii if the state is not empty and there are matching variables", () => { + it("should not set radii if the state is not empty", () => { + jest + .spyOn(figma.variables, "getVariableCollectionById") + .mockReturnValue({} as any); const mockState = mockSyncedMap({ anyId: { id: "anyId", name: "A", libStyleId: "", value: 0 }, }); useInitTheme({ [Slice.Radii]: mockState, [Slice.Colors]: mockSyncedMap(), + morfeoCollection: { id: "", defaultModeId: "", name: "" }, + }); + expect(mockState.set).not.toBeCalled(); + }); + + it("should not set radii if the morfeo collection is not found", () => { + jest + .spyOn(figma.variables, "getVariableCollectionById") + .mockReturnValue(null); + const mockState = mockSyncedMap(); + useInitTheme({ + [Slice.Radii]: mockState, + [Slice.Colors]: mockSyncedMap(), + morfeoCollection: { id: "", defaultModeId: "", name: "" }, }); expect(mockState.set).not.toBeCalled(); }); it("should use the variables to init the radii if the state is empty and radii variables are found", () => { + jest + .spyOn(figma.variables, "getVariableCollectionById") + .mockReturnValue({ variableIds: ["id:1", "id:2"] } as any); + jest.spyOn(figma.variables, "getVariableById").mockReturnValueOnce({ + id: "", + name: "A", + resolveForConsumer: () => ({ value: 10, resolvedType: "FLOAT" }), + scopes: ["CORNER_RADIUS"], + } as any); + jest.spyOn(figma.variables, "getVariableById").mockReturnValue({ + id: "", + name: "B", + resolveForConsumer: () => ({ value: 20, resolvedType: "FLOAT" }), + scopes: ["CORNER_RADIUS"], + } as any); + const mockState = mockSyncedMap(); useInitTheme({ [Slice.Radii]: mockState, [Slice.Colors]: mockSyncedMap(), + morfeoCollection: { id: "", defaultModeId: "", name: "" }, }); - expect(figma.combineAsVariants).not.toBeCalled(); expect(mockState.set).toBeCalledWith(expect.any(String), { id: expect.any(String), name: "A", value: 10, - refIds: ["1"], }); + expect(mockState.set).toBeCalledWith(expect.any(String), { id: expect.any(String), name: "B", value: 20, - refIds: ["2"], }); }); + it("should init the colors with default values and add them to the library if the library have not solid colours", () => { jest.spyOn(figma, "getLocalPaintStyles").mockReturnValue([]); const mockColorsState = mockSyncedMap(); useInitTheme({ [Slice.Radii]: mockSyncedMap(), [Slice.Colors]: mockColorsState, + morfeoCollection: { id: "", defaultModeId: "", name: "" }, }); expect(mockColorsState.set).toBeCalledWith(defaultColorSliceItems[0].id, { ...defaultColorSliceItems[0], @@ -95,6 +148,7 @@ describe.skip("useInitTheme", () => { useInitTheme({ [Slice.Radii]: mockSyncedMap(), [Slice.Colors]: mockColorsState, + morfeoCollection: { id: "", defaultModeId: "", name: "" }, }); expect(mockColorsState.set).toBeCalledWith("paint-1", { id: expect.any(String), diff --git a/widget-src/hooks/useInitTheme.ts b/widget-src/hooks/useInitTheme.ts index 3c73c41..debfcad 100644 --- a/widget-src/hooks/useInitTheme.ts +++ b/widget-src/hooks/useInitTheme.ts @@ -1,27 +1,71 @@ -import { defaultColorSliceItems } from "../constants"; +import { defaultColorSliceItems, defaultRadiiSliceItems } from "../constants"; import { RadiiSliceItem, ColorSliceItem, Slice, Store } from "../types"; import { getColorStateFromPaintStyles, rgbaToFigmaColor, } from "../utils/colorUtils"; +import { getVariablesBySlice } from "../utils/getVariablesBySlice"; const { widget } = figma; const { useEffect } = widget; -//TODO: refactor to init radii correctly (using variables) +const createNewVariable = ({ + name, + collectionId, + value, + modeId, + scope, +}: { + name: string; + value: T; + collectionId: string; + modeId: string; + scope: VariableScope; +}) => { + console.log(name); + + const newVar = figma.variables.createVariable(name, collectionId, "FLOAT"); + newVar.scopes = [scope]; + newVar.setValueForMode(modeId, value); + return { + id: newVar.id, + name: newVar.name, + value, + }; +}; export const useInitTheme = ({ [Slice.Radii]: radiiMap, [Slice.Colors]: colorMap, + morfeoCollection, }: Store) => { useEffect(() => { - if (radiiMap.size !== 0) { + const collection = figma.variables.getVariableCollectionById( + morfeoCollection.id + ); + if (radiiMap.size > 0 || !collection) { return; } let radiiSliceItems: RadiiSliceItem[] = []; let colorSliceItems: ColorSliceItem[] = []; + const existingVariables = getVariablesBySlice(collection.variableIds); + + if (existingVariables[Slice.Radii]) { + radiiSliceItems = existingVariables[Slice.Radii]; + } else { + const { defaultModeId: modeId, id: collectionId } = morfeoCollection; + radiiSliceItems = defaultRadiiSliceItems.map((defaultRadiiSliceItem) => + createNewVariable({ + ...defaultRadiiSliceItem, + collectionId, + modeId, + scope: "CORNER_RADIUS", + }) + ); + } + const localPaintStyles = figma.getLocalPaintStyles(); const existingColors = getColorStateFromPaintStyles(localPaintStyles); if (existingColors.length > 0) { diff --git a/widget-src/types.ts b/widget-src/types.ts index cbdb19c..37b210b 100644 --- a/widget-src/types.ts +++ b/widget-src/types.ts @@ -9,7 +9,6 @@ export interface ColorSliceItem extends BaseSliceItem { } export interface RadiiSliceItem extends BaseSliceItem { - libStyleId: string; value: number; } @@ -19,10 +18,16 @@ export enum Slice { Radii = "radii", Colors = "colors", } +export interface MorfeoCollection { + id: string; + name: string; + defaultModeId: string; +} export type Store = { [Slice.Radii]: SyncedMap; [Slice.Colors]: SyncedMap; + morfeoCollection: MorfeoCollection; }; export enum ActionTypes { diff --git a/widget-src/utils/downloadTheme.test.ts b/widget-src/utils/downloadTheme.test.ts index 58e4fa3..9633ea3 100644 --- a/widget-src/utils/downloadTheme.test.ts +++ b/widget-src/utils/downloadTheme.test.ts @@ -23,6 +23,7 @@ describe("downloadTheme", () => { libStyleId: "", }, }), + morfeoCollection: { id: "", defaultModeId: "", name: "" }, }); expect(figma.showUI).toBeCalled(); expect(figma.ui.postMessage).toBeCalledWith({ diff --git a/widget-src/utils/getVariablesBySlice.ts b/widget-src/utils/getVariablesBySlice.ts new file mode 100644 index 0000000..8e6a233 --- /dev/null +++ b/widget-src/utils/getVariablesBySlice.ts @@ -0,0 +1,35 @@ +import { RadiiSliceItem, Slice } from "../types"; + +const getVariableValue = (variable: Variable): T => { + const { value } = variable.resolveForConsumer(figma.currentPage.children[0]); + + return value as T; +}; + +export const getVariablesBySlice = (variableIds: string[] | undefined = []) => { + return variableIds.reduce>>( + (acc, variableId) => { + const variable = figma.variables.getVariableById(variableId); + if ( + variable && + variable.scopes.length === 1 && + variable.scopes[0] === "CORNER_RADIUS" + ) { + const value = getVariableValue(variable); + return { + ...acc, + radii: [ + ...(acc[Slice.Radii] || []), + { + id: variable.id, + name: variable.name, + value, + } satisfies RadiiSliceItem, + ], + }; + } + return acc; + }, + {} + ); +}; diff --git a/widget-src/widget.tsx b/widget-src/widget.tsx index 4320160..1f2898f 100644 --- a/widget-src/widget.tsx +++ b/widget-src/widget.tsx @@ -4,23 +4,36 @@ import { RadiiSliceItem, Slice, Store, + MorfeoCollection, } from "./types"; import { RadiiSlices } from "./components/Radii/RadiiSlices"; import { useInitTheme } from "./hooks/useInitTheme"; import { downloadTheme } from "./utils/downloadTheme"; import { ColorSlices } from "./components/Colors/ColorSlices"; +import { MORFEO_COLLECTION_NAME } from "./constants"; +import { useInitCollection } from "./hooks/useInitCollection"; const { widget } = figma; -const { useSyncedMap, AutoLayout, usePropertyMenu, useEffect } = widget; +const { useSyncedMap, AutoLayout, usePropertyMenu, useEffect, useSyncedState } = + widget; function Widget() { const radiiMap = useSyncedMap(Slice.Radii); const colorsMap = useSyncedMap(Slice.Colors); + const [morfeoCollection, setMorfeoCollection] = + useSyncedState(MORFEO_COLLECTION_NAME, { + name: MORFEO_COLLECTION_NAME, + id: "", + defaultModeId: "", + }); + const store: Store = { [Slice.Radii]: radiiMap, [Slice.Colors]: colorsMap, + morfeoCollection, }; + useInitCollection(morfeoCollection, setMorfeoCollection); useInitTheme(store); useEffect(() => {