diff --git a/cypress/integration/branches.spec.js b/cypress/integration/branches.spec.js index 46ea9bc19..a379abe82 100644 --- a/cypress/integration/branches.spec.js +++ b/cypress/integration/branches.spec.js @@ -18,6 +18,11 @@ describe('Branch switcher', () => { const mockStartupParams = { activeTheme: null, lastOpened: Date.now(), + onboardingExplainer: { + sets: true, + inspect: true, + syncProviders: true, + }, localApiProviders: [provider], licenseKey: null, settings: { diff --git a/cypress/integration/inspector.spec.js b/cypress/integration/inspector.spec.js index d37be51b0..75bbce6c8 100644 --- a/cypress/integration/inspector.spec.js +++ b/cypress/integration/inspector.spec.js @@ -8,6 +8,11 @@ describe('Inspector tokens', () => { const mockStartupParams = { activeTheme: null, lastOpened: Date.now(), + onboardingExplainer: { + sets: true, + inspect: true, + syncProviders: true, + }, localApiProviders: [], licenseKey: null, settings: { @@ -56,20 +61,20 @@ describe('Inspector tokens', () => { version: '5', values: { options: [{ - name: 'sizing.xs', - value: 4, - type: 'sizing' - }, - { - name: 'opacity.50', - value: '50%', - type: 'opacity' - }, - { - name: 'font-size.12', - value: '12px', - type: 'fontSizes' - } + name: 'sizing.xs', + value: 4, + type: 'sizing' + }, + { + name: 'opacity.50', + value: '50%', + type: 'opacity' + }, + { + name: 'font-size.12', + value: '12px', + type: 'fontSizes' + } ], global: [{ name: 'sizing.xs', @@ -80,35 +85,35 @@ describe('Inspector tokens', () => { }); cy.receiveSelectionValues({ selectionValues: [{ - category: "sizing", - type: "sizing", - value: "sizing.xs", - nodes: [{ - id: "1", - name: "Rectangle", - type: "RECTANGLE", - }], - }, - { - category: "opacity", - type: "opacity", - value: "opacity.50", - nodes: [{ - id: "1", - name: "Rectangle", - type: "RECTANGLE", - }], - }, - { - category: "fontSizes", - type: "fontSizes", - value: "font-size.12", - nodes: [{ - id: "1", - name: "Rectangle", - type: "RECTANGLE", - }], - }, + category: "sizing", + type: "sizing", + value: "sizing.xs", + nodes: [{ + id: "1", + name: "Rectangle", + type: "RECTANGLE", + }], + }, + { + category: "opacity", + type: "opacity", + value: "opacity.50", + nodes: [{ + id: "1", + name: "Rectangle", + type: "RECTANGLE", + }], + }, + { + category: "fontSizes", + type: "fontSizes", + value: "font-size.12", + nodes: [{ + id: "1", + name: "Rectangle", + type: "RECTANGLE", + }], + }, ], selectedNodes: 1, mainNodeSelectionValues: { @@ -132,25 +137,25 @@ describe('Inspector tokens', () => { version: '5', values: { options: [{ - name: 'sizing.xs', - value: 4, - type: 'sizing' - }, - { - name: 'opacity.50', - value: '50%', - type: 'opacity' - }, - { - name: 'opacity.100', - value: '100%', - type: 'opacity' - }, - { - name: 'font-size.12', - value: '12px', - type: 'fontSizes' - } + name: 'sizing.xs', + value: 4, + type: 'sizing' + }, + { + name: 'opacity.50', + value: '50%', + type: 'opacity' + }, + { + name: 'opacity.100', + value: '100%', + type: 'opacity' + }, + { + name: 'font-size.12', + value: '12px', + type: 'fontSizes' + } ], global: [{ name: 'sizing.xs', @@ -161,35 +166,35 @@ describe('Inspector tokens', () => { }); cy.receiveSelectionValues({ selectionValues: [{ - category: "sizing", - type: "sizing", - value: "sizing.xs", - nodes: [{ - id: "1", - name: "Rectangle", - type: "RECTANGLE", - }], - }, - { - category: "opacity", - type: "opacity", - value: "opacity.50", - nodes: [{ - id: "1", - name: "Rectangle", - type: "RECTANGLE", - }], - }, - { - category: "fontSizes", - type: "fontSizes", - value: "font-size.12", - nodes: [{ - id: "1", - name: "Rectangle", - type: "RECTANGLE", - }], - }, + category: "sizing", + type: "sizing", + value: "sizing.xs", + nodes: [{ + id: "1", + name: "Rectangle", + type: "RECTANGLE", + }], + }, + { + category: "opacity", + type: "opacity", + value: "opacity.50", + nodes: [{ + id: "1", + name: "Rectangle", + type: "RECTANGLE", + }], + }, + { + category: "fontSizes", + type: "fontSizes", + value: "font-size.12", + nodes: [{ + id: "1", + name: "Rectangle", + type: "RECTANGLE", + }], + }, ], selectedNodes: 1, mainNodeSelectionValues: { @@ -205,35 +210,35 @@ describe('Inspector tokens', () => { cy.get(`input[name=value]`).type('$opacity.100').type('{enter}'); cy.receiveSelectionValues({ selectionValues: [{ - category: "sizing", - type: "sizing", - value: "sizing.xs", - nodes: [{ - id: "1", - name: "Rectangle", - type: "RECTANGLE", - }], - }, - { - category: "opacity", - type: "opacity", - value: "opacity.100", - nodes: [{ - id: "1", - name: "Rectangle", - type: "RECTANGLE", - }], - }, - { - category: "fontSizes", - type: "fontSizes", - value: "font-size.12", - nodes: [{ - id: "1", - name: "Rectangle", - type: "RECTANGLE", - }], - }, + category: "sizing", + type: "sizing", + value: "sizing.xs", + nodes: [{ + id: "1", + name: "Rectangle", + type: "RECTANGLE", + }], + }, + { + category: "opacity", + type: "opacity", + value: "opacity.100", + nodes: [{ + id: "1", + name: "Rectangle", + type: "RECTANGLE", + }], + }, + { + category: "fontSizes", + type: "fontSizes", + value: "font-size.12", + nodes: [{ + id: "1", + name: "Rectangle", + type: "RECTANGLE", + }], + }, ], selectedNodes: 1, mainNodeSelectionValues: { diff --git a/cypress/integration/manage-themes.spec.js b/cypress/integration/manage-themes.spec.js index d688c55b4..61c548ef3 100644 --- a/cypress/integration/manage-themes.spec.js +++ b/cypress/integration/manage-themes.spec.js @@ -5,14 +5,19 @@ import { UpdateMode } from '@/constants/UpdateMode'; const createTokenSet = ({ name }) => { cy.get('[data-cy="button-new-token-set"]').click({ timeout: 1000 }) - .get('[data-cy="token-set-input"]') - .type(name).type('{enter}'); + .get('[data-cy="token-set-input"]') + .type(name).type('{enter}'); }; describe('TokenListing', () => { const mockStartupParams = { activeTheme: null, lastOpened: Date.now(), + onboardingExplainer: { + sets: true, + inspect: true, + syncProviders: true, + }, localApiProviders: [], licenseKey: null, settings: { diff --git a/cypress/integration/startup.spec.js b/cypress/integration/startup.spec.js index b5192e2ed..f3d9e884d 100644 --- a/cypress/integration/startup.spec.js +++ b/cypress/integration/startup.spec.js @@ -10,6 +10,11 @@ describe('Loads application', () => { const mockStartupParams = { activeTheme: null, lastOpened: Date.now(), + onboardingExplainer: { + sets: true, + inspect: true, + syncProviders: true, + }, localApiProviders: [], licenseKey: null, settings: { diff --git a/cypress/integration/themes.spec.js b/cypress/integration/themes.spec.js index a3e974f15..f40d6dd87 100644 --- a/cypress/integration/themes.spec.js +++ b/cypress/integration/themes.spec.js @@ -7,6 +7,11 @@ describe('Themes', () => { const mockStartupParams = { activeTheme: null, lastOpened: Date.now(), + onboardingExplainer: { + sets: true, + inspect: true, + syncProviders: true, + }, localApiProviders: [], licenseKey: null, settings: { diff --git a/cypress/integration/tokens.spec.js b/cypress/integration/tokens.spec.js index 8e387c55a..2512b6a91 100644 --- a/cypress/integration/tokens.spec.js +++ b/cypress/integration/tokens.spec.js @@ -41,6 +41,11 @@ describe('TokenListing', () => { const mockStartupParams = { activeTheme: null, lastOpened: Date.now(), + onboardingExplainer: { + sets: true, + inspect: true, + syncProviders: true, + }, localApiProviders: [], licenseKey: null, settings: { @@ -236,43 +241,43 @@ describe('TokenListing', () => { version: '5', values: { options: [{ - name: 'sizing.xs', - value: 4, - type: 'sizing' + name: 'sizing.xs', + value: 4, + type: 'sizing' + }, + { + name: 'boxshadow.single', + value: { + blur: "3", + color: "red", + spread: "3", + type: "innerShadow", + x: "3", + y: "3", }, - { - name: 'boxshadow.single', - value: { - blur: "3", - color: "red", - spread: "3", - type: "innerShadow", - x: "3", - y: "3", - }, - type: 'boxShadow' + type: 'boxShadow' + }, + { + name: 'boxshadow.multi', + value: [{ + blur: "3", + color: "red", + spread: "3", + type: "innerShadow", + x: "3", + y: "3", }, { - name: 'boxshadow.multi', - value: [{ - blur: "3", - color: "red", - spread: "3", - type: "innerShadow", - x: "3", - y: "3", - }, - { - blur: "1", - color: "blue", - spread: "1", - type: "dropShadow", - x: "1", - y: "1", - } - ], - type: 'boxShadow' + blur: "1", + color: "blue", + spread: "1", + type: "dropShadow", + x: "1", + y: "1", } + ], + type: 'boxShadow' + } ], global: [{ name: 'sizing.xs', @@ -355,37 +360,37 @@ describe('TokenListing', () => { version: '5', values: { options: [{ - name: 'sizing.xs', - value: 4, - type: 'sizing' - }, { - name: 'typography.heading', - value: { - fontFamily: "Arial", - fontSize: "12px", - fontWeight: "bold", - letterSpacing: "1", - lineHeight: "1", - paragraphSpacing: "1", - textCase: "none", - textDecoration: "underline", - }, - type: 'typography' + name: 'sizing.xs', + value: 4, + type: 'sizing' + }, { + name: 'typography.heading', + value: { + fontFamily: "Arial", + fontSize: "12px", + fontWeight: "bold", + letterSpacing: "1", + lineHeight: "1", + paragraphSpacing: "1", + textCase: "none", + textDecoration: "underline", }, - { - name: 'typography.label', - value: { - fontFamily: "Helvetica", - fontSize: "24px", - fontWeight: "light", - letterSpacing: "2", - lineHeight: "2", - paragraphSpacing: "2", - textCase: "none", - textDecoration: "none", - }, - type: 'typography' - } + type: 'typography' + }, + { + name: 'typography.label', + value: { + fontFamily: "Helvetica", + fontSize: "24px", + fontWeight: "light", + letterSpacing: "2", + lineHeight: "2", + paragraphSpacing: "2", + textCase: "none", + textDecoration: "none", + }, + type: 'typography' + } ], global: [{ name: 'sizing.xs', diff --git a/flags.d.ts b/flags.d.ts index e1bf9a934..26be3792f 100644 --- a/flags.d.ts +++ b/flags.d.ts @@ -6,5 +6,7 @@ declare module 'launchdarkly-js-sdk-common' { compositionTokens?: boolean; bitbucketSync?: boolean; tokenFlowButton?: boolean; + swapStylesAlpha?: boolean; + genericVersionedAlpha?: boolean; } } diff --git a/package.json b/package.json index be7518467..34525d702 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "tokens-studio-for-figma", "version": "1.0.0", - "plugin_version": "128", + "plugin_version": "129-rc3", "description": "Tokens Studio for Figma", "license": "MIT", "scripts": { @@ -12,7 +12,7 @@ "build-js": "webpack --mode=production", "build": "yarn build-css && yarn build-js", "build:cy": "cross-env LAUNCHDARKLY_FLAGS=tokenThemes,gitBranchSelector,multiFileSync,tokenFlowButton yarn build", - "build:watch": "concurrently 'yarn watch-css' 'yarn watch-js'", + "build:watch": "concurrently -r \"yarn watch-css\" \"yarn watch-js\"", "build-transform": "webpack --mode=production --config webpack-transform.config.js", "prettier:format": "prettier --write 'src/**/*.{js,jsx,ts,tsx,css,json}' ", "test": "cross-env LAUNCHDARKLY_FLAGS=tokenThemes,gitBranchSelector,multiFileSync,tokenFlowButton jest", @@ -114,7 +114,7 @@ "@babel/preset-env": "^7.12.16", "@babel/preset-react": "^7.12.13", "@babel/preset-typescript": "^7.12.16", - "@figma/plugin-typings": "^1.47.0", + "@figma/plugin-typings": "^1.55.1", "@storybook/addon-actions": "^6.5.8", "@storybook/addon-docs": "^6.5.8", "@storybook/addon-essentials": "^6.5.8", @@ -145,6 +145,7 @@ "cross-env": "^7.0.3", "css-loader": "^3.1.0", "cypress": "^6.6.0", + "deep-set-in": "^2.1.3", "dotenv": "^16.0.1", "eslint": "^8.2.0", "eslint-config-airbnb": "19.0.2", diff --git a/src/app/components/AddLicenseKey/AddLicenseKey.test.tsx b/src/app/components/AddLicenseKey/AddLicenseKey.test.tsx index d5758958d..176475876 100644 --- a/src/app/components/AddLicenseKey/AddLicenseKey.test.tsx +++ b/src/app/components/AddLicenseKey/AddLicenseKey.test.tsx @@ -23,6 +23,11 @@ const mockStartupParams: StartupMessage = { type: AsyncMessageTypes.STARTUP, activeTheme: null, lastOpened: Date.now(), + onboardingExplainer: { + sets: true, + inspect: true, + syncProviders: true, + }, localApiProviders: [], licenseKey: null, settings: { diff --git a/src/app/components/AppContainer/__tests__/AppContainerIntegration.test.tsx b/src/app/components/AppContainer/__tests__/AppContainerIntegration.test.tsx index 86d138c75..f23be27f2 100644 --- a/src/app/components/AppContainer/__tests__/AppContainerIntegration.test.tsx +++ b/src/app/components/AppContainer/__tests__/AppContainerIntegration.test.tsx @@ -114,6 +114,11 @@ const mockStartupParams: Omit = { type: AsyncMessageTypes.STARTUP, activeTheme: null, lastOpened: Date.now(), + onboardingExplainer: { + sets: true, + inspect: true, + syncProviders: true, + }, localApiProviders: [], settings: mockSettings, storageType: { diff --git a/src/app/components/AppContainer/startupProcessSteps/__tests__/savePluginDataFactory.test.ts b/src/app/components/AppContainer/startupProcessSteps/__tests__/savePluginDataFactory.test.ts index fcd76d70b..506e34c3a 100644 --- a/src/app/components/AppContainer/startupProcessSteps/__tests__/savePluginDataFactory.test.ts +++ b/src/app/components/AppContainer/startupProcessSteps/__tests__/savePluginDataFactory.test.ts @@ -17,6 +17,11 @@ describe('savePluginDataFactory', () => { name: 'Jan Six', }, lastOpened: Date.now(), + onboardingExplainer: { + sets: true, + inspect: true, + syncProviders: true, + }, settings: { width: 500, height: 500, diff --git a/src/app/components/AppContainer/startupProcessSteps/savePluginDataFactory.ts b/src/app/components/AppContainer/startupProcessSteps/savePluginDataFactory.ts index a1d74d0ce..51b1f3ed6 100644 --- a/src/app/components/AppContainer/startupProcessSteps/savePluginDataFactory.ts +++ b/src/app/components/AppContainer/startupProcessSteps/savePluginDataFactory.ts @@ -20,6 +20,9 @@ export function savePluginDataFactory(dispatch: Dispatch, params: StartupMessage dispatch.userState.setUserId(user.figmaId); dispatch.userState.setUserName(user.name); dispatch.uiState.setLastOpened(params.lastOpened); + dispatch.uiState.setOnboardingExplainerSets(params.onboardingExplainer.sets); + dispatch.uiState.setOnboardingExplainerSyncProviders(params.onboardingExplainer.syncProviders); + dispatch.uiState.setOnboardingExplainerInspect(params.onboardingExplainer.inspect); dispatch.settings.setUISettings(settings); identify(user); } else { diff --git a/src/app/components/ApplySelector.test.tsx b/src/app/components/ApplySelector.test.tsx new file mode 100644 index 000000000..792cddabb --- /dev/null +++ b/src/app/components/ApplySelector.test.tsx @@ -0,0 +1,117 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import userEvent from '@testing-library/user-event'; +import { + act, createMockStore, render, +} from '../../../tests/config/setupTest'; +import ApplySelector from './ApplySelector'; + +const mockStore = createMockStore({}); +const renderStore = () => render( + + + , +); + +describe('ApplySelector', () => { + it('should work', async () => { + const result = renderStore(); + + const trigger = await result.getByTestId('apply-selector'); + await act(async () => { + await userEvent.click(trigger); + const updateChanges = result.getByTestId('update-on-change'); + const updateRemote = result.getByTestId('update-remote'); + const updateStyles = result.getByTestId('update-styles'); + const swapStylesAlpha = result.queryByTestId('swap-styles-alpha'); + + expect(updateChanges).toBeInTheDocument(); + expect(updateRemote).toBeInTheDocument(); + expect(updateStyles).toBeInTheDocument(); + expect(swapStylesAlpha).toBeNull(); + }); + }); + + it('should call setUpdateMode', async () => { + const updateModeSpy = jest.spyOn(mockStore.dispatch.settings, 'setUpdateMode'); + const result = renderStore(); + + const trigger = await result.getByTestId('apply-selector'); + await act(async () => { + await userEvent.click(trigger); + const applyToDocument = result.getByTestId('apply-to-document'); + await userEvent.click(applyToDocument, { pointerEventsCheck: 0 }); + await userEvent.click(trigger); + const applyToPage = result.getByTestId('apply-to-page'); + await userEvent.click(applyToPage, { pointerEventsCheck: 0 }); + await userEvent.click(trigger); + const applyToSelection = result.getByTestId('apply-to-selection'); + await userEvent.click(applyToSelection, { pointerEventsCheck: 0 }); + expect(updateModeSpy).toBeCalledTimes(3); + }); + }); + it('should call updateOnChanges', async () => { + const updateOnChangeSpy = jest.spyOn(mockStore.dispatch.settings, 'setUpdateOnChange'); + const result = renderStore(); + + const trigger = await result.getByTestId('apply-selector'); + await act(async () => { + await userEvent.click(trigger); + const updateChanges = result.getByTestId('update-on-change'); + await userEvent.click(updateChanges, { pointerEventsCheck: 0 }); + expect(updateOnChangeSpy).toBeCalledTimes(1); + }); + }); + it('should call updateRemote', async () => { + const updateRemoteSpy = jest.spyOn(mockStore.dispatch.settings, 'setUpdateRemote'); + const result = renderStore(); + + const trigger = await result.getByTestId('apply-selector'); + await act(async () => { + await userEvent.click(trigger); + const updateChanges = result.getByTestId('update-remote'); + await userEvent.click(updateChanges, { pointerEventsCheck: 0 }); + expect(updateRemoteSpy).toBeCalledTimes(1); + }); + }); + it('should call updateStyles', async () => { + const updateStylesSpy = jest.spyOn(mockStore.dispatch.settings, 'setUpdateStyles'); + const result = renderStore(); + + const trigger = await result.getByTestId('apply-selector'); + await act(async () => { + await userEvent.click(trigger); + const updateChanges = result.getByTestId('update-styles'); + await userEvent.click(updateChanges, { pointerEventsCheck: 0 }); + expect(updateStylesSpy).toBeCalledTimes(1); + }); + }); + + it('with swap styles feature flag', async () => { + process.env.LAUNCHDARKLY_FLAGS = 'swapStylesAlpha'; + const result = renderStore(); + + const trigger = await result.getByTestId('apply-selector'); + await act(async () => { + await userEvent.click(trigger); + + const swapStylesAlpha = result.getByTestId('swap-styles-alpha'); + expect(swapStylesAlpha).toBeInTheDocument(); + }); + }); + + it('should call swapStyles with feature flag', async () => { + process.env.LAUNCHDARKLY_FLAGS = 'swapStylesAlpha'; + + const shouldSwapStylesSpy = jest.spyOn(mockStore.dispatch.settings, 'setShouldSwapStyles'); + const result = renderStore(); + + const trigger = await result.getByTestId('apply-selector'); + await act(async () => { + await userEvent.click(trigger); + const updateChanges = result.getByTestId('swap-styles-alpha'); + await userEvent.click(updateChanges, { pointerEventsCheck: 0 }); + expect(shouldSwapStylesSpy).toBeCalledTimes(1); + }); + }); +}); diff --git a/src/app/components/ApplySelector.tsx b/src/app/components/ApplySelector.tsx index b410918e2..dd8c230e2 100644 --- a/src/app/components/ApplySelector.tsx +++ b/src/app/components/ApplySelector.tsx @@ -16,14 +16,17 @@ import IconChevronDown from '@/icons/chevrondown.svg'; import { settingsStateSelector } from '@/selectors'; import { isEqual } from '@/utils/isEqual'; import { UpdateMode } from '@/constants/UpdateMode'; +import { useFlags } from './LaunchDarkly'; export default function ApplySelector() { const { - updateMode, updateRemote, updateOnChange, updateStyles, + updateMode, updateRemote, updateOnChange, updateStyles, shouldSwapStyles, } = useSelector(settingsStateSelector, isEqual); + const { swapStylesAlpha } = useFlags(); + const { - setUpdateMode, setUpdateOnChange, setUpdateRemote, setUpdateStyles, + setUpdateMode, setUpdateOnChange, setUpdateRemote, setUpdateStyles, setShouldSwapStyles, } = useDispatch().settings; const handleApplySelection = React.useCallback(() => { @@ -50,9 +53,13 @@ export default function ApplySelector() { setUpdateStyles(!updateStyles); }, [updateStyles, setUpdateStyles]); + const handleShouldSwapStyles = React.useCallback(() => { + setShouldSwapStyles(!shouldSwapStyles); + }, [shouldSwapStyles, setShouldSwapStyles]); + return ( - + Apply to {' '} @@ -63,19 +70,19 @@ export default function ApplySelector() { - + Apply to page - + Apply to document - + @@ -85,24 +92,32 @@ export default function ApplySelector() { - + Update on change - + Update remote - + Update styles + {swapStylesAlpha && ( + + + + + Swap styles (Alpha) + + )} ); diff --git a/src/app/components/Badge.tsx b/src/app/components/Badge.tsx new file mode 100644 index 000000000..d82524a31 --- /dev/null +++ b/src/app/components/Badge.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { styled } from '@/stitches.config'; + +export const StyledBadge = styled('div', { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: '$xsmall', + padding: '$3', + borderRadius: '$badge', + backgroundColor: '$interaction', + lineHeight: 1, + color: '$onInteraction', + fontWeight: '$bold', +}); + +type Props = { + text: string; +}; + +export default function Badge({ text }: Props) { + return ( + {text} + ); +} diff --git a/src/app/components/BorderTokenDownShiftInput.tsx b/src/app/components/BorderTokenDownShiftInput.tsx new file mode 100644 index 000000000..9d299511e --- /dev/null +++ b/src/app/components/BorderTokenDownShiftInput.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { ResolveTokenValuesResult } from '@/plugin/tokenHelpers'; +import DownshiftInput from './DownshiftInput'; +import { getLabelForProperty } from '@/utils/getLabelForProperty'; + +const mapTypeToPlaceHolder = { + color: 'Border color', + width: 'Border width', + style: 'solid | dashed', +}; + +export default function BorderTokenDownShiftInput({ + name, + value, + type, + resolvedTokens, + handleChange, + setInputValue, + handleToggleInputHelper, +}: { + name: string, + value: string; + type: string; + resolvedTokens: ResolveTokenValuesResult[]; + handleChange: React.ChangeEventHandler; + setInputValue: (newInputValue: string, property: string) => void; + handleToggleInputHelper?: () => void; +}) { + const onChange = React.useCallback((e: React.ChangeEvent) => handleChange(e), [handleChange]); + const handleBorderDownShiftInputChange = React.useCallback((newInputValue: string) => setInputValue(newInputValue, name), [name, setInputValue]); + const getIconComponent = React.useMemo(() => getLabelForProperty(name), [name]); + return ( + + {value} + + ) + } + suffix + /> + ); +} diff --git a/src/app/components/BorderTokenForm.tsx b/src/app/components/BorderTokenForm.tsx new file mode 100644 index 000000000..57f1b81b0 --- /dev/null +++ b/src/app/components/BorderTokenForm.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { useUIDSeed } from 'react-uid'; +import get from 'just-safe-get'; +import { EditTokenObject } from '@/types/tokens'; +import Heading from './Heading'; +import { TokenTypes } from '@/constants/TokenTypes'; +import { ResolveTokenValuesResult } from '@/plugin/tokenHelpers'; +import Stack from './Stack'; +import BorderTokenDownShiftInput from './BorderTokenDownShiftInput'; +import ColorPicker from './ColorPicker'; + +const propertyTypes = { + color: TokenTypes.COLOR, + width: TokenTypes.BORDER_WIDTH, + style: 'strokeStyle', +}; + +export default function BorderTokenForm({ + internalEditToken, + resolvedTokens, + handleBorderValueChange, + handleBorderValueDownShiftInputChange, +}: { + internalEditToken: Extract; + resolvedTokens: ResolveTokenValuesResult[]; + handleBorderValueChange: React.ChangeEventHandler; + handleBorderValueDownShiftInputChange: (newInputValue: string, property: string) => void; +}) { + const seed = useUIDSeed(); + const [inputHelperOpen, setInputHelperOpen] = React.useState(false); + + const handleToggleInputHelper = React.useCallback(() => setInputHelperOpen(!inputHelperOpen), [inputHelperOpen]); + const onColorChange = React.useCallback((color: string) => { + handleBorderValueDownShiftInputChange(color, 'color'); + }, [handleBorderValueChange]); + + return ( + + + Value + + + {Object.entries(internalEditToken.schema.schemas.value.properties ?? {}).map(([key], keyIndex) => ( + <> + + {inputHelperOpen && key === 'color' && ( + + )} + + ))} + + + + ); +} diff --git a/src/app/components/BrokenReferenceIndicator.tsx b/src/app/components/BrokenReferenceIndicator.tsx index dab45a36e..19e4446d0 100644 --- a/src/app/components/BrokenReferenceIndicator.tsx +++ b/src/app/components/BrokenReferenceIndicator.tsx @@ -1,22 +1,7 @@ import React from 'react'; -import { styled } from '@/stitches.config'; -import IconBrokenLink from '@/icons/brokenlink.svg'; import { SingleToken } from '@/types/tokens'; import { TokensContext } from '@/context'; - -const StyledIndicator = styled('div', { - position: 'absolute', - top: '3px', - right: '3px', - borderRadius: '100%', - border: '1px solid $bgDefault', - background: '$dangerFg', - width: '6px', - height: '6px', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', -}); +import StyledBrokenReferenceIndicator from './StyledBrokenReferenceIndicator'; type Props = { token: SingleToken; @@ -31,9 +16,7 @@ export default function BrokenReferenceIndicator({ token }: Props) { if (failedToResolve) { return ( - - - + ); } return null; diff --git a/src/app/components/Button/Button.tsx b/src/app/components/Button/Button.tsx index 028d41e94..e14026118 100644 --- a/src/app/components/Button/Button.tsx +++ b/src/app/components/Button/Button.tsx @@ -6,7 +6,7 @@ import { StyledButton } from './StyledButton'; export interface ButtonProps { type?: 'button' | 'submit'; form?: string; - variant: 'secondary' | 'primary' | 'ghost'; + variant: 'secondary' | 'primary' | 'ghost' | 'danger'; onClick?: () => void; size?: 'large' | 'small'; href?: string; @@ -49,7 +49,7 @@ const Button: React.FC = ({ href={href} data-cy={id} data-testid={id} - > + > + {count} + + + ); +} diff --git a/src/app/components/DownshiftInput/DownShiftInput.test.tsx b/src/app/components/DownshiftInput/DownShiftInput.test.tsx index 6f8f8fc81..82fb4b8cd 100644 --- a/src/app/components/DownshiftInput/DownShiftInput.test.tsx +++ b/src/app/components/DownshiftInput/DownShiftInput.test.tsx @@ -2,6 +2,8 @@ import React from 'react'; import { DownshiftInput } from './DownshiftInput'; import { render } from '../../../../tests/config/setupTest'; import { SingleToken } from '@/types/tokens'; +import { TokenTypes } from '@/constants/TokenTypes'; +import { BoxShadowTypes } from '@/constants/BoxShadowTypes'; const resolvedTokens = [ { @@ -53,6 +55,42 @@ const resolvedTokens = [ type: 'sizing', value: 1, }, + { + internal__Parent: 'core', + name: 'typography.regular', + rawValue: { + fontFamily: 'arial', + fontSize: '12px', + fontWeight: 'bold', + }, + type: TokenTypes.TYPOGRAPHY, + value: { + fontFamily: 'arial', + fontSize: '12px', + fontWeight: 'bold', + }, + }, + { + internal__Parent: 'core', + name: 'boxShadow.regular', + rawValue: [{ + x: '2', + y: '2', + blur: '2', + spread: '2', + color: '#000000', + type: BoxShadowTypes.DROP_SHADOW, + }], + type: TokenTypes.BOX_SHADOW, + value: [{ + x: '2', + y: '2', + blur: '2', + spread: '2', + color: '#000000', + type: BoxShadowTypes.DROP_SHADOW, + }], + }, ] as SingleToken[]; const mockSetInputValue = jest.fn(); @@ -119,6 +157,6 @@ describe('DownShiftInput', () => { />, ); result.getByTestId('downshift-input-suffix-button').click(); - expect(result.getAllByTestId('downshift-input-item')).toHaveLength(7); + expect(result.getAllByTestId('downshift-input-item')).toHaveLength(9); }); }); diff --git a/src/app/components/DownshiftInput/DownshiftInput.tsx b/src/app/components/DownshiftInput/DownshiftInput.tsx index 5156eab8f..418f22b7f 100644 --- a/src/app/components/DownshiftInput/DownshiftInput.tsx +++ b/src/app/components/DownshiftInput/DownshiftInput.tsx @@ -12,6 +12,7 @@ import { StyledDownshiftInput } from './StyledDownshiftInput'; import Tooltip from '../Tooltip'; import { Properties } from '@/constants/Properties'; import { isDocumentationType } from '@/utils/is/isDocumentationType'; +import { useReferenceTokenType } from '@/app/hooks/useReferenceTokenType'; const StyledDropdown = styled('div', { position: 'absolute', @@ -97,6 +98,7 @@ interface DownShiftProps { resolvedTokens: ResolveTokenValuesResult[]; setInputValue(value: string): void; handleChange: React.ChangeEventHandler; + handleBlur?: React.ChangeEventHandler; } export const DownshiftInput: React.FunctionComponent = ({ @@ -113,13 +115,15 @@ export const DownshiftInput: React.FunctionComponent = ({ setInputValue, resolvedTokens, handleChange, + handleBlur, }) => { const [showAutoSuggest, setShowAutoSuggest] = React.useState(false); - const [isFirstLoading, setisFirstLoading] = React.useState(true); + const [isFirstLoading, setIsFirstLoading] = React.useState(true); const filteredValue = useMemo(() => ((showAutoSuggest || typeof value !== 'string') ? '' : value?.replace(/[{}$]/g, '')), [ showAutoSuggest, value, ]); // removing non-alphanumberic except . from the input value + const referenceTokenTypes = useReferenceTokenType(type as TokenTypes); const getHighlightedText = useCallback((text: string, highlight: string) => { // Split on highlight term and include term into parts, ignore case const parts = text.split(new RegExp(`(${highlight.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&')})`, 'gi')); @@ -151,7 +155,7 @@ export const DownshiftInput: React.FunctionComponent = ({ .filter( (token: SingleToken) => !filteredValue || token.name.toLowerCase().includes(filteredValue.toLowerCase()), ) - .filter((token: SingleToken) => token?.type === type && token.name !== initialName).sort((a, b) => ( + .filter((token: SingleToken) => referenceTokenTypes.includes(token?.type) && token.name !== initialName).sort((a, b) => ( a.name.localeCompare(b.name) )); }, @@ -193,10 +197,16 @@ export const DownshiftInput: React.FunctionComponent = ({ }, [showAutoSuggest]); const handleInputChange = React.useCallback((e: React.ChangeEvent) => { - setisFirstLoading(false); + setIsFirstLoading(false); handleChange(e); }, [handleChange]); + const handleInputBlur = React.useCallback((e: React.ChangeEvent) => { + if (handleBlur) { + handleBlur(e); + } + }, [handleBlur]); + return ( {({ @@ -218,6 +228,7 @@ export const DownshiftInput: React.FunctionComponent = ({ value={value} onChange={handleInputChange} getInputProps={getInputProps} + onBlur={handleInputBlur} /> {suffix && ( @@ -241,7 +252,6 @@ export const DownshiftInput: React.FunctionComponent = ({ backgroundColor: highlightedIndex === index ? '$interaction' : '$bgDefault', }} isFocused={highlightedIndex === index} - > {type === 'color' && ( diff --git a/src/app/components/DownshiftInput/StyledDownshiftInput.tsx b/src/app/components/DownshiftInput/StyledDownshiftInput.tsx index 608075d5d..33ebb7a1e 100644 --- a/src/app/components/DownshiftInput/StyledDownshiftInput.tsx +++ b/src/app/components/DownshiftInput/StyledDownshiftInput.tsx @@ -10,6 +10,7 @@ type Props = { suffix?: React.ReactNode; getInputProps: (options?: T) => T & GetInputPropsOptions; onChange?: React.ChangeEventHandler; + onBlur?: React.ChangeEventHandler; }; export const StyledDownshiftInput: React.FC = ({ @@ -19,6 +20,7 @@ export const StyledDownshiftInput: React.FC = ({ placeholder, suffix, onChange, + onBlur, getInputProps, }) => { const { ref, size, ...inputProps } = getInputProps({ @@ -27,6 +29,7 @@ export const StyledDownshiftInput: React.FC = ({ placeholder, value: value || '', onChange, + onBlur, }); return ( diff --git a/src/app/components/EditTokenForm.tsx b/src/app/components/EditTokenForm.tsx index b94d6e5b0..9df0076ef 100644 --- a/src/app/components/EditTokenForm.tsx +++ b/src/app/components/EditTokenForm.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { track } from '@/utils/analytics'; +import { useShortcut } from '@/hooks/useShortcut'; import { Dispatch } from '../store'; import useManageTokens from '../store/useManageTokens'; import CompositionTokenForm from './CompositionTokenForm'; @@ -8,8 +9,10 @@ import Input from './Input'; import ColorPicker from './ColorPicker'; import useConfirm from '../hooks/useConfirm'; import useTokens from '../store/useTokens'; -import { EditTokenObject, SingleBoxShadowToken, SingleToken } from '@/types/tokens'; -import { checkIfContainsAlias, getAliasValue } from '@/utils/alias'; +import { + EditTokenObject, SingleBoxShadowToken, SingleDimensionToken, SingleToken, +} from '@/types/tokens'; +import { checkIfAlias, checkIfContainsAlias, getAliasValue } from '@/utils/alias'; import { ResolveTokenValuesResult } from '@/plugin/tokenHelpers'; import { activeTokenSetSelector, updateModeSelector, editTokenSelector, themesListSelector, @@ -27,6 +30,7 @@ import { EditTokenFormStatus } from '@/constants/EditTokenFormStatus'; import { StyleOptions } from '@/constants/StyleOptions'; import Textarea from './Textarea'; import Heading from './Heading'; +import BorderTokenForm from './BorderTokenForm'; import Box from './Box'; type Props = { @@ -37,7 +41,6 @@ type Choice = { key: string; label: string; enabled?: boolean, unique?: boolean // @TODO this needs to be reviewed from a typings perspective + performance function EditTokenForm({ resolvedTokens }: Props) { - const firstInput = React.useRef(null); const activeTokenSet = useSelector(activeTokenSetSelector); const editToken = useSelector(editTokenSelector); const themes = useSelector(themesListSelector); @@ -50,11 +53,16 @@ function EditTokenForm({ resolvedTokens }: Props) { const [internalEditToken, setInternalEditToken] = React.useState(editToken); const { confirm } = useConfirm(); + const isValidDimensionToken = React.useMemo(() => internalEditToken.type === TokenTypes.DIMENSION && (internalEditToken.value?.endsWith('px') || internalEditToken.value?.endsWith('rem') || checkIfAlias(internalEditToken as SingleDimensionToken, resolvedTokens)), [internalEditToken, resolvedTokens, checkIfAlias]); + const isValid = React.useMemo(() => { if (internalEditToken?.type === TokenTypes.COMPOSITION && internalEditToken.value && (internalEditToken.value.hasOwnProperty('') || Object.keys(internalEditToken.value).length === 0)) { return false; } + if (internalEditToken.type === TokenTypes.DIMENSION) { + return true; + } return internalEditToken?.value && !error; }, [internalEditToken, error]); @@ -68,6 +76,7 @@ function EditTokenForm({ resolvedTokens }: Props) { const hasAnotherTokenThatStartsWithName = React.useMemo( () => resolvedTokens .filter((t) => t.internal__Parent === activeTokenSet) + .filter((t) => t.name !== internalEditToken?.initialName) .find((t) => t.name.startsWith(`${internalEditToken?.name}.`)), [internalEditToken, resolvedTokens, activeTokenSet], ); @@ -110,6 +119,15 @@ function EditTokenForm({ resolvedTokens }: Props) { [internalEditToken], ); + const handleBlur = React.useCallback>( + () => { + if (internalEditToken.type === TokenTypes.DIMENSION && !isValidDimensionToken) { + setError('Value must include either px or rem'); + } + }, + [internalEditToken, isValidDimensionToken], + ); + const handleBoxShadowValueChange = React.useCallback( (shadow: SingleBoxShadowToken['value']) => { setError(null); @@ -186,6 +204,31 @@ function EditTokenForm({ resolvedTokens }: Props) { } }, [internalEditToken]); + const handleBorderValueChange = React.useCallback>( + (e) => { + e.persist(); + if (internalEditToken?.type === TokenTypes.BORDER && typeof internalEditToken?.value !== 'string') { + setInternalEditToken({ + ...internalEditToken, + value: { + ...internalEditToken.value, + [e.target.name]: e.target.value, + }, + }); + } + }, + [internalEditToken], + ); + + const handleBorderValueDownShiftInputChange = React.useCallback((newInputValue: string, property: string) => { + if (internalEditToken?.type === TokenTypes.BORDER && typeof internalEditToken?.value !== 'string') { + setInternalEditToken({ + ...internalEditToken, + value: { ...internalEditToken.value, [property]: newInputValue }, + }); + } + }, [internalEditToken]); + const handleDownShiftInputChange = React.useCallback((newInputValue: string) => { setInternalEditToken({ ...internalEditToken, @@ -297,6 +340,10 @@ function EditTokenForm({ resolvedTokens }: Props) { const handleSubmit = React.useCallback( (e: React.FormEvent) => { e.preventDefault(); + if (internalEditToken.type === TokenTypes.DIMENSION && !isValidDimensionToken) { + setError('Value must include either px or rem'); + return; + } if (isValid && internalEditToken) { submitTokenValue(internalEditToken); dispatch.uiState.setShowEditForm(false); @@ -305,16 +352,21 @@ function EditTokenForm({ resolvedTokens }: Props) { [dispatch, isValid, internalEditToken, submitTokenValue], ); + const handleSaveShortcut = React.useCallback((event: KeyboardEvent) => { + if (event.metaKey || event.ctrlKey) { + if (isValid && internalEditToken) { + submitTokenValue(internalEditToken); + dispatch.uiState.setShowEditForm(false); + } + } + }, [handleSubmit, submitTokenValue, dispatch, internalEditToken, isValid]); + + useShortcut(['Enter'], handleSaveShortcut); + const handleReset = React.useCallback(() => { dispatch.uiState.setShowEditForm(false); }, [dispatch]); - React.useEffect(() => { - setTimeout(() => { - firstInput.current?.focus(); - }, 50); - }, []); - const resolvedValue = React.useMemo(() => { if (internalEditToken) { return typeof internalEditToken?.value === 'string' @@ -360,6 +412,16 @@ function EditTokenForm({ resolvedTokens }: Props) { /> ); } + case TokenTypes.BORDER: { + return ( + + ); + } default: { return (
@@ -370,6 +432,7 @@ function EditTokenForm({ resolvedTokens }: Props) { resolvedTokens={resolvedTokens} initialName={internalEditToken.initialName} handleChange={handleChange} + handleBlur={handleBlur} setInputValue={handleDownShiftInputChange} placeholder={ internalEditToken.type === 'color' ? '#000000, hsla(), rgba() or {alias}' : 'Value or {alias}' @@ -416,8 +479,8 @@ function EditTokenForm({ resolvedTokens }: Props) { value={internalEditToken?.name} onChange={handleChange} type="text" + autofocus name="name" - inputRef={firstInput} error={error} placeholder="Unique name" /> @@ -429,6 +492,7 @@ function EditTokenForm({ resolvedTokens }: Props) {