From e09ef2459b9e8bcda14062053a1ceda70c7f3047 Mon Sep 17 00:00:00 2001 From: Mighty Knight <102773603+MightyDevKnight@users.noreply.github.com> Date: Mon, 20 Jun 2022 07:15:22 +0000 Subject: [PATCH 1/7] fix issue with dulicating token-set-creates-a-new-blank-token-set (#906) * fix issue with dulicating token-set-creates-a-new-blank-token-set * fix issue with blank token-set * remove unnecessary variable * use updateTokenSetsInState * update to using updateTokenSetsInstate --- src/app/components/TokenSetSelector.tsx | 2 +- src/app/store/models/tokenState.tsx | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/app/components/TokenSetSelector.tsx b/src/app/components/TokenSetSelector.tsx index 6bdaf26bc..952e522d6 100644 --- a/src/app/components/TokenSetSelector.tsx +++ b/src/app/components/TokenSetSelector.tsx @@ -54,7 +54,7 @@ export default function TokenSetSelector() { React.useEffect(() => { setShowNewTokenSetFields(false); - handleNewTokenSetNameChange(''); + handleNewTokenSetNameChange(tokenSetMarkedForChange); }, [tokens]); const handleNewTokenSetSubmit = React.useCallback((e: React.FormEvent) => { diff --git a/src/app/store/models/tokenState.tsx b/src/app/store/models/tokenState.tsx index b7b16e947..cf76ea9de 100644 --- a/src/app/store/models/tokenState.tsx +++ b/src/app/store/models/tokenState.tsx @@ -137,7 +137,8 @@ export const tokenState = createModel()({ } const newName = `${name}_Copy`; - return updateTokenSetsInState(state, null, [newName]); + + return updateTokenSetsInState(state, null, [newName, state.tokens[name]]); }, deleteTokenSet: (state, name: string) => updateTokenSetsInState( state, From bfc1029b136b9b4f4e01fc0ec070aee018597e68 Mon Sep 17 00:00:00 2001 From: Liam Martens Date: Mon, 20 Jun 2022 03:33:37 -0400 Subject: [PATCH 2/7] feat: coverage for adotokenstorage (#900) * feat: Added coverage for ADO Token Storage * chore: Typo Co-authored-by: Jan Six Co-authored-by: Jan Six --- src/storage/ADOTokenStorage.ts | 13 +- src/storage/__tests__/ADOTokenStorage.test.ts | 480 ++++++++++++++++++ 2 files changed, 489 insertions(+), 4 deletions(-) create mode 100644 src/storage/__tests__/ADOTokenStorage.test.ts diff --git a/src/storage/ADOTokenStorage.ts b/src/storage/ADOTokenStorage.ts index 6d365b3cd..9f6fe3114 100644 --- a/src/storage/ADOTokenStorage.ts +++ b/src/storage/ADOTokenStorage.ts @@ -52,7 +52,10 @@ export class ADOTokenStorage extends GitTokenStorage { secret, id: repositoryId, name: projectId, - }: Extract) { + }: Pick< + Extract, + 'baseUrl' | 'secret' | 'id' | 'name' + >) { super(secret, '', repositoryId, orgUrl); this.orgUrl = orgUrl; this.projectId = projectId; @@ -152,7 +155,7 @@ export class ADOTokenStorage extends GitTokenStorage { public async createBranch(branch: string, source: string = this.branch): Promise { const { value } = await this.getRefs(`heads/${source}`); - if (value[0].objectId) { + if (value[0]?.objectId) { const response = await this.postRefs({ name: `refs/heads/${branch}`, oldObjectId: '0000000000000000000000000000000000000000', @@ -186,6 +189,8 @@ export class ADOTokenStorage extends GitTokenStorage { private async getItem(path: string = this.path): Promise { try { + // @README setting includeContent to true + // enables downloading the content instead const response = await this.fetchGit({ ...this.itemsDefault(), params: { @@ -280,13 +285,13 @@ export class ADOTokenStorage extends GitTokenStorage { return [ { type: 'themes', - path: `${this.path}/$themes.json`, + path: this.path, data: Array.isArray($themes) ? $themes : [], }, ...Object.entries(data).map>(([name, tokenSet]) => ({ name, type: 'tokenSet', - path: `${this.path}/${name}.json`, + path: this.path, data: !Array.isArray(tokenSet) ? tokenSet : {}, })), ]; diff --git a/src/storage/__tests__/ADOTokenStorage.test.ts b/src/storage/__tests__/ADOTokenStorage.test.ts new file mode 100644 index 000000000..6b530b749 --- /dev/null +++ b/src/storage/__tests__/ADOTokenStorage.test.ts @@ -0,0 +1,480 @@ +import { ADOTokenStorage } from '../ADOTokenStorage'; +import { mockFetch } from '../../../tests/__mocks__/fetchMock'; +import { TokenTypes } from '@/constants/TokenTypes'; +import { TokenSetStatus } from '@/constants/TokenSetStatus'; + +describe('ADOTokenStorage', () => { + const baseUrl = 'https://brandcode.azure.com'; + const repositoryId = 'brandcode'; + const projectId = 'tokens'; + const secret = 'secret'; + const storageProvider = new ADOTokenStorage({ + baseUrl, + secret, + id: repositoryId, + name: projectId, + }); + + beforeEach(() => { + storageProvider.disableMultiFile(); + }); + + it('can fetch branches', async () => { + mockFetch.mockImplementationOnce(() => Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + count: 2, + value: [ + { name: 'refs/heads/feat/foo' }, + { name: 'refs/heads/feat/bar' }, + ], + }), + })); + + const branches = await storageProvider.fetchBranches(); + expect(branches).toEqual(['feat/foo', 'feat/bar']); + }); + + it('should try to create a branch', async () => { + mockFetch.mockImplementationOnce(() => Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + count: 1, + value: [ + { + name: 'refs/heads/main', + objectId: 'main', + }, + ], + }), + })).mockImplementationOnce(() => Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + count: 1, + value: [ + { + name: 'refs/heads/feat/foo', + success: true, + }, + ], + }), + })); + + const result = await storageProvider.createBranch('feat/foo'); + expect(mockFetch).toBeCalledTimes(2); + expect(mockFetch).toHaveBeenNthCalledWith( + 2, + `${baseUrl}/${projectId}/_apis/git/repositories/${repositoryId}/refs?api-version=6.0`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Basic ${btoa(`:${secret}`)}`, + }, + body: JSON.stringify([{ + name: 'refs/heads/feat/foo', + oldObjectId: '0000000000000000000000000000000000000000', + newObjectId: 'main', + }]), + }, + ); + expect(result).toBe(true); + + mockFetch.mockClear(); + }); + + it('should return false if a branch could not be created', async () => { + mockFetch.mockImplementation((input: string, init?: RequestInit) => { + if (init?.method === 'GET') { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + count: 1, + value: [ + ], + }), + }); + } + return Promise.resolve({ + ok: false, + }); + }); + + const result = await storageProvider.createBranch('feat/foo'); + expect(result).toBe(false); + + mockFetch.mockClear(); + }); + + it('should return `true` for canWrite if the refs response is ok', async () => { + mockFetch.mockImplementationOnce(() => Promise.resolve({ + status: 200, + })); + const canWrite = await storageProvider.canWrite(); + expect(canWrite).toBe(true); + }); + + it('should return `false` for canWrite if the refs response not ok', async () => { + mockFetch.mockImplementationOnce(() => Promise.resolve({ + status: 400, + })); + const canWrite = await storageProvider.canWrite(); + expect(canWrite).toBe(false); + }); + + it('can read from Git in single file format', async () => { + mockFetch.mockImplementationOnce(() => Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + global: { + colors: { + red: { + type: TokenTypes.COLOR, + value: '#ff0000', + }, + }, + }, + $themes: [ + { + id: 'dark', + name: 'Dark', + selectedTokenSets: {}, + }, + ], + }), + })); + + storageProvider.changePath('data/tokens.json'); + const result = await storageProvider.read(); + expect(result[0]).toEqual({ + type: 'themes', + path: 'data/tokens.json', + data: [ + { + id: 'dark', + name: 'Dark', + selectedTokenSets: {}, + }, + ], + }); + expect(result[1]).toEqual({ + type: 'tokenSet', + name: 'global', + path: 'data/tokens.json', + data: { + colors: { + red: { + type: TokenTypes.COLOR, + value: '#ff0000', + }, + }, + }, + }); + }); + + it('can read from Git in a multifile format', async () => { + mockFetch.mockImplementationOnce(() => Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + count: 2, + value: [ + { path: 'multifile/$themes.json' }, + { path: 'multifile/global.json' }, + ], + }), + })).mockImplementationOnce(() => Promise.resolve({ + ok: true, + json: () => Promise.resolve([ + { + id: 'light', + name: 'Light', + selectedTokenSets: {}, + }, + ]), + })).mockImplementationOnce(() => Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + red: { + type: TokenTypes.COLOR, + name: 'red', + value: '#ff00000', + }, + }), + })); + + storageProvider.enableMultiFile(); + storageProvider.changePath('multifile'); + + const result = await storageProvider.read(); + expect(result[0]).toEqual({ + type: 'themes', + path: 'multifile/$themes.json', + data: [{ + id: 'light', + name: 'Light', + selectedTokenSets: {}, + }], + }); + expect(result[1]).toEqual({ + type: 'tokenSet', + name: 'global', + path: 'multifile/global.json', + data: { + red: { + type: TokenTypes.COLOR, + name: 'red', + value: '#ff00000', + }, + }, + }); + }); + + it('should be able to write', async () => { + mockFetch.mockImplementationOnce(() => Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + count: 1, + value: [ + { name: 'refs/heads/main' }, + ], + }), + })).mockImplementationOnce(() => Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + count: 1, + value: [ + { + name: 'refs/heads/main', + objectId: 'main', + }, + ], + }), + })).mockImplementationOnce(() => Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + count: 1, + value: [ + { path: '/data/tokens.json' }, + ], + }), + })).mockImplementationOnce(() => Promise.resolve({ + ok: true, + })); + + storageProvider.selectBranch('main'); + storageProvider.changePath('data/tokens.json'); + expect(await storageProvider.write([ + { + type: 'metadata', + path: 'metadata.json', + data: { + commitMessage: 'Initial commit', + }, + }, + { + type: 'themes', + path: '$themes.json', + data: [ + { + id: 'light', + name: 'Light', + selectedTokenSets: { + global: TokenSetStatus.ENABLED, + }, + }, + ], + }, + { + type: 'tokenSet', + name: 'global', + path: 'global.json', + data: { + red: { + type: TokenTypes.COLOR, + name: 'red', + value: '#ff0000', + }, + }, + }, + ])).toBe(true); + expect(mockFetch).toHaveBeenNthCalledWith( + 4, + `${baseUrl}/${projectId}/_apis/git/repositories/${repositoryId}/pushes?api-version=6.0`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Basic ${btoa(`:${secret}`)}`, + }, + body: JSON.stringify({ + refUpdates: [ + { + name: 'refs/heads/main', + oldObjectId: 'main', + }, + ], + commits: [ + { + comment: 'Initial commit', + changes: [ + { + changeType: 'edit', + item: { path: '/data/tokens.json' }, + newContent: { + content: JSON.stringify({ + $themes: [ + { + id: 'light', + name: 'Light', + selectedTokenSets: { + global: TokenSetStatus.ENABLED, + }, + }, + ], + global: { + red: { + type: TokenTypes.COLOR, + name: 'red', + value: '#ff0000', + }, + }, + }, null, 2), + contentType: 'rawtext', + }, + }, + ], + }, + ], + }), + }, + ); + }); + + it('should be able to write in a multi file set-up', async () => { + mockFetch.mockImplementationOnce(() => Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + count: 1, + value: [ + { name: 'refs/heads/main' }, + ], + }), + })).mockImplementationOnce(() => Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + count: 1, + value: [ + { + name: 'refs/heads/main', + objectId: 'main', + }, + ], + }), + })).mockImplementationOnce(() => Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + count: 1, + value: [ + { path: '/multifile/global.json' }, + { path: '/multifile/$themes.json' }, + ], + }), + })) + .mockImplementationOnce(() => Promise.resolve({ ok: true })) + .mockImplementationOnce(() => Promise.resolve({ ok: true })); + + storageProvider.enableMultiFile(); + storageProvider.selectBranch('main'); + storageProvider.changePath('multifile'); + expect(await storageProvider.write([ + { + type: 'metadata', + path: 'metadata.json', + data: { + commitMessage: 'Initial commit', + }, + }, + { + type: 'themes', + path: '$themes.json', + data: [ + { + id: 'light', + name: 'Light', + selectedTokenSets: { + global: TokenSetStatus.ENABLED, + }, + }, + ], + }, + { + type: 'tokenSet', + name: 'global', + path: 'global.json', + data: { + red: { + type: TokenTypes.COLOR, + name: 'red', + value: '#ff0000', + }, + }, + }, + ])).toBe(true); + expect(mockFetch).toHaveBeenNthCalledWith( + 4, + `${baseUrl}/${projectId}/_apis/git/repositories/${repositoryId}/pushes?api-version=6.0`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Basic ${btoa(`:${secret}`)}`, + }, + body: JSON.stringify({ + refUpdates: [ + { + name: 'refs/heads/main', + oldObjectId: 'main', + }, + ], + commits: [ + { + comment: 'Initial commit', + changes: [ + { + changeType: 'edit', + item: { path: '/multifile/$themes.json' }, + newContent: { + content: JSON.stringify([ + { + id: 'light', + name: 'Light', + selectedTokenSets: { + global: TokenSetStatus.ENABLED, + }, + }, + ], null, 2), + contentType: 'rawtext', + }, + }, + { + changeType: 'edit', + item: { path: '/multifile/global.json' }, + newContent: { + content: JSON.stringify({ + red: { + type: TokenTypes.COLOR, + name: 'red', + value: '#ff0000', + }, + }, null, 2), + contentType: 'rawtext', + }, + }, + ], + }, + ], + }), + }, + ); + }); +}); From 73037d70d56dc46ecad2fee7d1e19f87b7bf3b13 Mon Sep 17 00:00:00 2001 From: SwordEdge Date: Mon, 20 Jun 2022 16:43:43 +0900 Subject: [PATCH 3/7] Bugfix auto complete search doesnt work when it is chinese (#903) * skip to start screen when create new file * resolve test error * add env * fix: DownShiftInput * test coverage for fileteredValue in DownShiftInputValue Co-authored-by: hiroshi --- .../DownshiftInput/DownShiftInput.test.tsx | 33 +++++++++++++++++++ .../DownshiftInput/DownshiftInput.tsx | 2 +- 2 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 src/app/components/DownshiftInput/DownShiftInput.test.tsx diff --git a/src/app/components/DownshiftInput/DownShiftInput.test.tsx b/src/app/components/DownshiftInput/DownShiftInput.test.tsx new file mode 100644 index 000000000..6b875ca8e --- /dev/null +++ b/src/app/components/DownshiftInput/DownShiftInput.test.tsx @@ -0,0 +1,33 @@ +describe('DownShiftInput', () => { + it('filteredValue should only replace {} or $ and remain all letters', () => { + const dataStore = [ + { + input: '{opacity.10}', + output: 'opacity.10', + }, + { + input: '{トランスペアレント.10', + output: 'トランスペアレント.10', + }, + { + input: '$不透 明度.10', + output: '不透 明度.10', + }, + { + input: '$불투명.10', + output: '불투명.10', + }, + { + input: '{अस्पष्टता.10}', + output: 'अस्पष्टता.10', + }, + { + input: 'թափանցիկ.10', + output: 'թափանցիկ.10', + }, + ]; + dataStore.forEach((data) => { + expect(data.input.replace(/[{}$]/g, '')).toBe(data.output); + }); + }); +}); diff --git a/src/app/components/DownshiftInput/DownshiftInput.tsx b/src/app/components/DownshiftInput/DownshiftInput.tsx index 0938d3aa2..618bc9d8c 100644 --- a/src/app/components/DownshiftInput/DownshiftInput.tsx +++ b/src/app/components/DownshiftInput/DownshiftInput.tsx @@ -113,7 +113,7 @@ export const DownshiftInput: React.FunctionComponent = ({ const [showAutoSuggest, setShowAutoSuggest] = React.useState(false); const [isFirstLoading, setisFirstLoading] = React.useState(true); - const filteredValue = useMemo(() => ((showAutoSuggest || typeof value !== 'string') ? '' : value?.replace(/[^a-zA-Z0-9.]/g, '')), [ + const filteredValue = useMemo(() => ((showAutoSuggest || typeof value !== 'string') ? '' : value?.replace(/[{}$]/g, '')), [ showAutoSuggest, value, ]); // removing non-alphanumberic except . from the input value From f9f53900c1b7feb7ba55fd6591598ac442cb523c Mon Sep 17 00:00:00 2001 From: SwordEdge Date: Mon, 20 Jun 2022 17:13:17 +0900 Subject: [PATCH 4/7] Create tests for remote tokens (#876) * skip to start screen when create new file * resolve test error * add env * hook mock * test coverage for pull tokens * useConfirm mock * restoreStoredProvider test coverage * push tokens test coverage * addNewProviderItem test coverage * create branch test coverage * convert to success Co-authored-by: hiroshi --- src/app/store/providers/ado/ado.tsx | 3 +- src/app/store/providers/github/github.tsx | 4 +- src/app/store/providers/gitlab/gitlab.tsx | 4 +- src/app/store/providers/jsonbin.tsx | 1 - src/app/store/remoteTokens.test.ts | 483 ++++++++++++++++++++++ src/app/store/remoteTokens.tsx | 1 - 6 files changed, 486 insertions(+), 10 deletions(-) create mode 100644 src/app/store/remoteTokens.test.ts diff --git a/src/app/store/providers/ado/ado.tsx b/src/app/store/providers/ado/ado.tsx index d97433928..0708501f1 100644 --- a/src/app/store/providers/ado/ado.tsx +++ b/src/app/store/providers/ado/ado.tsx @@ -45,8 +45,7 @@ export const useADO = () => { text: 'Pull from Ado?', description: 'Your repo already contains tokens, do you want to pull these now?', }); - if (confirmResult === false) return false; - return confirmResult.result; + return confirmResult }, [confirm]); const pushTokensToADO = React.useCallback(async (context: AdoCredentials) => { diff --git a/src/app/store/providers/github/github.tsx b/src/app/store/providers/github/github.tsx index ddeb0bf69..a25f6fb03 100644 --- a/src/app/store/providers/github/github.tsx +++ b/src/app/store/providers/github/github.tsx @@ -48,14 +48,12 @@ export function useGitHub() { text: 'Pull from GitHub?', description: 'Your repo already contains tokens, do you want to pull these now?', }); - if (confirmResult === false) return false; - return confirmResult.result; + return confirmResult; }, [confirm]); const pushTokensToGitHub = useCallback(async (context: GithubCredentials): Promise | null> => { const storage = storageClientFactory(context); const content = await storage.retrieve(); - if (content) { if ( content diff --git a/src/app/store/providers/gitlab/gitlab.tsx b/src/app/store/providers/gitlab/gitlab.tsx index acd6e8b69..d4cf7f087 100644 --- a/src/app/store/providers/gitlab/gitlab.tsx +++ b/src/app/store/providers/gitlab/gitlab.tsx @@ -49,8 +49,7 @@ export function useGitLab() { text: 'Pull from GitLab?', description: 'Your repo already contains tokens, do you want to pull these now?', }); - if (confirmResult === false) return false; - return confirmResult.result; + return confirmResult; }, [confirm]); const pushTokensToGitLab = useCallback(async (context: GitlabCredentials) => { @@ -91,7 +90,6 @@ export function useGitLab() { usedTokenSet, activeTheme, }); - pushDialog('success'); return { tokens, diff --git a/src/app/store/providers/jsonbin.tsx b/src/app/store/providers/jsonbin.tsx index 39ba569ac..4e12f0b5b 100644 --- a/src/app/store/providers/jsonbin.tsx +++ b/src/app/store/providers/jsonbin.tsx @@ -98,7 +98,6 @@ export function useJSONbin() { id, secret, name, internalId, } = context; if (!id || !secret) return null; - try { const storage = new JSONBinTokenStorage(id, secret); const data = await storage.retrieve(); diff --git a/src/app/store/remoteTokens.test.ts b/src/app/store/remoteTokens.test.ts new file mode 100644 index 000000000..fce9e7d5b --- /dev/null +++ b/src/app/store/remoteTokens.test.ts @@ -0,0 +1,483 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { Selector } from 'reselect'; +import { waitFor } from '@testing-library/react'; +import useRemoteTokens from './remoteTokens'; +import { StorageTypeCredentials } from '@/types/StorageType'; +import { + themesListSelector, + tokensSelector, +} from '@/selectors'; +import { notifyToUI } from '@/plugin/notifiers'; + +const mockStartJob = jest.fn(); +const mockRetrieve = jest.fn(); +const mockCanWrite = jest.fn(); +const mockChangePath = jest.fn(); +const mockSelectBrach = jest.fn(); +const mockEnableMultiFile = jest.fn(); +const mockSetLastSyncedState = jest.fn(); +const mockSetTokenData = jest.fn(); +const mockSetEditProhibited = jest.fn(); +const mockCompleteJob = jest.fn(); +const mockAssignProjectId = jest.fn(); +const mockSetProjectURL = jest.fn(); +const mockSetApiData = jest.fn(); +const mockSetLocalApiState = jest.fn(); +const mockSetStorage = jest.fn(); +const mockFetchBranches = jest.fn(); +const mockSetBranches = jest.fn(); +const mockConfirm = jest.fn(); +const mockSetShowConfirm = jest.fn(); +const mockPushDialog = jest.fn(); +const mockCreateBranch = jest.fn(); +const mockSave = jest.fn(); + +const mockSelector = (selector: Selector) => { + switch (selector) { + case tokensSelector: + return { + global: [ + { + value: '#ffffff', + type: 'color', + name: 'black', + }, + ], + }; + case themesListSelector: + return [ + { + id: 'light', + name: 'Light', + selectedTokenSets: { + global: 'enabled', + }, + }, + ]; + default: + return {}; + } +}; + +jest.mock('react-redux', () => ({ + useDispatch: jest.fn().mockImplementation(() => ({ + uiState: { + startJob: mockStartJob, + completeJob: mockCompleteJob, + setProjectURL: mockSetProjectURL, + setLocalApiState: mockSetLocalApiState, + setApiData: mockSetApiData, + setStorage: mockSetStorage, + setShowConfirm: mockSetShowConfirm, + }, + tokenState: { + setLastSyncedState: mockSetLastSyncedState, + setTokenData: mockSetTokenData, + setEditProhibited: mockSetEditProhibited, + }, + branchState: { + setBranches: mockSetBranches, + }, + })), + useSelector: (selector: Selector) => mockSelector(selector), +})); +jest.mock('../../storage/GithubTokenStorage', () => ({ + GithubTokenStorage: jest.fn().mockImplementation(() => ( + { + retrieve: mockRetrieve, + canWrite: mockCanWrite, + changePath: mockChangePath, + selectBranch: mockSelectBrach, + enableMultiFile: mockEnableMultiFile, + fetchBranches: mockFetchBranches, + save: mockSave, + createBranch: mockCreateBranch, + } + )), +})); +jest.mock('../../storage/GitlabTokenStorage', () => ({ + GitlabTokenStorage: jest.fn().mockImplementation(() => ( + { + retrieve: mockRetrieve, + canWrite: mockCanWrite, + changePath: mockChangePath, + selectBranch: mockSelectBrach, + enableMultiFile: mockEnableMultiFile, + save: mockSave, + createBranch: mockCreateBranch, + assignProjectId: jest.fn().mockImplementation(() => ({ + retrieve: mockRetrieve, + canWrite: mockCanWrite, + changePath: mockChangePath, + selectBranch: mockSelectBrach, + enableMultiFile: mockEnableMultiFile, + assignProjectId: mockAssignProjectId, + fetchBranches: mockFetchBranches, + save: mockSave, + createBranch: mockCreateBranch, + })), + } + )), +})); +jest.mock('../../storage/JSONBinTokenStorage', () => ({ + JSONBinTokenStorage: jest.fn().mockImplementation(() => ( + { + retrieve: mockRetrieve, + } + )), +})); +jest.mock('../../storage/ADOTokenStorage', () => ({ + ADOTokenStorage: jest.fn().mockImplementation(() => ( + { + retrieve: mockRetrieve, + canWrite: mockCanWrite, + changePath: mockChangePath, + selectBranch: mockSelectBrach, + enableMultiFile: mockEnableMultiFile, + fetchBranches: mockFetchBranches, + save: mockSave, + createBranch: mockCreateBranch, + } + )), +})); +jest.mock('../../storage/UrlTokenStorage', () => ({ + UrlTokenStorage: jest.fn().mockImplementation(() => ( + { + retrieve: mockRetrieve, + } + )), +})); +jest.mock('../hooks/useConfirm', () => ({ + __esModule: true, + default: () => ({ + confirm: mockConfirm, + }), +})); +jest.mock('../hooks/usePushDialog', () => ({ + __esModule: true, + default: () => ({ + pushDialog: mockPushDialog, + }), +})); +jest.mock('../../plugin/notifiers', (() => ({ + notifyToUI: jest.fn(), +}))); + +const gitHubContext = { + branch: 'main', + filePath: 'data/tokens.json', + id: 'six7/figma-tokens', + provider: 'github', + secret: 'github', +}; +const gitLabContext = { + branch: 'main', + filePath: 'data/tokens.json', + id: 'six7/figma-tokens', + provider: 'gitlab', + secret: 'gitlab', +}; +const jsonbinContext = { + name: 'six7', + id: 'six7/figma-tokens', + provider: 'jsonbin', + secret: 'jsonbin', +}; +const adoContext = { + name: 'six7', + id: 'six7/figma-tokens', + provider: 'ado', + secret: 'ado', +}; +const urlContext = { + name: 'six7', + id: 'six7/figma-tokens', + provider: 'url', + secret: 'url', +}; + +const contexts = [gitHubContext, gitLabContext, jsonbinContext, adoContext, urlContext]; +const contextNames = ['GitHub', 'GitLab', 'jsonbin', 'ADO', 'url']; +describe('remoteTokens', () => { + let { result } = renderHook(() => useRemoteTokens()); + beforeEach(() => { + result = renderHook(() => useRemoteTokens()).result; + mockRetrieve.mockImplementation(() => ( + Promise.resolve( + { + metadata: { + commitMessage: 'Initial commit', + }, + themes: [ + { + id: 'light', + name: 'Light', + selectedTokenSets: { + global: 'enabled', + }, + }, + ], + tokens: { + global: [ + { + value: '#ffffff', + type: 'color', + name: 'black', + }, + ], + }, + }, + ) + )); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + + contexts.forEach((context) => { + it(`Pull tokens from ${context.provider}`, async () => { + expect(await result.current.pullTokens({ context: context as StorageTypeCredentials })).toEqual({ + metadata: { + commitMessage: 'Initial commit', + }, + themes: [ + { + id: 'light', + name: 'Light', + selectedTokenSets: { + global: 'enabled', + }, + }, + ], + tokens: { + global: [ + { + value: '#ffffff', + type: 'color', + name: 'black', + }, + ], + }, + }); + }); + }); + + contexts.forEach((context, index) => { + it(`Restore storedProvider from ${context.provider}, should pull tokens if the user agree`, async () => { + mockFetchBranches.mockImplementationOnce(() => ( + Promise.resolve(['main']) + )); + mockRetrieve.mockImplementation(() => ( + Promise.resolve( + { + metadata: { + commitMessage: 'Initial commit', + }, + themes: [ + { + id: 'black', + name: 'Black', + selectedTokenSets: { + global: 'enabled', + }, + }, + ], + tokens: { + global: [ + { + value: '#000000', + type: 'color', + name: 'white', + }, + ], + }, + }, + ) + )); + mockConfirm.mockImplementation(() => ( + Promise.resolve(true) + )); + await waitFor(() => { result.current.restoreStoredProvider(context as StorageTypeCredentials); }); + if (context === gitHubContext || context === gitLabContext || context === adoContext) { + expect(notifyToUI).toBeCalledTimes(1); + expect(notifyToUI).toBeCalledWith(`Pulled tokens from ${contextNames[index]}`); + } else { + expect(mockStartJob).toBeCalledWith({ + isInfinite: true, + name: 'ui_pulltokens', + }); + } + }); + }); + + contexts.forEach((context) => { + it(`Restore storedProvider from ${context.provider}, should push tokens if there is no content`, async () => { + mockFetchBranches.mockImplementationOnce(() => ( + Promise.resolve(['main']) + )); + mockRetrieve.mockImplementation(() => ( + Promise.resolve(null) + )); + await waitFor(() => { result.current.restoreStoredProvider(context as StorageTypeCredentials); }); + if (context === gitHubContext || context === gitLabContext || context === adoContext) { + expect(mockPushDialog).toBeCalledTimes(1); + } else { + expect(mockStartJob).toBeCalledWith({ + isInfinite: true, + name: 'ui_pulltokens', + }); + } + }); + }); + + contexts.forEach((context) => { + it(`push tokens to ${context.provider}`, async () => { + mockRetrieve.mockImplementation(() => ( + Promise.resolve(null) + )); + mockPushDialog.mockImplementation(() => ( + Promise.resolve({ + customBranch: 'development', + commitMessage: 'Initial commit', + }) + )); + if (context === gitHubContext || context === gitLabContext || context === adoContext) { + await waitFor(() => { result.current.pushTokens(context as StorageTypeCredentials); }); + expect(mockPushDialog).toBeCalledTimes(2); + expect(mockPushDialog.mock.calls[1][0]).toBe('success'); + } + }); + }); + + contexts.forEach((context) => { + it(`push tokens to ${context.provider}, should return noting to commit if the content is same`, async () => { + if (context === gitHubContext || context === gitLabContext || context === adoContext) { + await waitFor(() => { result.current.pushTokens(context as StorageTypeCredentials); }); + expect(notifyToUI).toBeCalledWith('Nothing to commit'); + } + }); + }); + + contexts.forEach((context) => { + if (context === gitHubContext || context === gitLabContext || context === adoContext) { + it(`Add newProviderItem to ${context.provider}, should push tokens and return true if there is no content`, async () => { + mockFetchBranches.mockImplementationOnce(() => ( + Promise.resolve(['main']) + )); + mockRetrieve.mockImplementation(() => ( + Promise.resolve(null) + )); + mockPushDialog.mockImplementation(() => ( + Promise.resolve({ + customBranch: 'development', + commitMessage: 'Initial commit', + }) + )); + await waitFor(() => { result.current.pushTokens(context as StorageTypeCredentials); }); + expect(mockPushDialog).toBeCalledTimes(2); + expect(mockPushDialog.mock.calls[1][0]).toBe('success'); + expect(await result.current.addNewProviderItem(context as StorageTypeCredentials)).toEqual(true); + }); + } else { + it(`Add newProviderItem to ${context.provider}, should pull tokens and return false if there is no content`, async () => { + mockRetrieve.mockImplementation(() => ( + Promise.resolve(null) + )); + expect(await result.current.addNewProviderItem(context as StorageTypeCredentials)).toEqual(false); + }); + } + }); + + contexts.forEach((context, index) => { + if (context === gitHubContext || context === gitLabContext || context === adoContext) { + it(`Add newProviderItem to ${context.provider}, should pull tokens and notify that no tokens stored on remote if there is no tokens on remote`, async () => { + mockFetchBranches.mockImplementation(() => ( + Promise.resolve(['main']) + )); + mockRetrieve.mockImplementation(() => ( + Promise.resolve( + { + metadata: { + commitMessage: 'Initial commit', + }, + themes: [ + { + id: 'light', + name: 'Light', + selectedTokenSets: { + global: 'enabled', + }, + }, + ], + }, + ) + )); + mockConfirm.mockImplementation(() => ( + Promise.resolve(true) + )); + await waitFor(() => { result.current.addNewProviderItem(context as StorageTypeCredentials); }); + expect(notifyToUI).toBeCalledTimes(2); + expect(notifyToUI).toBeCalledWith(`Pulled tokens from ${contextNames[index]}`); + expect(notifyToUI).toBeCalledWith('No tokens stored on remote'); + expect(await result.current.addNewProviderItem(context as StorageTypeCredentials)).toEqual(true); + }); + } else { + it(`Add newProviderItem to ${context.provider}, should pull tokens and return false if there is no tokens on remote`, async () => { + mockRetrieve.mockImplementation(() => ( + Promise.resolve( + { + metadata: { + commitMessage: 'Initial commit', + }, + themes: [ + { + id: 'light', + name: 'Light', + selectedTokenSets: { + global: 'enabled', + }, + }, + ], + tokens: null, + }, + ) + )); + expect(await result.current.addNewProviderItem(context as StorageTypeCredentials)).toEqual(false); + }); + } + }); + + contexts.forEach((context) => { + it(`Add newProviderItem to ${context.provider}, should pull tokens and return true`, async () => { + mockFetchBranches.mockImplementation(() => ( + Promise.resolve(['main']) + )); + await waitFor(() => { result.current.addNewProviderItem(context as StorageTypeCredentials); }); + expect(await result.current.addNewProviderItem(context as StorageTypeCredentials)).toEqual(true); + }); + }); + + contexts.forEach((context) => { + it(`create branch in ${context.provider}`, async () => { + if (context === gitHubContext || context === gitLabContext || context === adoContext) { + mockCreateBranch.mockImplementation(() => ( + Promise.resolve(true) + )); + expect(await result.current.addNewBranch(context as StorageTypeCredentials, 'newBranch')).toEqual(true); + } + }); + }); + + contexts.forEach((context) => { + it(`fetch branchs in ${context.provider}`, async () => { + if (context === gitHubContext || context === gitLabContext || context === adoContext) { + mockFetchBranches.mockImplementation(() => ( + Promise.resolve(['main']) + )); + expect(await result.current.fetchBranches(context as StorageTypeCredentials)).toEqual(['main']); + } else { + expect(await result.current.fetchBranches(context as StorageTypeCredentials)).toEqual(null); + } + }); + }); +}); diff --git a/src/app/store/remoteTokens.tsx b/src/app/store/remoteTokens.tsx index 667da2f2e..ad7f330fc 100644 --- a/src/app/store/remoteTokens.tsx +++ b/src/app/store/remoteTokens.tsx @@ -232,7 +232,6 @@ export default function useRemoteTokens() { default: throw new Error('Not implemented'); } - return newBranchCreated; }, [createGithubBranch, createADOBranch]); From abd849b5dc9e5e40948b16b19c927f0fc0f5e0bb Mon Sep 17 00:00:00 2001 From: Liam Martens Date: Wed, 22 Jun 2022 01:36:01 -0400 Subject: [PATCH 5/7] fix: FT-320 fix gitlab subfolders (#920) --- src/storage/GitlabTokenStorage.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/storage/GitlabTokenStorage.ts b/src/storage/GitlabTokenStorage.ts index acbbdf19a..d4356b470 100644 --- a/src/storage/GitlabTokenStorage.ts +++ b/src/storage/GitlabTokenStorage.ts @@ -114,6 +114,7 @@ export class GitlabTokenStorage extends GitTokenStorage { const trees = await this.gitlabClient.Repositories.tree(this.projectId, { path: this.path, ref: this.branch, + recursive: true, }); if (trees.length > 0 && this.flags.multiFileEnabled) { const jsonFiles = trees.filter((file) => ( @@ -188,6 +189,7 @@ export class GitlabTokenStorage extends GitTokenStorage { const tree = await this.gitlabClient.Repositories.tree(this.projectId, { path: rootPath, ref: branch, + recursive: true, }); const filesInTrees = tree.map((t) => t.path); From c2eb100c47643a51a38add1ce53e85681d36a233 Mon Sep 17 00:00:00 2001 From: SwordEdge Date: Wed, 22 Jun 2022 14:37:53 +0900 Subject: [PATCH 6/7] Ft 301 gh multi file support for ado sync (#897) --- .../ManageThemesModal/CreateOrEditThemeForm.tsx | 4 ++-- src/app/components/TokenSetSelector.tsx | 4 ++-- ...ubMultiFileEnabled.ts => useIsGitMultiFileEnabled.ts} | 4 ++-- src/app/store/providers/github/github.tsx | 2 ++ src/storage/ADOTokenStorage.ts | 9 ++++----- src/storage/schemas/multiFileSchema.ts | 4 ++-- src/utils/tokenset/tokenSetListToTree.ts | 1 - 7 files changed, 14 insertions(+), 14 deletions(-) rename src/app/hooks/{useIsGithubMultiFileEnabled.ts => useIsGitMultiFileEnabled.ts} (80%) diff --git a/src/app/components/ManageThemesModal/CreateOrEditThemeForm.tsx b/src/app/components/ManageThemesModal/CreateOrEditThemeForm.tsx index 610b67a43..96d93d228 100644 --- a/src/app/components/ManageThemesModal/CreateOrEditThemeForm.tsx +++ b/src/app/components/ManageThemesModal/CreateOrEditThemeForm.tsx @@ -5,7 +5,7 @@ import Input from '../Input'; import { allTokenSetsSelector, usedTokenSetSelector } from '@/selectors'; import { StyledNameInputBox } from './StyledNameInputBox'; import { tokenSetListToTree, tokenSetListToList, TreeItem } from '@/utils/tokenset'; -import { useIsGithubMultiFileEnabled } from '@/app/hooks/useIsGithubMultiFileEnabled'; +import { useIsGitMultiFileEnabled } from '@/app/hooks/useIsGitMultiFileEnabled'; import { TokenSetListOrTree } from '../TokenSetListOrTree'; import { TokenSetThemeItem } from './TokenSetThemeItem'; import { StyledForm } from './StyledForm'; @@ -25,7 +25,7 @@ type Props = { export const CreateOrEditThemeForm: React.FC = ({ defaultValues, onSubmit }) => { const store = useStore(); - const githubMfsEnabled = useIsGithubMultiFileEnabled(); + const githubMfsEnabled = useIsGitMultiFileEnabled(); const selectedTokenSets = useMemo(() => ( usedTokenSetSelector(store.getState()) ), [store]); diff --git a/src/app/components/TokenSetSelector.tsx b/src/app/components/TokenSetSelector.tsx index 952e522d6..b476fb403 100644 --- a/src/app/components/TokenSetSelector.tsx +++ b/src/app/components/TokenSetSelector.tsx @@ -16,7 +16,7 @@ import { editProhibitedSelector, tokensSelector, } from '@/selectors'; import Stack from './Stack'; -import { useIsGithubMultiFileEnabled } from '../hooks/useIsGithubMultiFileEnabled'; +import { useIsGitMultiFileEnabled } from '../hooks/useIsGitMultiFileEnabled'; const StyledButton = styled('button', { flexShrink: 0, @@ -37,7 +37,7 @@ const StyledButton = styled('button', { export default function TokenSetSelector() { const tokens = useSelector(tokensSelector); const editProhibited = useSelector(editProhibitedSelector); - const mfsEnabled = useIsGithubMultiFileEnabled(); + const mfsEnabled = useIsGitMultiFileEnabled(); const dispatch = useDispatch(); const { confirm } = useConfirm(); diff --git a/src/app/hooks/useIsGithubMultiFileEnabled.ts b/src/app/hooks/useIsGitMultiFileEnabled.ts similarity index 80% rename from src/app/hooks/useIsGithubMultiFileEnabled.ts rename to src/app/hooks/useIsGitMultiFileEnabled.ts index 1dff1bfc5..21ee5089c 100644 --- a/src/app/hooks/useIsGithubMultiFileEnabled.ts +++ b/src/app/hooks/useIsGitMultiFileEnabled.ts @@ -4,14 +4,14 @@ import { useFlags } from 'launchdarkly-react-client-sdk'; import { apiSelector } from '@/selectors'; import { StorageProviderType } from '@/constants/StorageProviderType'; -export function useIsGithubMultiFileEnabled() { +export function useIsGitMultiFileEnabled() { const api = useSelector(apiSelector); const { multiFileSync } = useFlags(); return useMemo(() => ( Boolean( multiFileSync - && (api?.provider === StorageProviderType.GITHUB || api?.provider === StorageProviderType.GITLAB) + && (api?.provider === StorageProviderType.GITHUB || api?.provider === StorageProviderType.GITLAB || api?.provider === StorageProviderType.ADO) && !api?.filePath?.endsWith('.json'), ) ), [api, multiFileSync]); diff --git a/src/app/store/providers/github/github.tsx b/src/app/store/providers/github/github.tsx index a25f6fb03..337262e74 100644 --- a/src/app/store/providers/github/github.tsx +++ b/src/app/store/providers/github/github.tsx @@ -134,6 +134,7 @@ export function useGitHub() { try { const content = await storage.retrieve(); + if (content) { return content; } @@ -160,6 +161,7 @@ export function useGitHub() { await checkAndSetAccess({ context, owner, repo }); const content = await storage.retrieve(); + if (content) { if ( !isEqual(content.tokens, tokens) diff --git a/src/storage/ADOTokenStorage.ts b/src/storage/ADOTokenStorage.ts index 9f6fe3114..48fa021a3 100644 --- a/src/storage/ADOTokenStorage.ts +++ b/src/storage/ADOTokenStorage.ts @@ -166,7 +166,7 @@ export class ADOTokenStorage extends GitTokenStorage { return false; } - private async getOldObjectId(branch:string, shouldCreateBranch: boolean) { + private async getOldObjectId(branch: string, shouldCreateBranch: boolean) { const { value } = await this.getRefs(); const branches = new Map(); for (const val of value) { @@ -239,7 +239,7 @@ export class ADOTokenStorage extends GitTokenStorage { if (!jsonFiles.length) return []; - const jsonFileContents = compact(await Promise.all( + const jsonFileContents = await Promise.all( jsonFiles.map(async ({ path }) => { const res = await this.getItem(path); const validationResult = await multiFileSchema.safeParseAsync(res); @@ -248,12 +248,11 @@ export class ADOTokenStorage extends GitTokenStorage { } return null; }), - )); + ); return compact(jsonFileContents.map | null>((fileContent, index) => { const { path } = jsonFiles[index]; if (fileContent) { - const name = path?.split(/[\\/]/).pop()?.replace(/\.json$/, ''); - + const name = path?.replace(this.path, '')?.replace(/^\/+/, '')?.replace('.json', ''); if (name === '$themes' && Array.isArray(fileContent)) { return { path, diff --git a/src/storage/schemas/multiFileSchema.ts b/src/storage/schemas/multiFileSchema.ts index 7d0fdc76b..3eb688b77 100644 --- a/src/storage/schemas/multiFileSchema.ts +++ b/src/storage/schemas/multiFileSchema.ts @@ -1,8 +1,8 @@ import z from 'zod'; import { TokenSetStatus } from '@/constants/TokenSetStatus'; -import { singleTokenSchema } from './singleTokenSchema'; +import { tokensMapSchema } from './tokensMapSchema'; -export const multiFileSchema = z.record(singleTokenSchema).or(z.array(z.object({ +export const multiFileSchema = tokensMapSchema.or(z.array(z.object({ id: z.string(), name: z.string(), selectedTokenSets: z.record(z.enum([ diff --git a/src/utils/tokenset/tokenSetListToTree.ts b/src/utils/tokenset/tokenSetListToTree.ts index 2e2be7da7..602ab3abf 100644 --- a/src/utils/tokenset/tokenSetListToTree.ts +++ b/src/utils/tokenset/tokenSetListToTree.ts @@ -47,6 +47,5 @@ export function tokenSetListToTree(items: string[]) { } return 0; }); - return sorted; } From c5d87794a7825c0b07f9e8187342185e89e9d218 Mon Sep 17 00:00:00 2001 From: Jan Six Date: Thu, 23 Jun 2022 08:42:18 +0200 Subject: [PATCH 7/7] bump version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index fcaca747a..9b273392d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "figma-tokens", "version": "1.0.0", - "plugin_version": "110", + "plugin_version": "111", "description": "Figma Tokens", "license": "MIT", "scripts": {