├── register.js ├── .gitignore ├── src ├── index.d.ts ├── endpoints.ts ├── register.tsx ├── index.ts ├── typings.ts ├── util.ts └── panel.tsx ├── .eslintrc ├── tsconfig.json ├── .circleci └── config.yml ├── LICENSE ├── package.json └── README.md /register.js: -------------------------------------------------------------------------------- 1 | import './lib/register' 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .idea 3 | lib 4 | .DS_Store 5 | -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | export declare function withFigma({ apiToken, projectID }: { 3 | apiToken: string; 4 | projectID: string; 5 | }): any; 6 | -------------------------------------------------------------------------------- /src/endpoints.ts: -------------------------------------------------------------------------------- 1 | const BASE = 'https://api.figma.com/v1'; 2 | 3 | // https://www.figma.com/developers/docs#get-images-endpoint 4 | export function fileImage(fileKey: string, ids: string) { 5 | return `${ BASE }/images/${ fileKey }?ids=${ ids }&format=svg`; 6 | } 7 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "parserOptions": { 5 | "ecmaVersion": 2018 6 | }, 7 | "extends": [ 8 | "@dreipol/eslint-config", 9 | "@dreipol/eslint-config-typescript", 10 | "plugin:react/recommended" 11 | ], 12 | "plugins": [ 13 | "@typescript-eslint" 14 | ], 15 | "settings": { 16 | "react": { 17 | "version": "detect" 18 | }, 19 | "react/jsx-indent": [ 20 | "4", 21 | "spaces" 22 | ] 23 | }, 24 | "env": { 25 | "browser": true, 26 | "node": true 27 | }, 28 | "globals": { 29 | "globals": true 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/register.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import addons, { types } from '@storybook/addons'; 4 | import FigmaPanel from './panel'; 5 | import { constants } from './typings'; 6 | 7 | addons.register(constants.ADDON_NAME, api => { 8 | const render = ({ active, key }: { active: boolean; key: string }) => ( 9 | 13 | ); 14 | 15 | addons.add(constants.PANEL_NAME, { 16 | type: types.PANEL, 17 | title: constants.ADDON_TITLE, 18 | render, 19 | }); 20 | }); 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { constants } from './typings'; 2 | import addons, { makeDecorator, StoryWrapper } from '@storybook/addons'; 3 | 4 | const wrapper: StoryWrapper = (getStory, context, { options }) => { 5 | const channel = addons.getChannel(); 6 | 7 | channel.emit(constants.UPDATE_CONFIG_EVENT, options); 8 | 9 | return getStory(context); 10 | }; 11 | 12 | export const withFigma = makeDecorator({ 13 | name: constants.DECORATOR_NAME, 14 | parameterName: constants.PARAM_KEY, 15 | skipIfNoParameterOrOptions: true, 16 | wrapper, 17 | }); 18 | 19 | if (module && module.hot && module.hot.decline) { 20 | module.hot.decline(); 21 | } 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/typings.ts: -------------------------------------------------------------------------------- 1 | export interface FigmaImageAPIResponse { 2 | err: string | null; 3 | images: FigmaImageAPIObject; 4 | } 5 | 6 | export interface FigmaImageAPIObject { 7 | [key: string]: string; 8 | } 9 | 10 | export interface FigmaImage { 11 | url: string; 12 | name: string; 13 | type: 'image'; 14 | } 15 | 16 | export interface DecoratorParams { 17 | ids: string; 18 | names?: string[]; 19 | } 20 | 21 | export enum constants { 22 | ADDON_NAME = 'STORYBOOK_ADDON_FIGMA', 23 | DECORATOR_NAME = 'withFigma', 24 | PARAM_KEY = 'figma', 25 | PANEL_NAME = 'STORYBOOK_ADDON_FIGMA/panel', 26 | UPDATE_CONFIG_EVENT = 'STORYBOOK_ADDON_FIGMA/update_config', 27 | ADDON_TITLE = 'Figma', 28 | } 29 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2015", 4 | "module": "es2015", 5 | "jsx": "react", 6 | "allowSyntheticDefaultImports": true, 7 | "declaration": false, 8 | "declarationMap": false, 9 | "sourceMap": true, 10 | "downlevelIteration": true, 11 | "strict": true, 12 | "noImplicitAny": true, 13 | "outDir": "lib", 14 | "strictNullChecks": true, 15 | "strictFunctionTypes": true, 16 | "noUnusedLocals": true, 17 | "noUnusedParameters": true, 18 | "noImplicitReturns": true, 19 | "moduleResolution": "node" 20 | }, 21 | "include": [ 22 | "src/**/*" 23 | ], 24 | "exclude": [ 25 | "node_modules", 26 | "lib" 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Javascript Node CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-javascript/ for more details 4 | # 5 | version: 2 6 | jobs: 7 | build: 8 | docker: 9 | # specify the version you desire here 10 | - image: circleci/node:10.0 11 | 12 | working_directory: ~/repo 13 | 14 | steps: 15 | - checkout 16 | 17 | # Download and cache dependencies 18 | - restore_cache: 19 | keys: 20 | - v1-dependencies-{{ checksum "package-lock.json" }} 21 | # fallback to using the latest cache if no exact match is found 22 | - v1-dependencies- 23 | 24 | - run: npm install 25 | 26 | - save_cache: 27 | paths: 28 | - node_modules 29 | key: v1-dependencies-{{ checksum "package-lock.json" }} 30 | 31 | # run tests! 32 | - run: npm test 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 dreipol 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@dreipol/storybook-figma-addon", 3 | "version": "1.0.1", 4 | "description": "Figma storybook addon to embed private and public figma projects", 5 | "main": "lib/index.js", 6 | "types": "src/index.d.ts", 7 | "scripts": { 8 | "build": "tsc", 9 | "prepublishOnly": "npm run build", 10 | "test": "eslint src/*.{ts,tsx}" 11 | }, 12 | "files": [ 13 | "lib", 14 | "src", 15 | "register.js" 16 | ], 17 | "author": "Dreipol (http://dreipol.ch)", 18 | "license": "MIT", 19 | "dependencies": { 20 | "@storybook/addons": "^5.0.0", 21 | "@storybook/channels": "^5.0.11", 22 | "@storybook/components": "^5.0.11", 23 | "@storybook/core-events": "^5.0.11", 24 | "lodash": "^4.17.11" 25 | }, 26 | "devDependencies": { 27 | "@types/lodash": "^4.14.132", 28 | "@types/node": "^12.0.3", 29 | "eslint": "^5.16.0", 30 | "eslint-plugin-react": "^7.13.0", 31 | "@typescript-eslint/eslint-plugin": "^1.9.0", 32 | "@typescript-eslint/parser": "^1.9.0", 33 | "@dreipol/eslint-config": "^6.0.4", 34 | "@dreipol/eslint-config-typescript": "^1.0.0", 35 | "@types/react": "^16.8.19", 36 | "@types/webpack-env": "^1.13.9", 37 | "lodash": "^4.17.11", 38 | "react": "^16.8.4", 39 | "storybook-addon-designs": "^5.2.1", 40 | "typescript": "^3.4.5" 41 | }, 42 | "peerDependencies": { 43 | "storybook-addon-designs": "^5.2.1", 44 | "react": ">=16.8.0" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import { curry, flowRight as compose, memoize } from 'lodash'; 2 | import { fileImage } from './endpoints'; 3 | import { FigmaImage, FigmaImageAPIObject, FigmaImageAPIResponse } from './typings'; 4 | 5 | const FIGMA_TOKEN_HEADER = 'X-Figma-Token'; 6 | 7 | // Convert the API images into valid arguments for https://github.com/pocka/storybook-addon-designs 8 | const toFigmaImages = (images: FigmaImageAPIObject): FigmaImage[] => Object.entries(images).map(([name, url]) => ({ 9 | name, 10 | url, 11 | type: 'image', 12 | })); 13 | 14 | async function fetchImages(token: string, url: string) { 15 | const res = await fetch(url, { 16 | headers: { 17 | [FIGMA_TOKEN_HEADER]: token, 18 | }, 19 | }); 20 | 21 | return res.json(); 22 | } 23 | 24 | // Load asynchronously figma images by id 25 | export const loadFigmaImagesByIDs = memoize(async (ids: string, projectId: string, apiToken: string): Promise => { 26 | if (!projectId) { 27 | throw new Error('The figma project id was not set.'); 28 | } 29 | 30 | if (!apiToken) { 31 | throw new Error('Your figma api token was not set.'); 32 | } 33 | 34 | // curry the file image api endpoint 35 | const imagesEndpointWithProjectID = curry(fileImage)(projectId); 36 | // curry the fetch method 37 | const fetchImagesWithToken = curry(fetchImages)(apiToken); 38 | const loadImagesByIDs = compose(fetchImagesWithToken, imagesEndpointWithProjectID); 39 | 40 | try { 41 | const data: FigmaImageAPIResponse = (await loadImagesByIDs(ids)); 42 | 43 | return toFigmaImages(data.images); 44 | } catch (error) { 45 | throw new Error(error); 46 | } 47 | }); 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Description 2 | 3 | Figma storybook addon to embed private and public figma projects. 4 | This addon was designed and tested only in a react environment. 5 | 6 | [![Build Status][circleci-image]][circleci-url] 7 | [![NPM downloads][npm-downloads-image]][npm-url] 8 | [![NPM version][npm-version-image]][npm-url] 9 | [![Code quality][codeclimate-image]][codeclimate-url] 10 | [![MIT License][license-image]][license-url] 11 | 12 | # Installation 13 | 14 | ```bash 15 | npm i @dreipol/storybook-figma-addon storybook-addon-designs -D 16 | ``` 17 | 18 | # Usage 19 | 20 | 1. Register the plugin in `addons.js` 21 | ```js 22 | import '@dreipol/storybook-figma-addon/register'; 23 | ``` 24 | 2. Set your figma project id and API token 25 | ```js 26 | import { addDecorator } from '@storybook/react'; 27 | import { withFigma } from '@dreipol/storybook-figma-addon'; 28 | 29 | addDecorator(withFigma({ 30 | apiToken: process.env.FIGMA_API_TOKEN, 31 | projectID: process.env.FIGMA_PROJECT_ID, 32 | })); 33 | ``` 34 | 3. Use it in your component stories 35 | ```jsx harmony 36 | stories.add( 37 | 'Default', 38 | () => , 39 | { 40 | // one or more figma image ids concatenated via commas 41 | figma: { 42 | ids: '14%3A160,45%3A1939', 43 | names: ['Buttons', 'Buttons Hover'] 44 | }, 45 | }, 46 | ); 47 | ``` 48 | 49 | 50 | [circleci-image]: https://circleci.com/gh/dreipol/storybook-figma-addon.svg?style=svg 51 | [circleci-url]: https://circleci.com/gh/dreipol/storybook-figma-addon 52 | [license-image]: http://img.shields.io/badge/license-MIT-000000.svg?style=flat-square 53 | [license-url]: LICENSE 54 | [npm-version-image]: http://img.shields.io/npm/v/@dreipol/storybook-figma-addon.svg?style=flat-square 55 | [npm-downloads-image]: http://img.shields.io/npm/dm/@dreipol/storybook-figma-addon.svg?style=flat-square 56 | [npm-url]: https://npmjs.org/package/@dreipol/storybook-figma-addon 57 | [codeclimate-image]: https://api.codeclimate.com/v1/badges/fb8c4a8a6043d9e73f7f/maintainability 58 | [codeclimate-url]: https://codeclimate.com/github/dreipol/storybook-figma-addon/maintainability 59 | 60 | 61 | -------------------------------------------------------------------------------- /src/panel.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-ignore */ 2 | import React, { Fragment, ReactElement, useEffect, useState } from 'react'; 3 | import { STORY_CHANGED, STORY_RENDERED } from '@storybook/core-events'; 4 | import { ImageConfig } from 'storybook-addon-designs/esm/config'; 5 | // @ts-ignore 6 | import { Placeholder, TabsState } from '@storybook/components'; 7 | import ImagePreview from 'storybook-addon-designs/esm/register/components/Image'; 8 | import { loadFigmaImagesByIDs } from './util'; 9 | 10 | import { Channel } from '@storybook/channels'; 11 | import { constants, DecoratorParams } from './typings'; 12 | 13 | declare interface StorybookAddon extends Channel { 14 | getParameters(id: string, key: string): DecoratorParams; 15 | } 16 | 17 | interface Props { 18 | active: boolean; 19 | api: StorybookAddon; 20 | channel: Channel; 21 | } 22 | 23 | function getPanels(config: ImageConfig | ImageConfig[], names: string[]): [ReactElement, { id: string; title: string }][] { 24 | return [...(Array.isArray(config) ? config : [config])].map((cfg, i) => { 25 | const meta = { 26 | id: `${ constants.ADDON_NAME }-${ i }`, 27 | title: names[i] || cfg.name || '', 28 | }; 29 | 30 | return [, meta]; 31 | }); 32 | } 33 | 34 | function PlaceholderMessage(props: any): ReactElement { 35 | return ( 36 | 37 | 38 | 39 | ); 40 | } 41 | 42 | export default function FigmaPanel({ api, active, channel }: Props): ReactElement { 43 | const [config, setConfig] = useState(); 44 | const [hasImages, setHasImages] = useState(); 45 | const [imageNames, setNames] = useState(); 46 | const [apiConfig, setApiConfig] = useState<{ 47 | apiToken: string; 48 | projectID: string; 49 | }>(); 50 | 51 | const [storyId, changeStory] = useState(); 52 | 53 | useEffect(() => { 54 | const onStoryChanged = async (id: string) => { 55 | changeStory(id); 56 | 57 | const params = api.getParameters(id, constants.PARAM_KEY); 58 | 59 | setHasImages(Boolean(params)); 60 | 61 | if (!params) { 62 | return; 63 | } 64 | 65 | const { ids, names } = params; 66 | 67 | if (ids && apiConfig) { 68 | setNames(names || []); 69 | const cfg = await loadFigmaImagesByIDs(ids, apiConfig.projectID, apiConfig.apiToken); 70 | 71 | setConfig(cfg); 72 | } 73 | }; 74 | 75 | channel.on(constants.UPDATE_CONFIG_EVENT, setApiConfig); 76 | channel.on(STORY_CHANGED, onStoryChanged); 77 | channel.on(STORY_RENDERED, onStoryChanged); 78 | 79 | return () => { 80 | channel.removeListener(constants.UPDATE_CONFIG_EVENT, setApiConfig); 81 | channel.removeListener(STORY_CHANGED, onStoryChanged); 82 | channel.removeListener(STORY_RENDERED, onStoryChanged); 83 | }; 84 | }, [apiConfig]); 85 | 86 | if (!active) { 87 | return