diff --git a/.vscode/launch.json b/.vscode/launch.json index 1ae8cfa61..d54efb809 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -11,6 +11,18 @@ "url": "http://localhost:8080", "webRoot": "${workspaceFolder}" }, + { + "type": "node", + "request": "launch", + "name": "Test current file", + "program": "${workspaceRoot}/node_modules/jest/bin/jest", + "args": [ + "--config=./jest.config.ts", + "--json", + "--testPathPattern=${fileBasenameNoExtension}" + ], + "console": "integratedTerminal" + }, { "type": "node", "request": "launch", @@ -40,6 +52,20 @@ "console": "integratedTerminal", "cwd": "${workspaceFolder}/token-transformer", "internalConsoleOptions": "neverOpen" + }, + { + "name": "Test current file", + "type": "node", + "request": "launch", + "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/jest", + "args": [ + "--testPathPattern=${fileBasenameNoExtension}", + "--no-cache", + "--coverage=false" + ], + "cwd": "${workspaceFolder}", + "sourceMaps": true, + "console": "integratedTerminal" } ] } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 8ae7f5e65..a32e4350d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -94,7 +94,7 @@ "set-value": "^3.0.2", "storyblok-js-client": "^3.3.1", "use-debounce": "^6.0.1", - "zod": "^3.14.4" + "zod": "^3.21.4" }, "devDependencies": { "@babel/core": "^7.12.16", @@ -55281,10 +55281,9 @@ } }, "node_modules/zod": { - "version": "3.14.4", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.14.4.tgz", - "integrity": "sha512-U9BFLb2GO34Sfo9IUYp0w3wJLlmcyGoMd75qU9yf+DrdGA4kEx6e+l9KOkAlyUO0PSQzZCa3TR4qVlcmwqSDuw==", - "license": "MIT", + "version": "3.21.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.21.4.tgz", + "integrity": "sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==", "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -94943,9 +94942,9 @@ "dev": true }, "zod": { - "version": "3.14.4", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.14.4.tgz", - "integrity": "sha512-U9BFLb2GO34Sfo9IUYp0w3wJLlmcyGoMd75qU9yf+DrdGA4kEx6e+l9KOkAlyUO0PSQzZCa3TR4qVlcmwqSDuw==" + "version": "3.21.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.21.4.tgz", + "integrity": "sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==" }, "zwitch": { "version": "1.0.5", diff --git a/package.json b/package.json index a974571b2..2ef3a2ccf 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "tokens-studio-for-figma", "version": "1.0.0", - "plugin_version": "1.35.2", + "plugin_version": "1.35.3", "description": "Tokens Studio for Figma", "license": "MIT", "scripts": { @@ -16,6 +16,7 @@ "cy:open": "cypress open", "cy:run": "cypress run --headless", "serve": "serve dist", + "lint":"eslint . --quiet --fix", "storybook": "start-storybook -p 6006", "build-storybook": "build-storybook" }, @@ -105,7 +106,7 @@ "set-value": "^3.0.2", "storyblok-js-client": "^3.3.1", "use-debounce": "^6.0.1", - "zod": "^3.14.4" + "zod": "^3.21.4" }, "devDependencies": { "@babel/core": "^7.12.16", diff --git a/src/app/components/TokenGroup/TokenGroupHeading.test.tsx b/src/app/components/TokenGroup/TokenGroupHeading.test.tsx index a6a28eb5b..533bb5012 100644 --- a/src/app/components/TokenGroup/TokenGroupHeading.test.tsx +++ b/src/app/components/TokenGroup/TokenGroupHeading.test.tsx @@ -48,6 +48,6 @@ describe('TokenGroupHeading', () => { await fireEvent.contextMenu(getByText('color')); await fireEvent.click(getByText('Duplicate')); - expect(getByText('Duplicate Group')).toBeInTheDocument(); + expect(getByText('Duplicate group')).toBeInTheDocument(); }); }); diff --git a/src/app/components/modals/DuplicateTokenGroupModal.tsx b/src/app/components/modals/DuplicateTokenGroupModal.tsx index ec8544d03..a5b43f556 100644 --- a/src/app/components/modals/DuplicateTokenGroupModal.tsx +++ b/src/app/components/modals/DuplicateTokenGroupModal.tsx @@ -35,11 +35,11 @@ export default function DuplicateTokenGroupModal({ oldName, newName, tokenSets: selectedTokenSets, type, }); onClose(); - }, [duplicateGroup, oldName, newName, type, selectedTokenSets]); + }, [duplicateGroup, oldName, newName, selectedTokenSets, type, onClose]); return ( { + const newTextStyleId = await getNewStyleId(segment.textStyleId, styleIds, styleMap, newTheme); + + if (newTextStyleId) { + node.setRangeTextStyleId(segment.start, segment.end, newTextStyleId); + } + }); + } + if (node.fillStyleId !== figma.mixed) { const newFillStyleId = await getNewStyleId(node.fillStyleId as string, styleIds, styleMap, newTheme); if (newFillStyleId) { diff --git a/src/plugin/extractColorInBorderTokenForAlias.ts b/src/plugin/extractColorInBorderTokenForAlias.ts deleted file mode 100644 index 571ac4f32..000000000 --- a/src/plugin/extractColorInBorderTokenForAlias.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { MapValuesToTokensResult } from '@/types'; -import { NodeTokenRefMap } from '@/types/NodeTokenRefMap'; -import { AnyTokenList } from '@/types/tokens'; - -export function extractColorInBorderTokenForAlias(tokens: Map, values: NodeTokenRefMap, borderToken: string): MapValuesToTokensResult { - const resolvedToken = tokens.get(borderToken); - if (resolvedToken?.rawValue && typeof resolvedToken.rawValue === 'object' && 'color' in resolvedToken.rawValue && resolvedToken.rawValue.color) { - const { color } = resolvedToken.rawValue; - const { borderColor } = values; - let colorTokenName = color; - if (String(color).startsWith('$')) colorTokenName = String(color).slice(1, String(color).length); - if (String(color).startsWith('{')) colorTokenName = String(color).slice(1, String(color).length - 1); - values = { ...values, ...(borderColor ? { } : { borderColor: String(colorTokenName) }) }; - } - return values; -} diff --git a/src/plugin/node.test.ts b/src/plugin/node.test.ts index 273967c5e..fdd9a9d86 100644 --- a/src/plugin/node.test.ts +++ b/src/plugin/node.test.ts @@ -1,7 +1,7 @@ import { mockRootSetSharedPluginData } from '../../tests/__mocks__/figmaMock'; import { StorageProviderType } from '@/constants/StorageProviderType'; import { - destructureToken, mapValuesToTokens, returnValueToLookFor, saveStorageType, saveOnboardingExplainerSets, saveOnboardingExplainerInspect, saveOnboardingExplainerSyncProviders, + destructureToken, mapValuesToTokens, returnValueToLookFor, saveStorageType, saveOnboardingExplainerSets, saveOnboardingExplainerInspect, saveOnboardingExplainerSyncProviders, destructureTokenForAlias, } from './node'; import getOnboardingExplainer from '@/utils/getOnboardingExplainer'; import { TokenTypes } from '@/constants/TokenTypes'; @@ -134,6 +134,14 @@ const tokens = new Map([ value: multipleShadowToken.value, }, ], + ['global.border.general', + { + ...borderToken, + name: 'border.general', + rawValue: borderToken.value, + value: borderToken.value, + }, + ], ]); const values = [ @@ -144,6 +152,7 @@ const values = [ { composition: 'global.composition.containMultiBoxshadow' }, { boxShadow: 'global.shadow.single' }, { boxShadow: 'global.shadow.multiple' }, + { border: 'global.border.general' }, ]; const mappedTokens = [ @@ -205,6 +214,20 @@ const applyProperties = [ { borderBottom: borderToken.value, borderColor: '#ff0000' }, ]; +const applyTokens = [ + { fill: 'global.colors.blue' }, + { opacity: 'opacity.40' }, + { + opacity: 'opacity.40', + borderRadius: 'border-radius.7', + }, + { boxShadow: 'global.shadow.single' }, + { boxShadow: 'global.shadow.multiple' }, + { boxShadow: 'global.shadow.single' }, + { boxShadow: 'global.shadow.multiple' }, + { border: 'global.border.general', borderColor: 'global.border.general' }, +]; + describe('mapValuesToTokens', () => { it('maps values to tokens', () => { values.forEach((value, index) => { @@ -221,6 +244,14 @@ describe('destructureToken', () => { }); }); +describe('destructureTokenForAliasa', () => { + it('return extract border color from border token', () => { + values.forEach((value, index) => { + expect(destructureTokenForAlias(tokens, value)).toEqual(applyTokens[index]); + }); + }); +}); + describe('returnValueToLookFor', () => { it('returns value that were looking for', () => { const tokens = [ diff --git a/src/plugin/node.ts b/src/plugin/node.ts index c61596b0a..0006e5b27 100644 --- a/src/plugin/node.ts +++ b/src/plugin/node.ts @@ -24,7 +24,6 @@ import { import { AsyncMessageChannel } from '@/AsyncMessageChannel'; import { AsyncMessageTypes } from '@/types/AsyncMessages'; import { updatePluginData } from './pluginData'; -import { extractColorInBorderTokenForAlias } from './extractColorInBorderTokenForAlias'; import { SettingsState } from '@/app/store/models/settings'; // @TODO fix typings @@ -53,7 +52,6 @@ export function mapValuesToTokens(tokens: Map, val const mappedValues = Object.entries(values).reduce((acc, [key, tokenOnNode]) => { const resolvedToken = tokens.get(tokenOnNode); if (!resolvedToken) return acc; - acc[key] = isSingleToken(resolvedToken) ? resolvedToken[returnValueToLookFor(key)] || resolvedToken.value : resolvedToken; return acc; }, {}); @@ -174,19 +172,19 @@ export function destructureToken(values: MapValuesToTokensResult): MapValuesToTo export function destructureTokenForAlias(tokens: Map, values: NodeTokenRefMap): MapValuesToTokensResult { if (values && values.border) { - values = extractColorInBorderTokenForAlias(tokens, values, values.border); + values = { ...values, ...(values.borderColor ? { } : { borderColor: values.border }) }; } if (values && values.borderTop) { - values = extractColorInBorderTokenForAlias(tokens, values, values.borderTop); + values = { ...values, ...(values.borderColor ? { } : { borderColor: values.borderTop }) }; } if (values && values.borderRight) { - values = extractColorInBorderTokenForAlias(tokens, values, values.borderRight); + values = { ...values, ...(values.borderColor ? { } : { borderColor: values.borderTop }) }; } if (values && values.borderLeft) { - values = extractColorInBorderTokenForAlias(tokens, values, values.borderLeft); + values = { ...values, ...(values.borderColor ? { } : { borderColor: values.borderTop }) }; } if (values && values.borderBottom) { - values = extractColorInBorderTokenForAlias(tokens, values, values.borderBottom); + values = { ...values, ...(values.borderColor ? { } : { borderColor: values.borderTop }) }; } if (values && values.composition) { const resolvedToken = tokens.get(values.composition); diff --git a/src/plugin/renameStylesFromPlugin.test.ts b/src/plugin/renameStylesFromPlugin.test.ts new file mode 100644 index 000000000..f1b3c2a8a --- /dev/null +++ b/src/plugin/renameStylesFromPlugin.test.ts @@ -0,0 +1,54 @@ +import { AsyncMessageChannel } from '@/AsyncMessageChannel'; +import { TokenSetStatus } from '@/constants/TokenSetStatus'; +import { AsyncMessageTypes, GetThemeInfoMessageResult } from '@/types/AsyncMessages'; +import renameStylesFromPlugin from './renameStylesFromPlugin'; + +describe('renameStylesFromPlugin', () => { + figma.getLocalPaintStyles.mockReturnValue([ + { + name: 'colors/old', + id: '456', + description: 'the red one', + paints: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0 }, opacity: 1 }], + remove: jest.fn(), + }, + { + name: 'colors/blue', + id: '567', + description: 'the blue one', + paints: [{ type: 'other', color: { r: 0, g: 0, b: 1 }, opacity: 0.5 }], + remove: jest.fn(), + }, + { + name: 'colors/red', + id: '678', + description: 'the red one', + paints: [{ type: 'other', color: { r: 0, g: 0, b: 1 }, opacity: 0.5 }], + remove: jest.fn(), + }, + ]); + + const runAfter: (() => void)[] = []; + + const mockGetThemeInfoHandler = async (): Promise => ({ + type: AsyncMessageTypes.GET_THEME_INFO, + activeTheme: 'light', + themes: [{ + id: 'light', + name: 'Light', + selectedTokenSets: { + global: TokenSetStatus.ENABLED, + }, + $figmaStyleReferences: {}, + }], + }); + + runAfter.push(AsyncMessageChannel.ReactInstance.connect()); + AsyncMessageChannel.ReactInstance.handle(AsyncMessageTypes.GET_THEME_INFO, mockGetThemeInfoHandler); + + runAfter.push(AsyncMessageChannel.PluginInstance.connect()); + + it('should remove styles from plugin', async () => { + expect(await renameStylesFromPlugin('global.colors.old', 'global.colors.new', 'global')).toEqual(['456']); + }); +}); diff --git a/src/plugin/renameStylesFromPlugin.ts b/src/plugin/renameStylesFromPlugin.ts index 9bc3c7578..33c48f97c 100644 --- a/src/plugin/renameStylesFromPlugin.ts +++ b/src/plugin/renameStylesFromPlugin.ts @@ -19,12 +19,12 @@ export default async function renameStylesFromPlugin( }); const themesToContainToken = themeInfo.themes.filter((theme) => Object.entries(theme.selectedTokenSets).some(([tokenSet, value]) => tokenSet === parent && value === TokenSetStatus.ENABLED)).map((filteredTheme) => filteredTheme.name); - const pathNames = themesToContainToken.map((theme) => convertTokenNameToPath(oldName, theme)).concat(convertTokenNameToPath(oldName)); - const allStyleIds = allStyles.filter((style) => pathNames.some((pathName) => { - if (isMatchingStyle(pathName, style)) { - const oldPath = oldName.split('.').map((part) => part.trim()).join('/'); - const newPath = newName.split('.').map((part) => part.trim()).join('/'); - style.name = pathName.replace(oldPath, newPath); + const oldPathNames = themesToContainToken.map((theme) => convertTokenNameToPath(oldName, theme)).concat(themesToContainToken.map(() => convertTokenNameToPath(oldName, null, 1))).concat(convertTokenNameToPath(oldName)); + const newPathNames = themesToContainToken.map((theme) => convertTokenNameToPath(newName, theme)).concat(themesToContainToken.map(() => convertTokenNameToPath(newName, null, 1))).concat(convertTokenNameToPath(newName)); + const allStyleIds = allStyles.filter((style) => oldPathNames.some((oldPathName) => { + if (isMatchingStyle(oldPathName, style)) { + const index = oldPathNames.findIndex((item) => item === oldPathName); + style.name = newPathNames[index]; return true; } return false; diff --git a/src/plugin/setTextValuesOnTarget.ts b/src/plugin/setTextValuesOnTarget.ts index 92ce2fd10..d38445a03 100644 --- a/src/plugin/setTextValuesOnTarget.ts +++ b/src/plugin/setTextValuesOnTarget.ts @@ -83,32 +83,60 @@ export default async function setTextValuesOnTarget( trackFromPlugin('Font not found', { family, style }); } } - if (typeof fontSize !== 'undefined') { - target.fontSize = transformValue(fontSize, 'fontSizes', baseFontSize); + try { + if (typeof fontSize !== 'undefined') { + target.fontSize = transformValue(fontSize, 'fontSizes', baseFontSize); + } + } catch (e) { + console.log('Error setting fontSize on target', target, token, e); } - if (typeof lineHeight !== 'undefined') { - const transformedValue = transformValue(String(lineHeight), 'lineHeights', baseFontSize); - if (transformedValue !== null) { - target.lineHeight = transformedValue; + try { + if (typeof lineHeight !== 'undefined') { + const transformedValue = transformValue(String(lineHeight), 'lineHeights', baseFontSize); + if (transformedValue !== null) { + target.lineHeight = transformedValue; + } } + } catch (e) { + console.log('Error setting lineHeight on target', target, token, e); } - if (typeof letterSpacing !== 'undefined') { - const transformedValue = transformValue(letterSpacing, 'letterSpacing', baseFontSize); - if (transformedValue !== null) { - target.letterSpacing = transformedValue; + try { + if (typeof letterSpacing !== 'undefined') { + const transformedValue = transformValue(letterSpacing, 'letterSpacing', baseFontSize); + if (transformedValue !== null) { + target.letterSpacing = transformedValue; + } } + } catch (e) { + console.log('Error setting letterSpacing on target', target, token, e); } - if (typeof paragraphSpacing !== 'undefined') { - target.paragraphSpacing = transformValue(paragraphSpacing, 'paragraphSpacing', baseFontSize); + try { + if (typeof paragraphSpacing !== 'undefined') { + target.paragraphSpacing = transformValue(paragraphSpacing, 'paragraphSpacing', baseFontSize); + } + } catch (e) { + console.log('Error setting paragraphSpacing on target', target, token, e); } - if (typeof paragraphIndent !== 'undefined') { - target.paragraphIndent = transformValue(paragraphIndent, 'paragraphIndent', baseFontSize); + try { + if (typeof paragraphIndent !== 'undefined') { + target.paragraphIndent = transformValue(paragraphIndent, 'paragraphIndent', baseFontSize); + } + } catch (e) { + console.log('Error setting paragraphIndent on target', target, token, e); } - if (textCase) { - target.textCase = transformValue(textCase, 'textCase', baseFontSize); + try { + if (textCase) { + target.textCase = transformValue(textCase, 'textCase', baseFontSize); + } + } catch (e) { + console.log('Error setting textCase on target', target, token, e); } - if (textDecoration) { - target.textDecoration = transformValue(textDecoration, 'textDecoration', baseFontSize); + try { + if (textDecoration) { + target.textDecoration = transformValue(textDecoration, 'textDecoration', baseFontSize); + } + } catch (e) { + console.log('Error setting textDecoration on target', target, token, e); } if (description && 'description' in target) { target.description = description; diff --git a/src/storage/GenericVersionedStorage.ts b/src/storage/GenericVersionedStorage.ts index 1fc3c9c91..240b5b011 100644 --- a/src/storage/GenericVersionedStorage.ts +++ b/src/storage/GenericVersionedStorage.ts @@ -12,7 +12,7 @@ const genericVersionedSchema = singleFileSchema.extend({ values: z.record(tokensMapSchema), version: z.string().optional(), $themes: z.array(themeObjectSchema).optional(), - updatedAt: z.number().optional(), + updatedAt: z.coerce.date().optional(), }); type GenericVersionedMeta = { @@ -139,9 +139,9 @@ export class GenericVersionedStorage extends RemoteTokenStorage { if (!this.path.endsWith('.json') && !this.flags.multiFileEnabled) return false; - if (!this.groupId || !this.projectId) throw new Error('Missing Project or Group ID'); + if (!this.projectId) throw new Error('Project ID not assigned'); const currentUser = await this.gitlabClient.Users.current(); try { if (!currentUser || currentUser.state !== 'active') return false; - - const groupPermission = await this.gitlabClient.GroupMembers.show(this.groupId, currentUser.id, { + const projectPermission = await this.gitlabClient.ProjectMembers.show(this.projectId, currentUser.id, { includeInherited: true, }); - return groupPermission.access_level >= GitLabAccessLevel.Developer; + return projectPermission.access_level >= GitLabAccessLevel.Developer; } catch (e) { - try { - const projectPermission = await this.gitlabClient.ProjectMembers.show(this.projectId, currentUser.id, { - includeInherited: true, - }); - return projectPermission.access_level >= GitLabAccessLevel.Developer; - } catch (e) { - console.error(e); - } + console.error(e); } - return false; } diff --git a/src/storage/__tests__/GenericVersionedStorage.test.ts b/src/storage/__tests__/GenericVersionedStorage.test.ts index 54b4317a9..02a35a56e 100644 --- a/src/storage/__tests__/GenericVersionedStorage.test.ts +++ b/src/storage/__tests__/GenericVersionedStorage.test.ts @@ -50,12 +50,14 @@ describe('GenericVersionedStorage', () => { }); it('can read GenericVersioned data', async () => { + const unixTime = 1666785400000; + const date = new Date(unixTime); mockFetch.mockImplementationOnce(() => ( Promise.resolve({ ok: true, json: () => Promise.resolve({ version: '1', - updatedAt: 1666785400000, + updatedAt: unixTime, values: { global: { colors: { @@ -96,7 +98,73 @@ describe('GenericVersionedStorage', () => { path: '$metadata.json', data: { version: '1', - updatedAt: 1666785400000, + updatedAt: date.toISOString(), + }, + }); + expect(result[2]).toEqual({ + type: 'tokenSet', + path: 'global.json', + name: 'global', + data: { + colors: { + red: { + type: TokenTypes.COLOR, + value: '#ff0000', + }, + }, + }, + }); + }); + + it('can parse date as an iso string', async () => { + const date = new Date(1666785400000); + mockFetch.mockImplementationOnce(() => ( + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + version: '1', + updatedAt: date.toISOString(), + values: { + global: { + colors: { + red: { + type: TokenTypes.COLOR, + value: '#ff0000', + }, + }, + }, + }, + $themes: [ + { + id: 'light', + name: 'Light', + selectedTokenSets: {}, + }, + ], + }), + }) + )); + + const storage = new GenericVersionedStorage(url, GenericVersionedStorageFlow.READ_WRITE_CREATE, defaultHeaders); + const result = await storage.read(); + + expect(result[0]).toEqual({ + type: 'themes', + path: '$themes.json', + data: [ + { + id: 'light', + name: 'Light', + selectedTokenSets: {}, + }, + ], + }); + expect(result[1]).toEqual({ + type: 'metadata', + path: '$metadata.json', + data: { + version: '1', + updatedAt: date.toISOString(), }, }); expect(result[2]).toEqual({ diff --git a/src/storage/__tests__/GitlabTokenStorage.test.ts b/src/storage/__tests__/GitlabTokenStorage.test.ts index 7926697da..7a54104f1 100644 --- a/src/storage/__tests__/GitlabTokenStorage.test.ts +++ b/src/storage/__tests__/GitlabTokenStorage.test.ts @@ -165,22 +165,6 @@ describe('GitlabTokenStorage', () => { expect(mockCreateBranch).toBeCalledWith(35102363, 'development', 'heads/main'); }); - it('canWrite should return true if user is a collaborator by GroupMember', async () => { - mockGetCurrentUser.mockImplementationOnce(() => ( - Promise.resolve({ - id: 11289475, - state: 'active', - }) - )); - mockGetGroupMembers.mockImplementationOnce(() => ( - Promise.resolve({ - access_level: 50, - }) - )); - expect(await storageProvider.canWrite()).toBe(true); - expect(mockGetGroupMembers).toBeCalledWith(51634506, 11289475, { includeInherited: true }); - }); - it('canWrite should return true if user is a collaborator by projectMember', async () => { mockGetCurrentUser.mockImplementationOnce(() => ( Promise.resolve({ @@ -220,7 +204,7 @@ describe('GitlabTokenStorage', () => { provider.enableMultiFile(); await expect(provider.canWrite()) .rejects - .toThrow('Missing Project or Group ID'); + .toThrow('Project ID not assigned'); }); it('canWrite should return false if filePath is a folder and multiFileSync flag is false', async () => { diff --git a/src/utils/alias/__tests__/getAliasValue.test.ts b/src/utils/alias/__tests__/getAliasValue.test.ts index 550569431..9588eb448 100644 --- a/src/utils/alias/__tests__/getAliasValue.test.ts +++ b/src/utils/alias/__tests__/getAliasValue.test.ts @@ -463,11 +463,36 @@ describe('getAliasValue', () => { }, type: TokenTypes.BORDER, }, + { + name: 'clamped', input: 'clamped($xx,2,4)', value: 2, type: TokenTypes.DIMENSION, + }, + { + name: 'clamp', input: 'clamp($xx,2,4)', value: 'clamp(1,2,4)', type: TokenTypes.DIMENSION, + }, + { + name: 'xx', + input: '1', + value: 1, + type: TokenTypes.DIMENSION, + }, + { + name: 'yy', + input: '0.2', + value: 0.2, + type: TokenTypes.DIMENSION, + }, + { + // Note that we cannot do {sample(cubicBezier1D($yy,$yy),$yy)}px to inject px values, it must have a semantic intermediary as shown in the following + name: 'cubicSample', input: 'sample(cubicBezier1D($yy,$yy),$yy)', value: 0.104, type: TokenTypes.DIMENSION, + }, + { + name: 'cubicSamplePx', input: '{cubicSample}px', value: '0.104px', type: TokenTypes.DIMENSION, + }, ]; allTokens.forEach((token) => { it(`alias ${token.name}`, () => { - // @TODO check this test typing + // @TODO check this test typing, expect(getAliasValue({ ...token, value: token.input, type: token.type } as SingleToken, allTokens as unknown as SingleToken[], false)).toEqual(token.value); }); }); diff --git a/src/utils/convertTokensToObject.ts b/src/utils/convertTokensToObject.ts index 7d7ef839e..548e25b24 100644 --- a/src/utils/convertTokensToObject.ts +++ b/src/utils/convertTokensToObject.ts @@ -1,6 +1,7 @@ import set from 'set-value'; import { appendTypeToToken } from '@/app/components/createTokenObj'; import { AnyTokenList, AnyTokenSet } from '@/types/tokens'; +import { getGroupTypeName } from './stringifyTokens'; export default function convertTokensToObject(tokens: Record) { const tokenObj = Object.entries(tokens).reduce>>((acc, [key, val]) => { @@ -8,7 +9,16 @@ export default function convertTokensToObject(tokens: Record { const tokenWithType = appendTypeToToken(token); const { name, ...tokenWithoutName } = tokenWithType; - set(tokenGroupObj, token.name, tokenWithoutName); + if (tokenWithoutName.inheritTypeLevel) { + const { + type, inheritTypeLevel, ...tokenWithoutType + } = tokenWithoutName; + // set type of group level + set(tokenGroupObj, getGroupTypeName(token.name, inheritTypeLevel), tokenWithoutName.type); + set(tokenGroupObj, token.name, tokenWithoutType); + } else { + set(tokenGroupObj, token.name, tokenWithoutName); + } }); acc[key] = tokenGroupObj; return acc; diff --git a/src/utils/math/__tests__/checkAndEvaluateMath.test.ts b/src/utils/math/__tests__/checkAndEvaluateMath.test.ts index d84b66108..0ce0d4785 100644 --- a/src/utils/math/__tests__/checkAndEvaluateMath.test.ts +++ b/src/utils/math/__tests__/checkAndEvaluateMath.test.ts @@ -22,4 +22,35 @@ describe('checkAndEvaluateMath', () => { expect(checkAndEvaluateMath('2px * 2px')).toEqual('2px * 2px'); expect(checkAndEvaluateMath('2px + 10%')).toEqual('2px + 10%'); }); + + it('older clamp continues to work correctly', () => { + expect(checkAndEvaluateMath('clamp(5,2,4)')).toEqual('clamp(5,2,4)'); + expect(checkAndEvaluateMath('clamp(0,2,4)')).toEqual('clamp(0,2,4)'); + expect(checkAndEvaluateMath('clamp(3,2,4)')).toEqual('clamp(3,2,4)'); + }); + + it('clamps expressions correctly', () => { + expect(checkAndEvaluateMath('clamped(5,2,4)')).toEqual(4); + expect(checkAndEvaluateMath('clamped(0,2,4)')).toEqual(2); + expect(checkAndEvaluateMath('clamped(3,2,4)')).toEqual(3); + }); + + it('normalized values as expected', () => { + expect(checkAndEvaluateMath('norm(10,5,15)')).toEqual(0.5); + expect(checkAndEvaluateMath('norm(1,1,88)')).toEqual(0); + expect(checkAndEvaluateMath('norm(3,0,10)')).toEqual(0.3); + }); + + it('lerps values as expected', () => { + expect(checkAndEvaluateMath('lerp(0.5,5,15)')).toEqual(10); + expect(checkAndEvaluateMath('lerp(0,1,88)')).toEqual(1); + expect(checkAndEvaluateMath('lerp(1,47,94)')).toEqual(94); + }); + + it('samples the curve correctly', () => { + expect(checkAndEvaluateMath('sample(cubicBezier1D(1,0),0.5)')).toEqual(0.5); + expect(checkAndEvaluateMath('sample(cubicBezier1D(0.33, 0.66),0.8)')).toEqual(0.797); + expect(checkAndEvaluateMath('sample(cubicBezier1D(0.45,0.34),0.2)')).toEqual(0.213); + expect(checkAndEvaluateMath('sample(cubicBezier1D(0.45,0.34),0.2)')).toEqual(0.213); + }); }); diff --git a/src/utils/math/checkAndEvaluateMath.ts b/src/utils/math/checkAndEvaluateMath.ts index e53926be6..9c86fe53b 100644 --- a/src/utils/math/checkAndEvaluateMath.ts +++ b/src/utils/math/checkAndEvaluateMath.ts @@ -4,8 +4,58 @@ import { Root } from 'postcss-calc-ast-parser/dist/types/ast'; const parser = new Parser(); +/** + * Clamps the value of x between min and max + * @param x + * @param min + * @param max + * @returns + */ +parser.functions.clamped = (x: number, min: number, max: number): number => Math.max(Math.min(x, max), min); + +/** + * One dimensional linear interpolation + * @param x Normalized value between 0 and 1 + * @param min + * @param max + * @returns + */ +parser.functions.lerp = (x: number, start: number, end: number): number => start + (end - start) * x; + +/** + * Returns a normalized value between 0 - 1. + * @param x + * @param start + * @param end + * @returns + */ +parser.functions.norm = (x: number, start: number, end: number): number => (x - start) / (end - start); + +/** + * Creates a one dimensional cubicBezier + * @remarks We have to do a significant overhaul to the system to support multidimensional functions. Seems like expr-eval can support neither array or property accessors + * @param x1 + * @param x2 + * @returns + */ +parser.functions.cubicBezier1D = (x1: number, x2: number) => { + const xx = [0, x1, x2, 1]; + + return (t: number) => { + const coeffs = [(1 - t) ** 3, 3 * (1 - t) ** 2 * t, 3 * (1 - t) * t ** 2, t ** 3]; + const x = coeffs.reduce((acc, c, i) => acc + c * xx[i], 0); + return x; + }; +}; + +// eslint-disable-next-line +parser.functions.sample = (func: Function, ...args: any[]) => { + return func(...args); +}; + export function checkAndEvaluateMath(expr: string) { let calcParsed: Root; + try { calcParsed = calcAstParser.parse(expr); } catch (ex) { @@ -13,8 +63,9 @@ export function checkAndEvaluateMath(expr: string) { } const calcReduced = calcAstParser.reduceExpression(calcParsed); + let unitlessExpr = expr; - let unit = ''; + let unit; if (calcReduced && calcReduced.type !== 'Number') { unitlessExpr = expr.replace(new RegExp(calcReduced.unit, 'ig'), ''); @@ -24,13 +75,13 @@ export function checkAndEvaluateMath(expr: string) { let evaluated: number; try { - evaluated = parser.evaluate(unitlessExpr); + evaluated = parser.evaluate(`${unitlessExpr}`); } catch (ex) { return expr; } try { return unit ? `${evaluated}${unit}` : Number.parseFloat(evaluated.toFixed(3)); - } catch { + } catch (ex) { return expr; } } diff --git a/src/utils/stringifyTokens.ts b/src/utils/stringifyTokens.ts index 917ce2039..ddc9cabad 100644 --- a/src/utils/stringifyTokens.ts +++ b/src/utils/stringifyTokens.ts @@ -2,7 +2,7 @@ import set from 'set-value'; import { appendTypeToToken } from '@/app/components/createTokenObj'; import { AnyTokenList } from '@/types/tokens'; -function getGroupTypeName(tokenName: string, groupLevel: number): string { +export function getGroupTypeName(tokenName: string, groupLevel: number): string { if (tokenName.includes('.')) { const lastDotPosition = tokenName.split('.', groupLevel - 1).join('.').length; return `${tokenName.slice(0, lastDotPosition)}.type`;