diff --git a/.changeset/theme-refs-round-trip.md b/.changeset/theme-refs-round-trip.md new file mode 100644 index 0000000000..23ff307e2a --- /dev/null +++ b/.changeset/theme-refs-round-trip.md @@ -0,0 +1,5 @@ +--- +"@tokens-studio/figma-plugin": patch +--- + +Added support for saving variable and style references for Studio projects diff --git a/packages/tokens-studio-for-figma/src/app/store/middlewares/tokenState.ts b/packages/tokens-studio-for-figma/src/app/store/middlewares/tokenState.ts index 4dd185db14..5288ba8e8a 100644 --- a/packages/tokens-studio-for-figma/src/app/store/middlewares/tokenState.ts +++ b/packages/tokens-studio-for-figma/src/app/store/middlewares/tokenState.ts @@ -1,5 +1,6 @@ import { StorageProviderType } from '@/constants/StorageProviderType'; import { updateThemeGroupsInTokensStudio } from '@/storage/tokensStudio/updateThemeGroupsInTokensStudio'; +import { updateThemeRefsViaRestApi, snapshotThemeRefs } from '@/utils/tokensStudio/updateThemeRefsViaRestApi'; const actionsToTriggerUpdateInTokensStudio = [ 'tokenState/assignVariableIdsToCurrentTheme', @@ -14,8 +15,32 @@ const actionsToTriggerUpdateInTokensStudio = [ 'tokenState/disconnectStyleFromTheme', ]; +const refOnlyActions = [ + 'tokenState/assignVariableIdsToCurrentTheme', + 'tokenState/assignVariableIdsToTheme', + 'tokenState/assignStyleIdsToCurrentTheme', + 'tokenState/assignStyleIdsToTheme', + 'tokenState/disconnectVariableFromTheme', + 'tokenState/disconnectStyleFromTheme', + 'tokenState/renameVariableIdsToTheme', + 'tokenState/renameStyleIdsToCurrentTheme', + 'tokenState/renameVariableNamesToThemes', + 'tokenState/renameStyleNamesToCurrentTheme', + 'tokenState/removeVariableNamesFromThemes', + 'tokenState/removeStyleNamesFromThemes', + 'tokenState/removeStyleIdsFromThemes', +]; + export const tokenStateMiddleware = (store) => (next) => (action) => { const prevState = store.getState(); + + // Capture refs snapshot BEFORE the action runs + const isOAuthRefAction = prevState.uiState.api?.provider === StorageProviderType.TOKENS_STUDIO_OAUTH + && refOnlyActions.includes(action.type); + const prevThemeRefs = isOAuthRefAction + ? snapshotThemeRefs(prevState.tokenState.themes) + : undefined; + next(action); const nextState = store.getState(); @@ -30,4 +55,11 @@ export const tokenStateMiddleware = (store) => (next) => (action) => { dispatch: store.dispatch, }); } + + if (prevThemeRefs) { + updateThemeRefsViaRestApi({ + prevThemeRefs, + rootState: nextState, + }).catch((err) => console.error('[tokenStateMiddleware] REST ref sync failed:', err)); + } }; diff --git a/packages/tokens-studio-for-figma/src/storage/schemas/themeObjectSchema.ts b/packages/tokens-studio-for-figma/src/storage/schemas/themeObjectSchema.ts index fd47335373..791c83b279 100644 --- a/packages/tokens-studio-for-figma/src/storage/schemas/themeObjectSchema.ts +++ b/packages/tokens-studio-for-figma/src/storage/schemas/themeObjectSchema.ts @@ -14,4 +14,6 @@ export const themeObjectSchema = z.object({ $figmaVariableReferences: z.record(z.string()).optional(), $figmaCollectionId: z.string().optional(), $figmaModeId: z.string().optional(), + $themeGroupId: z.string().optional(), + $themeOptionId: z.string().optional(), }); diff --git a/packages/tokens-studio-for-figma/src/types/ThemeObject.ts b/packages/tokens-studio-for-figma/src/types/ThemeObject.ts index 564b079975..5f190874cd 100644 --- a/packages/tokens-studio-for-figma/src/types/ThemeObject.ts +++ b/packages/tokens-studio-for-figma/src/types/ThemeObject.ts @@ -15,4 +15,7 @@ export type ThemeObject = { $figmaCollectionId?: string; $figmaModeId?: string; $figmaVariableReferences?: Record; + // Server IDs for REST API round-trip (Studio-on-Rails) + $themeGroupId?: string; + $themeOptionId?: string; }; diff --git a/packages/tokens-studio-for-figma/src/utils/tokensStudio/fetchBranchesListRest.ts b/packages/tokens-studio-for-figma/src/utils/tokensStudio/fetchBranchesListRest.ts index c150128ea9..bd0412bc21 100644 --- a/packages/tokens-studio-for-figma/src/utils/tokensStudio/fetchBranchesListRest.ts +++ b/packages/tokens-studio-for-figma/src/utils/tokensStudio/fetchBranchesListRest.ts @@ -25,6 +25,33 @@ export function parseBranchesFromResponse(branchesData: any): RestBranch[] { return branches; } +export async function resolveChangeSetId( + authToken: string, + apiBaseUrl: string, + projectId: string, + branchName: string, +): Promise { + const headers = { + Authorization: `Bearer ${authToken}`, + 'Content-Type': 'application/json', + }; + + try { + const branchesRes = await fetch(`${apiBaseUrl}/api/v1/projects/${projectId}/branches`, { headers }); + if (!branchesRes.ok) throw new Error(`Failed to fetch branches: ${branchesRes.statusText}`); + const branchesData = await branchesRes.json(); + + const branches = parseBranchesFromResponse(branchesData); + const branch = branches.find((b) => b.name === branchName) + || branches.find((b) => b.is_default); + + return branch?.change_set_id || null; + } catch (error) { + console.error('Error resolving change_set_id:', error); + return null; + } +} + export async function fetchBranchesListRest( authToken: string, apiBaseUrl: string, diff --git a/packages/tokens-studio-for-figma/src/utils/tokensStudio/fetchProjectDataRest.ts b/packages/tokens-studio-for-figma/src/utils/tokensStudio/fetchProjectDataRest.ts index 075cc87f79..0d214af65e 100644 --- a/packages/tokens-studio-for-figma/src/utils/tokensStudio/fetchProjectDataRest.ts +++ b/packages/tokens-studio-for-figma/src/utils/tokensStudio/fetchProjectDataRest.ts @@ -20,7 +20,6 @@ interface RestThemeOption { theme_group_id: string; selected_token_sets: Record; figmaStyleReferences?: Record; - figmaVariableReferences?: Record; figmaCollectionId?: string; figmaModeId?: string; } @@ -219,7 +218,6 @@ export async function fetchProjectDataRest( theme_group_id: item.relationships?.theme_group?.data?.id || '', selected_token_sets: item.attributes?.selected_token_sets || {}, figmaStyleReferences: item.attributes?.figma_style_references, - figmaVariableReferences: item.attributes?.figma_variable_references, figmaCollectionId: item.attributes?.figma_collection_id, figmaModeId: item.attributes?.figma_mode_id, }); @@ -232,12 +230,13 @@ export async function fetchProjectDataRest( optionsByGroupId[opt.theme_group_id].push(opt); }); - // Parse theme groups + // Parse theme groups — variable refs live at the group level const themes: ThemeObjectsList = []; if (themeGroupsData.data && Array.isArray(themeGroupsData.data)) { themeGroupsData.data.forEach((group: any) => { const { id } = group; const name = group.attributes?.name || id; + const groupVariableRefs: Record | undefined = group.attributes?.figma_variable_references; const options = optionsByGroupId[id] || []; options.forEach((opt) => { @@ -255,10 +254,12 @@ export async function fetchProjectDataRest( name: opt.name, group: name, selectedTokenSets: selectedTokenSetsByName as any, + $themeGroupId: id, + $themeOptionId: opt.id, }; if (opt.figmaStyleReferences !== undefined) themeObj.$figmaStyleReferences = opt.figmaStyleReferences; - if (opt.figmaVariableReferences !== undefined) themeObj.$figmaVariableReferences = opt.figmaVariableReferences; + if (groupVariableRefs !== undefined) themeObj.$figmaVariableReferences = { ...groupVariableRefs }; if (opt.figmaCollectionId !== undefined) themeObj.$figmaCollectionId = opt.figmaCollectionId; if (opt.figmaModeId !== undefined) themeObj.$figmaModeId = opt.figmaModeId; diff --git a/packages/tokens-studio-for-figma/src/utils/tokensStudio/pushThemeRefsRest.test.ts b/packages/tokens-studio-for-figma/src/utils/tokensStudio/pushThemeRefsRest.test.ts new file mode 100644 index 0000000000..ac42f572a9 --- /dev/null +++ b/packages/tokens-studio-for-figma/src/utils/tokensStudio/pushThemeRefsRest.test.ts @@ -0,0 +1,102 @@ +import { patchThemeGroupVariableRefs, patchThemeOptionStyleRefs } from './pushThemeRefsRest'; + +describe('pushThemeRefsRest', () => { + const mockFetch = jest.fn(); + const originalFetch = global.fetch; + + beforeEach(() => { + global.fetch = mockFetch; + mockFetch.mockReset(); + }); + + afterAll(() => { + global.fetch = originalFetch; + }); + + const authToken = 'test-token'; + const apiBaseUrl = 'https://studio.example.com'; + const projectId = 'proj-123'; + const changeSetId = 'cs-456'; + + describe('patchThemeGroupVariableRefs', () => { + it('should skip PATCH when refs is undefined (preserve semantics)', async () => { + await patchThemeGroupVariableRefs(authToken, apiBaseUrl, projectId, changeSetId, 'group-1', undefined); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should PATCH with empty object to clear refs', async () => { + mockFetch.mockResolvedValue({ ok: true }); + + await patchThemeGroupVariableRefs(authToken, apiBaseUrl, projectId, changeSetId, 'group-1', {}); + + expect(mockFetch).toHaveBeenCalledWith( + `${apiBaseUrl}/api/v1/projects/${projectId}/theme_groups/group-1?change_set_id=${encodeURIComponent(changeSetId)}`, + expect.objectContaining({ + method: 'PATCH', + headers: expect.objectContaining({ + Authorization: `Bearer ${authToken}`, + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ theme_group: { figma_variable_references: {} } }), + }), + ); + }); + + it('should PATCH with refs map', async () => { + mockFetch.mockResolvedValue({ ok: true }); + const refs = { 'color.brand.500': 'VariableID:123:456' }; + + await patchThemeGroupVariableRefs(authToken, apiBaseUrl, projectId, changeSetId, 'group-1', refs); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.theme_group.figma_variable_references).toEqual(refs); + }); + + it('should throw on non-OK response', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 422, text: () => Promise.resolve('Unprocessable') }); + + await expect( + patchThemeGroupVariableRefs(authToken, apiBaseUrl, projectId, changeSetId, 'group-1', {}), + ).rejects.toThrow('Failed to PATCH theme_group variable refs (422)'); + }); + }); + + describe('patchThemeOptionStyleRefs', () => { + it('should skip PATCH when refs is undefined (preserve semantics)', async () => { + await patchThemeOptionStyleRefs(authToken, apiBaseUrl, projectId, changeSetId, 'opt-1', undefined); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should PATCH with empty object to clear refs', async () => { + mockFetch.mockResolvedValue({ ok: true }); + + await patchThemeOptionStyleRefs(authToken, apiBaseUrl, projectId, changeSetId, 'opt-1', {}); + + expect(mockFetch).toHaveBeenCalledWith( + `${apiBaseUrl}/api/v1/projects/${projectId}/theme_options/opt-1?change_set_id=${encodeURIComponent(changeSetId)}`, + expect.objectContaining({ + method: 'PATCH', + body: JSON.stringify({ theme_option: { figma_style_references: {} } }), + }), + ); + }); + + it('should PATCH with style refs verbatim (S: prefix included)', async () => { + mockFetch.mockResolvedValue({ ok: true }); + const refs = { 'hint.text': 'S:81e4c301abc,' }; + + await patchThemeOptionStyleRefs(authToken, apiBaseUrl, projectId, changeSetId, 'opt-1', refs); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.theme_option.figma_style_references).toEqual(refs); + }); + + it('should throw on non-OK response', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 500, text: () => Promise.resolve('Internal') }); + + await expect( + patchThemeOptionStyleRefs(authToken, apiBaseUrl, projectId, changeSetId, 'opt-1', {}), + ).rejects.toThrow('Failed to PATCH theme_option style refs (500)'); + }); + }); +}); diff --git a/packages/tokens-studio-for-figma/src/utils/tokensStudio/pushThemeRefsRest.ts b/packages/tokens-studio-for-figma/src/utils/tokensStudio/pushThemeRefsRest.ts new file mode 100644 index 0000000000..5806ee2d03 --- /dev/null +++ b/packages/tokens-studio-for-figma/src/utils/tokensStudio/pushThemeRefsRest.ts @@ -0,0 +1,73 @@ +/** + * REST PATCH helpers for syncing $figmaVariableReferences and $figmaStyleReferences + * with Studio-on-Rails theme_groups and theme_options endpoints. + * + * Semantics: + * - undefined → omit key from payload (preserve existing refs on server) + * - {} → explicitly clear all refs + * - never send null + */ + +export async function patchThemeGroupVariableRefs( + authToken: string, + apiBaseUrl: string, + projectId: string, + changeSetId: string, + themeGroupId: string, + refs: Record | undefined, +): Promise { + if (refs === undefined) return; + + const url = `${apiBaseUrl}/api/v1/projects/${projectId}/theme_groups/${themeGroupId}?change_set_id=${encodeURIComponent(changeSetId)}`; + const body = { + theme_group: { + figma_variable_references: refs, + }, + }; + + const res = await fetch(url, { + method: 'PATCH', + headers: { + Authorization: `Bearer ${authToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const errorText = await res.text().catch(() => ''); + throw new Error(`Failed to PATCH theme_group variable refs (${res.status}): ${errorText}`); + } +} + +export async function patchThemeOptionStyleRefs( + authToken: string, + apiBaseUrl: string, + projectId: string, + changeSetId: string, + themeOptionId: string, + refs: Record | undefined, +): Promise { + if (refs === undefined) return; + + const url = `${apiBaseUrl}/api/v1/projects/${projectId}/theme_options/${themeOptionId}?change_set_id=${encodeURIComponent(changeSetId)}`; + const body = { + theme_option: { + figma_style_references: refs, + }, + }; + + const res = await fetch(url, { + method: 'PATCH', + headers: { + Authorization: `Bearer ${authToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const errorText = await res.text().catch(() => ''); + throw new Error(`Failed to PATCH theme_option style refs (${res.status}): ${errorText}`); + } +} diff --git a/packages/tokens-studio-for-figma/src/utils/tokensStudio/updateThemeRefsViaRestApi.test.ts b/packages/tokens-studio-for-figma/src/utils/tokensStudio/updateThemeRefsViaRestApi.test.ts new file mode 100644 index 0000000000..9e04bae5e6 --- /dev/null +++ b/packages/tokens-studio-for-figma/src/utils/tokensStudio/updateThemeRefsViaRestApi.test.ts @@ -0,0 +1,224 @@ +import { updateThemeRefsViaRestApi, snapshotThemeRefs } from './updateThemeRefsViaRestApi'; + +// Mock dependencies +jest.mock('@/app/store/useAuthStore', () => ({ + useAuthStore: { + getState: jest.fn(), + }, +})); + +jest.mock('@/app/services/OAuthService', () => ({ + OAuthService: { + getApiBaseUrl: jest.fn(() => 'https://api.studio.example.com'), + }, +})); + +jest.mock('@/constants/TokensStudio', () => ({ + TOKENS_STUDIO_APP_URL: 'https://studio.example.com', +})); + +jest.mock('./fetchBranchesListRest', () => ({ + resolveChangeSetId: jest.fn(), +})); + +jest.mock('./pushThemeRefsRest', () => ({ + patchThemeGroupVariableRefs: jest.fn(), + patchThemeOptionStyleRefs: jest.fn(), +})); + +import { useAuthStore } from '@/app/store/useAuthStore'; +import { resolveChangeSetId } from './fetchBranchesListRest'; +import { patchThemeGroupVariableRefs, patchThemeOptionStyleRefs } from './pushThemeRefsRest'; + +describe('updateThemeRefsViaRestApi', () => { + const mockGetState = useAuthStore.getState as jest.Mock; + const mockResolveChangeSetId = resolveChangeSetId as jest.Mock; + const mockPatchGroup = patchThemeGroupVariableRefs as jest.Mock; + const mockPatchOption = patchThemeOptionStyleRefs as jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + mockGetState.mockReturnValue({ + oauthTokens: { accessToken: 'test-token' }, + }); + mockResolveChangeSetId.mockResolvedValue('cs-789'); + mockPatchGroup.mockResolvedValue(undefined); + mockPatchOption.mockResolvedValue(undefined); + }); + + const makeRootState = (themes: any[], api?: any) => ({ + tokenState: { themes }, + uiState: { api: api || { provider: 'tokensstudio-oauth', id: 'proj-1', branch: 'main' } }, + }); + + it('should not call PATCH when no refs changed', async () => { + const themes = [{ + id: 'opt-1', + name: 'Light', + $themeGroupId: 'grp-1', + $themeOptionId: 'opt-1', + $figmaVariableReferences: { 'color.bg': 'VarID:1:1' }, + $figmaStyleReferences: { 'text.body': 'S:abc,' }, + }]; + + await updateThemeRefsViaRestApi({ + prevThemeRefs: snapshotThemeRefs(themes as any), + rootState: makeRootState(themes) as any, + }); + + expect(mockPatchGroup).not.toHaveBeenCalled(); + expect(mockPatchOption).not.toHaveBeenCalled(); + }); + + it('should PATCH variable refs when they change (group-level)', async () => { + const prevThemes = [{ + id: 'opt-1', + name: 'Light', + $themeGroupId: 'grp-1', + $themeOptionId: 'opt-1', + $figmaVariableReferences: { 'color.bg': 'VarID:1:1' }, + }]; + const nextThemes = [{ + id: 'opt-1', + name: 'Light', + $themeGroupId: 'grp-1', + $themeOptionId: 'opt-1', + $figmaVariableReferences: { 'color.bg': 'VarID:1:1', 'color.fg': 'VarID:2:2' }, + }]; + + await updateThemeRefsViaRestApi({ + prevThemeRefs: snapshotThemeRefs(prevThemes as any), + rootState: makeRootState(nextThemes) as any, + }); + + expect(mockPatchGroup).toHaveBeenCalledWith( + 'test-token', + 'https://api.studio.example.com', + 'proj-1', + 'cs-789', + 'grp-1', + { 'color.bg': 'VarID:1:1', 'color.fg': 'VarID:2:2' }, + ); + }); + + it('should PATCH style refs when they change (option-level)', async () => { + const prevThemes = [{ + id: 'opt-1', + name: 'Light', + $themeGroupId: 'grp-1', + $themeOptionId: 'opt-1', + $figmaStyleReferences: { 'text.body': 'S:abc,' }, + }]; + const nextThemes = [{ + id: 'opt-1', + name: 'Light', + $themeGroupId: 'grp-1', + $themeOptionId: 'opt-1', + $figmaStyleReferences: { 'text.body': 'S:abc,', 'text.heading': 'S:def,' }, + }]; + + await updateThemeRefsViaRestApi({ + prevThemeRefs: snapshotThemeRefs(prevThemes as any), + rootState: makeRootState(nextThemes) as any, + }); + + expect(mockPatchOption).toHaveBeenCalledWith( + 'test-token', + 'https://api.studio.example.com', + 'proj-1', + 'cs-789', + 'opt-1', + { 'text.body': 'S:abc,', 'text.heading': 'S:def,' }, + ); + }); + + it('should only PATCH group once even if multiple options share it', async () => { + const prevThemes = [ + { id: 'opt-1', name: 'Light', $themeGroupId: 'grp-1', $themeOptionId: 'opt-1', $figmaVariableReferences: {} }, + { id: 'opt-2', name: 'Dark', $themeGroupId: 'grp-1', $themeOptionId: 'opt-2', $figmaVariableReferences: {} }, + ]; + const nextThemes = [ + { id: 'opt-1', name: 'Light', $themeGroupId: 'grp-1', $themeOptionId: 'opt-1', $figmaVariableReferences: { 'x': 'y' } }, + { id: 'opt-2', name: 'Dark', $themeGroupId: 'grp-1', $themeOptionId: 'opt-2', $figmaVariableReferences: { 'x': 'y' } }, + ]; + + await updateThemeRefsViaRestApi({ + prevThemeRefs: snapshotThemeRefs(prevThemes as any), + rootState: makeRootState(nextThemes) as any, + }); + + expect(mockPatchGroup).toHaveBeenCalledTimes(1); + }); + + it('should abort if no OAuth token', async () => { + mockGetState.mockReturnValue({ oauthTokens: null }); + + await updateThemeRefsViaRestApi({ + prevThemeRefs: new Map(), + rootState: makeRootState([]) as any, + }); + + expect(mockResolveChangeSetId).not.toHaveBeenCalled(); + }); + + it('should abort if changeSetId cannot be resolved', async () => { + const spy = jest.spyOn(console, 'error').mockImplementation(); + mockResolveChangeSetId.mockResolvedValue(null); + + const themes = [{ + id: 'opt-1', $themeGroupId: 'grp-1', $themeOptionId: 'opt-1', + $figmaVariableReferences: { a: 'b' }, + }]; + + await updateThemeRefsViaRestApi({ + prevThemeRefs: snapshotThemeRefs([{ id: 'opt-1', $themeGroupId: 'grp-1', $themeOptionId: 'opt-1' }] as any), + rootState: makeRootState(themes) as any, + }); + + expect(mockPatchGroup).not.toHaveBeenCalled(); + expect(mockPatchOption).not.toHaveBeenCalled(); + spy.mockRestore(); + }); + + it('should correctly detect changes even when refs are mutated in-place by utilizing snapshots', async () => { + // Simulate what the current reducers do: mutate refs in-place + const sharedVarRefs = { 'color.old': 'VarID:1' }; + + const themesBeforeMutation = [{ + id: 'light', + $themeGroupId: 'grp-1', + $themeOptionId: 'opt-1', + $figmaVariableReferences: sharedVarRefs, + }]; + + // Capture snapshot BEFORE the mutation + const prevThemeRefs = snapshotThemeRefs(themesBeforeMutation as any); + + // Now mutate in-place (simulating the reducer) + sharedVarRefs['color.new'] = 'VarID:1'; + delete sharedVarRefs['color.old']; + + // nextState themes point to the mutated sharedVarRefs object + const nextThemes = [{ + id: 'light', + $themeGroupId: 'grp-1', + $themeOptionId: 'opt-1', + $figmaVariableReferences: sharedVarRefs, + }]; + + await updateThemeRefsViaRestApi({ + prevThemeRefs, + rootState: makeRootState(nextThemes) as any, + }); + + // The diff should succeed because it compares against the snapshot + expect(mockPatchGroup).toHaveBeenCalledWith( + 'test-token', + 'https://api.studio.example.com', + 'proj-1', + 'cs-789', + 'grp-1', + { 'color.new': 'VarID:1' }, + ); + }); +}); diff --git a/packages/tokens-studio-for-figma/src/utils/tokensStudio/updateThemeRefsViaRestApi.ts b/packages/tokens-studio-for-figma/src/utils/tokensStudio/updateThemeRefsViaRestApi.ts new file mode 100644 index 0000000000..066633aabe --- /dev/null +++ b/packages/tokens-studio-for-figma/src/utils/tokensStudio/updateThemeRefsViaRestApi.ts @@ -0,0 +1,136 @@ +import { RematchRootState } from '@rematch/core'; +import { RootModel } from '@/types/RootModel'; +import { ThemeObjectsList } from '@/types'; +import { TokensStudioOAuthStorageType } from '@/types/StorageType'; +import { useAuthStore } from '@/app/store/useAuthStore'; +import { OAuthService } from '@/app/services/OAuthService'; +import { TOKENS_STUDIO_APP_URL } from '@/constants/TokensStudio'; +import { resolveChangeSetId } from './fetchBranchesListRest'; +import { patchThemeGroupVariableRefs, patchThemeOptionStyleRefs } from './pushThemeRefsRest'; + +/** Pre-action snapshot of each theme's refs, keyed by theme id. */ +export type ThemeRefsSnapshot = Map; + styleRefs?: Record; +}>; + +interface UpdateThemeRefsPayload { + /** Cloned refs captured *before* the reducer ran (immune to in-place mutation). */ + prevThemeRefs: ThemeRefsSnapshot; + rootState: RematchRootState>; +} + +/** + * Shallow-clone every theme's ref maps to create a snapshot that is immune + * to in-place mutations performed by some reducers. + */ +export function snapshotThemeRefs(themes: ThemeObjectsList): ThemeRefsSnapshot { + const snapshot: ThemeRefsSnapshot = new Map(); + themes.forEach((theme) => { + snapshot.set(theme.id, { + varRefs: theme.$figmaVariableReferences ? { ...theme.$figmaVariableReferences } : undefined, + styleRefs: theme.$figmaStyleReferences ? { ...theme.$figmaStyleReferences } : undefined, + }); + }); + return snapshot; +} + +function shallowEqual( + a: Record | null | undefined, + b: Record | null | undefined, +): boolean { + if (a === b) return true; + if (!a || !b) return false; + const keysA = Object.keys(a); + const keysB = Object.keys(b); + if (keysA.length !== keysB.length) return false; + return keysA.every((k) => a[k] === b[k]); +} + +/** + * Diffs prev vs next themes and PATCHes changed refs to Studio-on-Rails. + * Only called for ref-mutation actions on the OAuth provider path. + */ +export async function updateThemeRefsViaRestApi({ + prevThemeRefs, + rootState, +}: UpdateThemeRefsPayload): Promise { + const { oauthTokens } = useAuthStore.getState(); + if (!oauthTokens?.accessToken) return; + + const context = rootState.uiState.api; + if (!context || !('id' in context)) return; + + const oauthContext = context as TokensStudioOAuthStorageType; + const projectId = oauthContext.id; + const branchName = oauthContext.branch || 'main'; + + const apiBaseUrl = OAuthService.getApiBaseUrl(TOKENS_STUDIO_APP_URL); + + const changeSetId = await resolveChangeSetId( + oauthTokens.accessToken, + apiBaseUrl, + projectId, + branchName, + ); + if (!changeSetId) { + console.error('[updateThemeRefsViaRestApi] Could not resolve change_set_id, aborting write'); + return; + } + + const nextThemes: ThemeObjectsList = rootState.tokenState.themes; + + // Track which groups have already been patched (variable refs are group-level) + const patchedGroups = new Set(); + + for (const nextTheme of nextThemes) { + const prev = prevThemeRefs.get(nextTheme.id); + if (prev) { + const themeGroupId = nextTheme.$themeGroupId; + const themeOptionId = nextTheme.$themeOptionId; + + // Variable refs — group-level, only PATCH once per group + if (themeGroupId && !patchedGroups.has(themeGroupId)) { + const prevVarRefs = prev.varRefs; + const nextVarRefs = nextTheme.$figmaVariableReferences; + + if (!shallowEqual(prevVarRefs, nextVarRefs)) { + patchedGroups.add(themeGroupId); + try { + await patchThemeGroupVariableRefs( + oauthTokens.accessToken, + apiBaseUrl, + projectId, + changeSetId, + themeGroupId, + nextVarRefs ?? {}, + ); + } catch (err) { + console.error('[updateThemeRefsViaRestApi] Failed to patch variable refs:', err); + } + } + } + + // Style refs — option-level + if (themeOptionId) { + const prevStyleRefs = prev.styleRefs; + const nextStyleRefs = nextTheme.$figmaStyleReferences; + + if (!shallowEqual(prevStyleRefs, nextStyleRefs)) { + try { + await patchThemeOptionStyleRefs( + oauthTokens.accessToken, + apiBaseUrl, + projectId, + changeSetId, + themeOptionId, + nextStyleRefs ?? {}, + ); + } catch (err) { + console.error('[updateThemeRefsViaRestApi] Failed to patch style refs:', err); + } + } + } + } + } +}