├── 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 ;
88 | }
89 |
90 | if (hasImages === false) {
91 | return This component has no figma designs ¯\_(ツ)_/¯;
92 | }
93 |
94 | if (!config || !storyId) {
95 | return Loading designs...;
96 | }
97 |
98 | const panels = getPanels(config, imageNames);
99 |
100 | return (
101 |
102 | { panels.map(([el, meta]) => (
103 |
104 | { el }
105 |
106 | )) }
107 |
108 | );
109 | }
110 |
--------------------------------------------------------------------------------