├── register.js ├── .eslintignore ├── src ├── assets │ ├── story.png │ └── headless.png ├── types │ ├── index.ts │ ├── messages.ts │ ├── generic.ts │ ├── options.ts │ ├── schemas.ts │ ├── parameters.ts │ └── state.ts ├── index.ts ├── components │ ├── Loader │ │ ├── stories.tsx │ │ ├── test.tsx │ │ └── index.tsx │ ├── Variable │ │ ├── styled.ts │ │ ├── Unknown.tsx │ │ ├── Boolean.tsx │ │ ├── String.tsx │ │ ├── Number.tsx │ │ ├── __tests__ │ │ │ ├── Unknown.tsx │ │ │ ├── Select.tsx │ │ │ ├── Boolean.tsx │ │ │ ├── Number.tsx │ │ │ ├── String.tsx │ │ │ └── Date.tsx │ │ ├── Select.tsx │ │ ├── index.tsx │ │ ├── Date.tsx │ │ └── stories.tsx │ ├── Variables │ │ ├── styled.ts │ │ ├── stories.tsx │ │ └── index.tsx │ ├── index.ts │ ├── Prompt │ │ ├── stories.tsx │ │ ├── styled.ts │ │ ├── index.tsx │ │ └── test.tsx │ ├── BrowserOnly │ │ └── index.tsx │ ├── Panel │ │ ├── styled.ts │ │ ├── Tab.tsx │ │ └── index.tsx │ ├── Message │ │ ├── stories.tsx │ │ ├── styled.ts │ │ ├── index.tsx │ │ └── test.tsx │ ├── ErrorBoundary │ │ └── index.tsx │ └── Select │ │ ├── stories.tsx │ │ ├── styled.ts │ │ └── index.tsx ├── examples │ ├── intro.stories.mdx │ ├── options.stories.mdx │ ├── restful.stories.tsx │ ├── components.stories.mdx │ ├── index.tsx │ ├── graphql.stories.tsx │ └── parameters.stories.mdx ├── config.ts ├── __tests__ │ ├── decorator.tsx │ └── utilities.ts ├── register.tsx ├── decorator.tsx └── utilities.ts ├── tsconfig.test.json ├── .prettierrc.json ├── .storybook-dist ├── preview.tsx └── main.js ├── .gitignore ├── .prettierignore ├── .storybook-dev ├── preview.tsx └── main.js ├── .github ├── workflows │ └── release.yml ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── PULL_REQUEST_TEMPLATE.md └── FUNDING.yml ├── jest.config.js ├── tsconfig.json ├── LICENSE ├── rollup.config.js ├── .eslintrc.json ├── CONTRIBUTING.md ├── CODE_OF_CONDUCT.md ├── package.json └── README.md /register.js: -------------------------------------------------------------------------------- 1 | require('./dist/register') 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /coverage/ 2 | /dist/ 3 | /docs/ 4 | /node_modules/ 5 | /src/**/*.stories.tsx 6 | -------------------------------------------------------------------------------- /src/assets/story.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ArrayKnight/storybook-addon-headless/HEAD/src/assets/story.png -------------------------------------------------------------------------------- /src/assets/headless.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ArrayKnight/storybook-addon-headless/HEAD/src/assets/headless.png -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "endOfLine": "auto", 4 | "semi": false, 5 | "singleQuote": true, 6 | "tabWidth": 4, 7 | "trailingComma": "all" 8 | } 9 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './generic' 2 | export * from './messages' 3 | export * from './options' 4 | export * from './parameters' 5 | export * from './schemas' 6 | export * from './state' 7 | -------------------------------------------------------------------------------- /.storybook-dist/preview.tsx: -------------------------------------------------------------------------------- 1 | export const parameters = { 2 | options: { 3 | storySort: { 4 | order: ['Intro', 'Options', 'Parameters', 'Components'], 5 | }, 6 | }, 7 | } 8 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './config' 2 | export { 3 | Loader, 4 | Prompt, 5 | useHeadlessLoader, 6 | useHeadlessPrompt, 7 | } from './components' 8 | export { withHeadless } from './decorator' 9 | export * from './types' 10 | export { pack } from './utilities' 11 | -------------------------------------------------------------------------------- /src/components/Loader/stories.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react' 2 | 3 | import { Loader } from '.' 4 | 5 | export default { 6 | title: 'Loader', 7 | } 8 | 9 | export const LoaderStory = (): ReactElement => 10 | 11 | LoaderStory.storyName = 'Loader' 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # IDE 2 | /.code-workspace/ 3 | /.idea/ 4 | /.settings/ 5 | /.vscode/ 6 | 7 | # Libraries 8 | /node_modules/ 9 | 10 | # Local 11 | .env* 12 | 13 | # Generated 14 | /coverage/ 15 | /dist/ 16 | /docs/ 17 | yarn.lock 18 | 19 | # Logs 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | -------------------------------------------------------------------------------- /src/types/messages.ts: -------------------------------------------------------------------------------- 1 | import { HeadlessOptions } from './options' 2 | import { HeadlessState } from './state' 3 | 4 | export interface InitializeMessage { 5 | storyId: string 6 | options: HeadlessOptions 7 | } 8 | 9 | export type UpdateMessage = Pick 10 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # IDE 2 | /.code-workspace/ 3 | /.idea/ 4 | /.settings/ 5 | /.vscode/ 6 | 7 | # Local 8 | /src/assets/ 9 | .env* 10 | .*ignore 11 | LICENSE 12 | 13 | # Generated 14 | /coverage/ 15 | /dist/ 16 | /docs/ 17 | /node_modules/ 18 | package-lock.json 19 | yarn.lock 20 | 21 | # Logs 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | -------------------------------------------------------------------------------- /src/types/generic.ts: -------------------------------------------------------------------------------- 1 | export type Identifiable = T & { id: string } 2 | 3 | export interface Item { 4 | label: string 5 | value: unknown 6 | } 7 | 8 | export type ObjectLike = Record | unknown[] | null | undefined 9 | 10 | export type OneOrMore = T | Array> 11 | 12 | export type Transform = (value: T) => T 13 | -------------------------------------------------------------------------------- /src/components/Variable/styled.ts: -------------------------------------------------------------------------------- 1 | import { styled } from '@storybook/theming' 2 | 3 | export const Row = styled.div` 4 | min-height: 32px; 5 | display: flex; 6 | flex-direction: row; 7 | align-items: center; 8 | ` 9 | 10 | export const Error = styled.span` 11 | padding-left: 10px; 12 | 13 | &:first-letter { 14 | text-transform: uppercase; 15 | } 16 | ` 17 | -------------------------------------------------------------------------------- /src/components/Variables/styled.ts: -------------------------------------------------------------------------------- 1 | import { styled } from '@storybook/theming' 2 | 3 | export const Fieldset = styled.fieldset` 4 | padding: 0; 5 | border: 0; 6 | margin: 0 -15px 20px; 7 | 8 | & > label:last-child { 9 | margin-bottom: 0; 10 | } 11 | ` 12 | 13 | export const Actions = styled.div` 14 | display: flex; 15 | justify-content: space-between; 16 | ` 17 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | export { BrowserOnly } from './BrowserOnly' 2 | export { ErrorBoundary } from './ErrorBoundary' 3 | export { Loader, useHeadlessLoader } from './Loader' 4 | export { Message } from './Message' 5 | export { Panel } from './Panel' 6 | export { Prompt, useHeadlessPrompt } from './Prompt' 7 | export { Select } from './Select' 8 | export { Variable } from './Variable' 9 | export { Variables } from './Variables' 10 | -------------------------------------------------------------------------------- /src/examples/intro.stories.mdx: -------------------------------------------------------------------------------- 1 | import { 2 | Description, 3 | DocsContainer, 4 | DocsPage, 5 | Meta, 6 | } from '@storybook/addon-docs/blocks' 7 | 8 | import readme from '../../README.md' 9 | 10 | 20 | 21 | export default () => 22 | -------------------------------------------------------------------------------- /.storybook-dev/preview.tsx: -------------------------------------------------------------------------------- 1 | import { addDecorator } from '@storybook/react' 2 | import { 3 | convert, 4 | createReset, 5 | Global, 6 | ThemeProvider, 7 | themes, 8 | } from '@storybook/theming' 9 | import React from 'react' 10 | 11 | addDecorator((storyFn) => { 12 | const theme = convert(themes.normal) 13 | 14 | return ( 15 | 16 | 17 | {storyFn()} 18 | 19 | ) 20 | }) 21 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | export const ADDON_ID = 'headless' 2 | export const PANEL_ID = `${ADDON_ID}/panel` 3 | export const PANEL_TITLE = 'Headless' 4 | export const PARAM_KEY = ADDON_ID 5 | export const DECORATOR_NAME = `with${PANEL_TITLE}` 6 | export const EVENT_INITIALIZED = `${ADDON_ID}/event/initialized` 7 | export const EVENT_DATA_UPDATED = `${ADDON_ID}/event/data-updated` 8 | export const EVENT_REQUESTED_ADDON = `${ADDON_ID}/event/requested-addon` 9 | export const EVENT_REQUESTED_STORY = `${ADDON_ID}/event/requested-story` 10 | export const STORAGE_KEY = '@storybook/addon-headless/state' 11 | -------------------------------------------------------------------------------- /src/__tests__/decorator.tsx: -------------------------------------------------------------------------------- 1 | import { createFilteredRecord } from '../decorator' 2 | 3 | describe('createFilteredRecord', () => { 4 | it('should create a record with entries filtered by keys passed', () => { 5 | expect( 6 | createFilteredRecord(['foo'], { foo: true, bar: false }, false), 7 | ).toEqual({ foo: true }) 8 | }) 9 | 10 | it('should add keys with a default value when missing', () => { 11 | expect( 12 | createFilteredRecord(['foo', 'wux'], { foo: 17, bar: -3 }, 0), 13 | ).toEqual({ foo: 17, wux: 0 }) 14 | }) 15 | }) 16 | -------------------------------------------------------------------------------- /src/components/Loader/test.tsx: -------------------------------------------------------------------------------- 1 | import { cleanup, render, RenderResult } from '@testing-library/react' 2 | import '@testing-library/jest-dom/extend-expect' 3 | import React from 'react' 4 | 5 | import { Loader, TEST_IDS } from '.' 6 | 7 | describe('Loader', () => { 8 | afterEach(cleanup) 9 | 10 | function setup(): RenderResult { 11 | return { 12 | ...render(), 13 | } 14 | } 15 | 16 | it('should render', () => { 17 | const { getByTestId } = setup() 18 | 19 | expect(getByTestId(TEST_IDS.root)).toBeInTheDocument() 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /.storybook-dev/main.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | addons: ['@storybook/addon-knobs', '@storybook/addon-actions'], 3 | stories: ['../src/components/**/?(*.)stories.tsx'], 4 | webpackFinal: async (config) => { 5 | config.module.rules.push({ 6 | test: /\.(ts|tsx)$/, 7 | loader: require.resolve('babel-loader'), 8 | options: { 9 | presets: [['react-app', { flow: false, typescript: true }]], 10 | }, 11 | }) 12 | 13 | config.resolve.extensions.push('.ts', '.tsx') 14 | 15 | return config 16 | }, 17 | } 18 | -------------------------------------------------------------------------------- /src/components/Prompt/stories.tsx: -------------------------------------------------------------------------------- 1 | import { text, withKnobs } from '@storybook/addon-knobs' 2 | import React, { ReactElement } from 'react' 3 | 4 | import { Prompt } from '.' 5 | 6 | export default { 7 | title: 'Prompt', 8 | decorators: [withKnobs], 9 | } 10 | 11 | export const PromptStory = (): ReactElement => { 12 | const headline = text('headline', '') 13 | const message = text('message', '') 14 | 15 | return ( 16 | 20 | ) 21 | } 22 | 23 | PromptStory.storyName = 'Prompt' 24 | -------------------------------------------------------------------------------- /src/components/Prompt/styled.ts: -------------------------------------------------------------------------------- 1 | import { css, styled } from '@storybook/theming' 2 | 3 | export const Root = styled.div( 4 | ({ theme }) => css` 5 | width: 100%; 6 | min-height: 100%; 7 | background: ${theme.background.app}; 8 | position: absolute; 9 | 10 | *:focus { 11 | outline: none; 12 | } 13 | `, 14 | ) 15 | 16 | export const Content = styled.div( 17 | ({ theme }) => css` 18 | width: 60%; 19 | min-width: 400px; 20 | max-width: 800px; 21 | padding: 20px; 22 | margin: 20px auto; 23 | background: ${theme.background.content}; 24 | `, 25 | ) 26 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: push 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@master 11 | 12 | - uses: actions/setup-node@master 13 | with: 14 | node-version: 16 15 | 16 | - run: npm install 17 | - run: npm run lint 18 | - run: npm run test 19 | - run: npm run build:dist 20 | 21 | - uses: codfish/semantic-release-action@master 22 | env: 23 | GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} 24 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 9 | 10 | ## Issue 11 | 12 | 13 | 14 | ## Description 15 | 16 | 17 | 18 | ## Testing 19 | 20 | 21 | 22 | ## Documentation 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/components/BrowserOnly/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo, ReactElement, ReactNode } from 'react' 2 | 3 | import { isFunction } from '../../utilities' 4 | import { ErrorBoundary } from '../ErrorBoundary' 5 | 6 | interface Props { 7 | children?: () => ReactNode 8 | fallback?: ReactNode 9 | } 10 | 11 | export const BrowserOnly = memo( 12 | ({ children, fallback = null }: Props): ReactElement | null => { 13 | if (typeof window !== 'undefined' && isFunction(children)) { 14 | return {children()} 15 | } 16 | 17 | return {fallback} 18 | }, 19 | ) 20 | 21 | BrowserOnly.displayName = 'BrowserOnly' 22 | -------------------------------------------------------------------------------- /src/types/options.ts: -------------------------------------------------------------------------------- 1 | import type { ApolloClientOptions } from '@apollo/client' 2 | import type { AxiosRequestConfig as AxiosClientConfig } from 'axios' 3 | import type { ThemeKeys } from 'react-json-view' 4 | 5 | import { OneOrMore } from './generic' 6 | 7 | export type RestfulOptions = AxiosClientConfig 8 | export type RestfulOptionsTypes = OneOrMore 9 | 10 | export type GraphQLOptions = Omit< 11 | ApolloClientOptions, 12 | 'cache' | 'link' 13 | > 14 | export type GraphQLOptionsTypes = OneOrMore 15 | 16 | export interface HeadlessOptions { 17 | restful?: RestfulOptionsTypes 18 | graphql?: GraphQLOptionsTypes 19 | jsonDark?: ThemeKeys 20 | jsonLight?: ThemeKeys 21 | } 22 | -------------------------------------------------------------------------------- /src/components/Variable/Unknown.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from 'react' 2 | 3 | import { Row } from './styled' 4 | import type { Props as VariableProps } from '.' 5 | 6 | export interface Props extends Omit { 7 | isValid: boolean 8 | } 9 | 10 | export const TEST_IDS = Object.freeze({ 11 | root: 'UnknownVariableRoot', 12 | }) 13 | 14 | export const MESSAGE = 'Unknown variable type' 15 | 16 | export const UnknownInput = memo(({ schema, value }: Props) => { 17 | console.warn(MESSAGE, { 18 | schema, 19 | value, 20 | }) 21 | 22 | return ( 23 | 24 | {MESSAGE} 25 | 26 | ) 27 | }) 28 | 29 | UnknownInput.displayName = 'Unknown' 30 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [ArrayKnight] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /src/types/schemas.ts: -------------------------------------------------------------------------------- 1 | import { AsyncSchema, SchemaObject } from 'ajv' 2 | 3 | export type AnySchema = AsyncSchema | SchemaObject 4 | 5 | export type BooleanSchema = AnySchema & { 6 | type: 'boolean' 7 | } 8 | 9 | export type DateTimeSchema = AnySchema & { 10 | type: 'string' 11 | format: 'date' | 'date-time' | 'time' 12 | } 13 | 14 | export type NumberSchema = AnySchema & { 15 | type: 'number' | 'integer' 16 | } 17 | 18 | export type SelectSchema = AnySchema & { 19 | enum: unknown[] 20 | } 21 | 22 | export type StringSchema = AnySchema & { 23 | type: 'string' 24 | } 25 | 26 | export type KnownSchema = 27 | | BooleanSchema 28 | | DateTimeSchema 29 | | NumberSchema 30 | | SelectSchema 31 | | StringSchema 32 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | collectCoverageFrom: [ 3 | 'src/**/*.[jt]s?(x)', 4 | '!src/**/*.d.ts', 5 | '!src/**/index.ts', 6 | '!src/examples/*', 7 | '!src/**/?(*.){stories,styled}.[jt]s?(x)', 8 | ], 9 | moduleFileExtensions: [ 10 | 'web.js', 11 | 'js', 12 | 'web.ts', 13 | 'ts', 14 | 'web.tsx', 15 | 'tsx', 16 | 'json', 17 | 'web.jsx', 18 | 'jsx', 19 | 'node', 20 | ], 21 | setupFilesAfterEnv: [ 22 | 'jest-expect-message', 23 | 'jest-mock-console/dist/setupTestFramework.js', 24 | ], 25 | testEnvironment: 'jest-environment-jsdom', 26 | testURL: 'http://localhost', 27 | transform: { 28 | '^.+\\.tsx?$': 'ts-jest', 29 | }, 30 | transformIgnorePatterns: ['[/\\\\]node_modules[/\\\\].+\\.[jt]sx?$'], 31 | } 32 | -------------------------------------------------------------------------------- /src/components/Loader/index.tsx: -------------------------------------------------------------------------------- 1 | import { Loader as LoaderBase } from '@storybook/components' 2 | import { 3 | convert, 4 | createReset, 5 | Global, 6 | ThemeProvider, 7 | themes, 8 | } from '@storybook/theming' 9 | import React, { memo, ReactElement } from 'react' 10 | import ReactDOM from 'react-dom' 11 | 12 | export const TEST_IDS = Object.freeze({ 13 | root: 'LoaderRoot', 14 | }) 15 | 16 | export const Loader = memo((): ReactElement => { 17 | const theme = convert(themes.normal) 18 | 19 | return ( 20 | 21 | 22 | 23 | 24 | ) 25 | }) 26 | 27 | Loader.displayName = 'Loader' 28 | 29 | export function useHeadlessLoader(element: HTMLElement): void { 30 | ReactDOM.render(, element) 31 | } 32 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "allowSyntheticDefaultImports": true, 5 | "declaration": true, 6 | "declarationDir": "./dist", 7 | "emitDecoratorMetadata": true, 8 | "esModuleInterop": true, 9 | "experimentalDecorators": true, 10 | "jsx": "react", 11 | "lib": ["es2017", "dom"], 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "noImplicitAny": true, 15 | "noImplicitReturns": true, 16 | "noStrictGenericChecks": false, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": false, 19 | "outDir": "./dist", 20 | "rootDir": "./src", 21 | "skipDefaultLibCheck": true, 22 | "skipLibCheck": true, 23 | "sourceMap": true, 24 | "sourceRoot": "./src", 25 | "target": "es5" 26 | }, 27 | "include": ["src"] 28 | } 29 | -------------------------------------------------------------------------------- /src/register.tsx: -------------------------------------------------------------------------------- 1 | import { addons, RenderOptions, types } from '@storybook/addons' 2 | import React, { ReactElement } from 'react' 3 | 4 | import { ErrorBoundary, Panel } from './components' 5 | import { 6 | ADDON_ID, 7 | PANEL_ID, 8 | PANEL_TITLE, 9 | PARAM_KEY, 10 | STORAGE_KEY, 11 | } from './config' 12 | import { useImmediateEffect } from './utilities' 13 | 14 | export function Render(props: RenderOptions): ReactElement { 15 | useImmediateEffect(() => sessionStorage.removeItem(STORAGE_KEY)) 16 | 17 | return ( 18 | 19 | 20 | 21 | ) 22 | } 23 | 24 | addons.register(ADDON_ID, () => { 25 | addons.add(PANEL_ID, { 26 | type: types.TAB, 27 | title: PANEL_TITLE, 28 | paramKey: PARAM_KEY, 29 | route: ({ storyId }) => `/${ADDON_ID}/${storyId}`, 30 | match: ({ viewMode }) => viewMode === ADDON_ID, 31 | render: Render, 32 | }) 33 | }) 34 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior: 14 | 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | 28 | - OS: [e.g. iOS] 29 | - Browser [e.g. chrome, safari] 30 | - Version [e.g. 22] 31 | 32 | **Smartphone (please complete the following information):** 33 | 34 | - Device: [e.g. iPhone6] 35 | - OS: [e.g. iOS8.1] 36 | - Browser [e.g. stock browser, safari] 37 | - Version [e.g. 22] 38 | 39 | **Additional context** 40 | Add any other context about the problem here. 41 | -------------------------------------------------------------------------------- /src/components/Panel/styled.ts: -------------------------------------------------------------------------------- 1 | import { css, styled } from '@storybook/theming' 2 | 3 | export const Root = styled.div( 4 | ({ theme }) => css` 5 | width: 100%; 6 | min-height: 100%; 7 | background: ${theme.background.app}; 8 | position: absolute; 9 | ${!theme.active && 10 | css` 11 | display: none; 12 | pointer-events: none; 13 | `} 14 | 15 | *:focus { 16 | outline: none; 17 | } 18 | `, 19 | ) 20 | 21 | export const Content = styled.div( 22 | ({ theme }) => css` 23 | width: 60%; 24 | min-width: 400px; 25 | max-width: 800px; 26 | padding: 20px; 27 | margin: 20px auto; 28 | background: ${theme.background.content}; 29 | `, 30 | ) 31 | 32 | export const TabContent = styled.div` 33 | padding-top: 10px; 34 | ` 35 | 36 | export const Separator = styled.hr( 37 | ({ theme }) => css` 38 | border: 0; 39 | height: 20px; 40 | margin: 20px -20px; 41 | background: ${theme.background.app}; 42 | `, 43 | ) 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Ray Knight 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 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from 'rollup-plugin-typescript2' 2 | 3 | import pkg from './package.json' 4 | 5 | const external = [ 6 | ...Object.keys(pkg.dependencies || {}), 7 | ...Object.keys(pkg.peerDependencies || {}), 8 | ] 9 | const options = { 10 | clean: true, 11 | tsconfigOverride: { 12 | exclude: ['**/__tests__/**', '**/test.tsx', '**/stories.tsx'], 13 | }, 14 | typescript: require('typescript'), 15 | } 16 | const output = { 17 | format: 'cjs', 18 | preferConst: true, 19 | } 20 | 21 | export default [ 22 | { 23 | external, 24 | input: 'src/index.ts', 25 | output: { 26 | ...output, 27 | file: pkg.main, 28 | }, 29 | plugins: [typescript(options)], 30 | }, 31 | { 32 | external, 33 | input: 'src/register.tsx', 34 | output: { 35 | ...output, 36 | file: 'dist/register.js', 37 | }, 38 | plugins: [ 39 | typescript({ 40 | ...options, 41 | tsconfigOverride: { 42 | ...options.tsconfigOverride, 43 | compilerOptions: { declaration: false }, 44 | }, 45 | }), 46 | ], 47 | }, 48 | ] 49 | -------------------------------------------------------------------------------- /src/types/parameters.ts: -------------------------------------------------------------------------------- 1 | import type { DocumentNode } from '@apollo/client' 2 | 3 | import type { Transform } from './generic' 4 | import type { GraphQLOptions, RestfulOptions } from './options' 5 | import type { KnownSchema } from './schemas' 6 | 7 | export type PackedDocumentNode = Omit & { 8 | definitions: string[] 9 | source?: string 10 | } 11 | 12 | export type HeadlessParameter = string | PackedDocumentNode | ApiParameters 13 | 14 | export interface HeadlessParameters { 15 | [name: string]: HeadlessParameter 16 | } 17 | 18 | export interface VariableParameters { 19 | [name: string]: KnownSchema 20 | } 21 | 22 | export interface BaseParameters { 23 | base?: string 24 | variables?: VariableParameters 25 | defaults?: Record 26 | transforms?: Record 27 | autoFetchOnInit?: boolean 28 | } 29 | 30 | export interface GraphQLParameters extends BaseParameters { 31 | query: PackedDocumentNode 32 | config?: GraphQLOptions 33 | } 34 | 35 | export interface RestfulParameters extends BaseParameters { 36 | query: string 37 | config?: RestfulOptions 38 | convertToFormData?: boolean 39 | } 40 | 41 | export type ApiParameters = GraphQLParameters | RestfulParameters 42 | -------------------------------------------------------------------------------- /src/components/Message/stories.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react' 2 | import { boolean, text, withKnobs } from '@storybook/addon-knobs' 3 | 4 | import { Message } from '.' 5 | 6 | export default { 7 | title: 'Message', 8 | decorators: [withKnobs], 9 | } 10 | 11 | export const MessageStory = (): ReactElement => { 12 | const children = text( 13 | 'children', 14 | `Hello World 15 | 16 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis vel tortor ac elit tincidunt consequat a vel metus. Nunc mollis ligula odio, at consectetur nulla interdum ut. Fusce sed lacus ut dolor pellentesque tincidunt. Suspendisse sit amet quam vel mi dapibus sodales ac ac enim. Nulla mattis erat eu lorem sodales eleifend. Praesent ac cursus nulla, eu cursus ante. Phasellus malesuada egestas enim, eget mollis urna rhoncus vel. Mauris laoreet lorem enim, et iaculis purus tempus quis. Etiam eu lacus ut odio aliquam tempor. Praesent at tortor sem. Nunc eget efficitur magna.`, 17 | ) 18 | const collapsible = boolean('collapsible', false) 19 | const collapsed = boolean('collapsed', true) 20 | 21 | return ( 22 | 23 | {children} 24 | 25 | ) 26 | } 27 | 28 | MessageStory.storyName = 'Message' 29 | -------------------------------------------------------------------------------- /src/components/Message/styled.ts: -------------------------------------------------------------------------------- 1 | import { css, styled } from '@storybook/theming' 2 | 3 | export const Root = styled.div( 4 | ({ theme }) => css` 5 | max-height: ${theme.isCollapsed ? '30px' : '600px'}; 6 | padding: 0.42em 1em; 7 | border: 1px solid ${theme.input.border}; 8 | border-radius: ${theme.input.borderRadius}px; 9 | position: relative; 10 | transition: max-height 500ms; 11 | overflow: ${theme.isCollapsed ? 'hidden' : 'auto'}; 12 | cursor: ${theme.collapasble ? 'pointer' : 'default'}; 13 | 14 | &:before { 15 | content: ''; 16 | width: 100%; 17 | height: 100%; 18 | background: ${theme.background.app}; 19 | position: absolute; 20 | top: 0; 21 | right: 0; 22 | bottom: 0; 23 | left: 0; 24 | opacity: 0.5; 25 | } 26 | `, 27 | ) 28 | 29 | export const Pre = styled.pre` 30 | padding: 0; 31 | margin: 0; 32 | position: relative; 33 | ` 34 | 35 | export const Button = styled.button` 36 | width: 30px; 37 | height: 30px; 38 | padding: 5px; 39 | border: 0; 40 | background: none; 41 | position: absolute; 42 | top: 0; 43 | right: 0; 44 | appearance: none; 45 | cursor: pointer; 46 | ` 47 | -------------------------------------------------------------------------------- /src/components/Variable/Boolean.tsx: -------------------------------------------------------------------------------- 1 | import React, { ChangeEvent, memo } from 'react' 2 | 3 | import type { BooleanSchema } from '../../types' 4 | import { Error, Row } from './styled' 5 | 6 | export interface Props { 7 | schema: BooleanSchema 8 | value: boolean | undefined 9 | error: string | null 10 | isValid: boolean 11 | onChange: (value: boolean) => void 12 | } 13 | 14 | export const TEST_IDS = Object.freeze({ 15 | root: 'BooleanVariableRoot', 16 | input: 'BooleanVariableInput', 17 | error: 'BooleanVariableError', 18 | }) 19 | 20 | export const BooleanInput = memo( 21 | ({ value, error, isValid, onChange }: Props) => { 22 | function update(event: ChangeEvent): void { 23 | onChange(event.target.checked) 24 | } 25 | 26 | return ( 27 | 28 | 34 | {!isValid && error && ( 35 | {error} 36 | )} 37 | 38 | ) 39 | }, 40 | ) 41 | 42 | BooleanInput.displayName = 'Boolean' 43 | -------------------------------------------------------------------------------- /src/types/state.ts: -------------------------------------------------------------------------------- 1 | import type { StoryContext } from '@storybook/addons' 2 | import type { AnyValidateFunction } from 'ajv/lib/types' 3 | 4 | import type { HeadlessOptions } from './options' 5 | import type { AnySchema } from './schemas' 6 | 7 | export enum FetchStatus { 8 | Inactive = 'INACTIVE', 9 | Loading = 'LOADING', 10 | Rejected = 'REJECTED', 11 | Resolved = 'RESOLVED', 12 | } 13 | 14 | export enum VariableType { 15 | Unknown = 'UNKNOWN', 16 | Boolean = 'BOOLEAN', 17 | Date = 'DATE', 18 | Number = 'NUMBER', 19 | Select = 'SELECT', 20 | String = 'STRING', 21 | } 22 | 23 | export interface HeadlessState< 24 | T extends Record = Record, 25 | > { 26 | storyId: string 27 | options: Required 28 | status: Record 29 | data: T 30 | errors: Record> 31 | } 32 | 33 | export interface VariableState { 34 | schema: AnySchema 35 | type: VariableType 36 | validator: AnyValidateFunction 37 | dirty: boolean 38 | error: string | null 39 | value: unknown 40 | } 41 | 42 | export type HeadlessStoryContext< 43 | T extends Record = Record, 44 | > = StoryContext & Pick, 'status' | 'data' | 'errors'> 45 | -------------------------------------------------------------------------------- /.storybook-dist/main.js: -------------------------------------------------------------------------------- 1 | const createCompiler = require('@storybook/addon-docs/mdx-compiler-plugin') 2 | 3 | module.exports = { 4 | addons: ['@storybook/addon-knobs', '../register'], 5 | stories: ['../src/examples/*.stories.mdx', '../src/examples/*.stories.tsx'], 6 | webpackFinal: async (config) => { 7 | config.module.rules.push( 8 | { 9 | test: /\.(stories|story)\.mdx$/, 10 | use: [ 11 | { 12 | loader: 'babel-loader', 13 | options: { 14 | plugins: ['@babel/plugin-transform-react-jsx'], 15 | }, 16 | }, 17 | { 18 | loader: '@mdx-js/loader', 19 | options: { 20 | compilers: [createCompiler({})], 21 | }, 22 | }, 23 | ], 24 | }, 25 | { 26 | test: /\.(ts|tsx)$/, 27 | loader: require.resolve('babel-loader'), 28 | options: { 29 | presets: [['react-app', { flow: false, typescript: true }]], 30 | }, 31 | }, 32 | ) 33 | 34 | config.resolve.extensions.push('.ts', '.tsx') 35 | 36 | return config 37 | }, 38 | } 39 | -------------------------------------------------------------------------------- /src/components/Variable/String.tsx: -------------------------------------------------------------------------------- 1 | import { Form } from '@storybook/components' 2 | import React, { ChangeEvent, memo } from 'react' 3 | 4 | import type { StringSchema } from '../../types' 5 | import { Error, Row } from './styled' 6 | 7 | export interface Props { 8 | schema: StringSchema 9 | value: string | undefined 10 | error: string | null 11 | isValid: boolean 12 | onChange: (value: string) => void 13 | } 14 | 15 | export const TEST_IDS = Object.freeze({ 16 | root: 'StringVariableRoot', 17 | input: 'StringVariableInput', 18 | error: 'StringVariableError', 19 | }) 20 | 21 | export const StringInput = memo( 22 | ({ value, error, isValid, onChange }: Props) => { 23 | function update(event: ChangeEvent): void { 24 | onChange(event.target.value) 25 | } 26 | 27 | return ( 28 | 29 | 36 | {!isValid && error && ( 37 | {error} 38 | )} 39 | 40 | ) 41 | }, 42 | ) 43 | 44 | StringInput.displayName = 'String' 45 | -------------------------------------------------------------------------------- /src/components/ErrorBoundary/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component, ErrorInfo, ReactNode } from 'react' 2 | 3 | // eslint-disable-next-line @typescript-eslint/no-empty-interface 4 | interface Props {} 5 | 6 | interface State { 7 | error: string | null 8 | stack: string | null 9 | } 10 | 11 | export class ErrorBoundary extends Component { 12 | public static getDerivedStateFromError(error: Error): State { 13 | return { error: error.message, stack: error.stack ?? null } 14 | } 15 | 16 | public state: State = { 17 | error: null, 18 | stack: null, 19 | } 20 | 21 | public componentDidCatch(error: Error, errorInfo: ErrorInfo): void { 22 | this.setState({ 23 | error: error.message, 24 | stack: error.stack 25 | ? `${error.stack}\n\n${errorInfo.componentStack}` 26 | : errorInfo.componentStack, 27 | }) 28 | } 29 | 30 | public render(): ReactNode { 31 | const { error, stack } = this.state 32 | 33 | if (error) { 34 | return ( 35 | <> 36 |

Something went wrong.

37 |

{error}

38 |
39 |                         {stack}
40 |                     
41 | 42 | ) 43 | } 44 | 45 | return this.props.children 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/components/Variable/Number.tsx: -------------------------------------------------------------------------------- 1 | import { Form } from '@storybook/components' 2 | import React, { ChangeEvent, memo } from 'react' 3 | 4 | import type { NumberSchema } from '../../types' 5 | import { isUndefined } from '../../utilities' 6 | import { Error, Row } from './styled' 7 | 8 | export interface Props { 9 | schema: NumberSchema 10 | value: number | undefined 11 | error: string | null 12 | isValid: boolean 13 | onChange: (value: number) => void 14 | } 15 | 16 | export const TEST_IDS = Object.freeze({ 17 | root: 'NumberVariableRoot', 18 | input: 'NumberVariableInput', 19 | error: 'NumberVariableError', 20 | }) 21 | 22 | export const NumberInput = memo( 23 | ({ value, error, isValid, onChange }: Props) => { 24 | function update(event: ChangeEvent): void { 25 | onChange(parseFloat(event.target.value)) 26 | } 27 | 28 | return ( 29 | 30 | 37 | {!isValid && error && ( 38 | {error} 39 | )} 40 | 41 | ) 42 | }, 43 | ) 44 | 45 | NumberInput.displayName = 'Number' 46 | -------------------------------------------------------------------------------- /src/components/Variable/__tests__/Unknown.tsx: -------------------------------------------------------------------------------- 1 | import { convert, ThemeProvider, themes } from '@storybook/theming' 2 | import { cleanup, render, RenderResult } from '@testing-library/react' 3 | import '@testing-library/jest-dom/extend-expect' 4 | import mockConsole from 'jest-mock-console' 5 | import React from 'react' 6 | 7 | import { UnknownInput, MESSAGE, Props, TEST_IDS } from '../Unknown' 8 | 9 | describe('Unknown', () => { 10 | afterEach(cleanup) 11 | 12 | function setup({ 13 | schema = { type: 'unknown' }, 14 | value, 15 | error = null, 16 | isValid = true, 17 | onChange = jest.fn(), 18 | }: Partial = {}): RenderResult & { 19 | props: Props 20 | } { 21 | const props: Props = { 22 | schema, 23 | value, 24 | error, 25 | isValid, 26 | onChange, 27 | } 28 | 29 | return { 30 | ...render( 31 | 32 | 33 | , 34 | ), 35 | props, 36 | } 37 | } 38 | 39 | it('should render', () => { 40 | mockConsole() 41 | 42 | const { 43 | getByTestId, 44 | props: { schema, value }, 45 | } = setup() 46 | 47 | expect(getByTestId(TEST_IDS.root)).toBeInTheDocument() 48 | 49 | expect(console.warn).toHaveBeenCalledWith(MESSAGE, { schema, value }) 50 | }) 51 | }) 52 | -------------------------------------------------------------------------------- /src/components/Select/stories.tsx: -------------------------------------------------------------------------------- 1 | import { action } from '@storybook/addon-actions' 2 | import { array, boolean, withKnobs } from '@storybook/addon-knobs' 3 | import React, { ReactElement, useState } from 'react' 4 | 5 | import type { Item } from '../../types' 6 | import { convertToItem, isArray, isNull } from '../../utilities' 7 | import { Select } from '.' 8 | 9 | export default { 10 | title: 'Select', 11 | component: Select, 12 | decorators: [withKnobs], 13 | } 14 | 15 | export const SelectStory = (): ReactElement => { 16 | const items = array('items', [ 17 | 'Foo', 18 | 'Bar', 19 | 'Baz', 20 | 'Wux', 21 | 'Lorem Ipsum Dolor', 22 | ]) 23 | const [value, setValue] = useState([]) 24 | const isMulti = boolean('isMulti', false) 25 | 26 | function onChange(val: Item | Item[] | null): void { 27 | action('onChange')(val) 28 | 29 | if (isNull(val)) { 30 | setValue([]) 31 | } else { 32 | setValue(isArray(val) ? val.map((item) => item.value) : [val.value]) 33 | } 34 | } 35 | 36 | return ( 37 | 45 | {!isValid && error && ( 46 | {error} 47 | )} 48 | 49 | ) 50 | }, 51 | ) 52 | 53 | SelectInput.displayName = 'Select' 54 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "jest": true 6 | }, 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:react/recommended", 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:@typescript-eslint/recommended", 12 | "plugin:@typescript-eslint/recommended-requiring-type-checking", 13 | "plugin:jest-dom/recommended", 14 | "plugin:prettier/recommended", 15 | "plugin:testing-library/react" 16 | ], 17 | "parser": "@typescript-eslint/parser", 18 | "parserOptions": { 19 | "project": "tsconfig.json", 20 | "sourceType": "module" 21 | }, 22 | "plugins": ["@typescript-eslint", "prettier", "react", "react-hooks"], 23 | "rules": { 24 | "@typescript-eslint/explicit-function-return-type": [ 25 | 2, 26 | { 27 | "allowExpressions": true, 28 | "allowTypedFunctionExpressions": true, 29 | "allowHigherOrderFunctions": true 30 | } 31 | ], 32 | "@typescript-eslint/no-unused-vars": [1, { "args": "after-used" }], 33 | "@typescript-eslint/no-unsafe-return": [0], 34 | "@typescript-eslint/no-use-before-define": [ 35 | 2, 36 | { 37 | "classes": true, 38 | "functions": false, 39 | "variables": true 40 | } 41 | ], 42 | "@typescript-eslint/restrict-template-expressions": [0], 43 | "react/prop-types": [0], 44 | "react-hooks/exhaustive-deps": "warn", 45 | "react-hooks/rules-of-hooks": "error" 46 | }, 47 | "settings": { 48 | "react": { 49 | "pragma": "React", 50 | "version": "detect" 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/components/Variable/__tests__/Select.tsx: -------------------------------------------------------------------------------- 1 | import { convert, ThemeProvider, themes } from '@storybook/theming' 2 | import { 3 | cleanup, 4 | getNodeText, 5 | render, 6 | RenderResult, 7 | } from '@testing-library/react' 8 | import '@testing-library/jest-dom/extend-expect' 9 | import React from 'react' 10 | 11 | import { SelectInput, Props, TEST_IDS } from '../Select' 12 | 13 | describe('Select', () => { 14 | afterEach(cleanup) 15 | 16 | function setup({ 17 | schema = { type: 'string', enum: ['foo', 'bar', 'wux'] }, 18 | value, 19 | error = null, 20 | isValid = true, 21 | onChange = jest.fn(), 22 | }: Partial = {}): RenderResult & { 23 | props: Props 24 | } { 25 | const props: Props = { 26 | schema, 27 | value, 28 | error, 29 | isValid, 30 | onChange, 31 | } 32 | 33 | return { 34 | ...render( 35 | 36 | 37 | , 38 | ), 39 | props, 40 | } 41 | } 42 | 43 | it('should render', () => { 44 | const { getByTestId } = setup() 45 | 46 | expect(getByTestId(TEST_IDS.root)).toBeInTheDocument() 47 | }) 48 | 49 | it('should render error when invalid', () => { 50 | const error = 'Error message' 51 | const { getByTestId } = setup({ 52 | isValid: false, 53 | error, 54 | }) 55 | 56 | expect(getByTestId(TEST_IDS.error)).toBeInTheDocument() 57 | expect(getNodeText(getByTestId(TEST_IDS.error))).toEqual(error) 58 | }) 59 | 60 | it.skip('should be a controlled input', () => { 61 | // const { getByTestId, props, rerender } = setup() 62 | }) 63 | }) 64 | -------------------------------------------------------------------------------- /src/examples/options.stories.mdx: -------------------------------------------------------------------------------- 1 | import { DocsContainer, DocsPage, Meta } from '@storybook/addon-docs/blocks' 2 | 3 | 13 | 14 | # Options 15 | 16 | By utilizing options when setting up the Headless decorator, its possible to establish a base config that will keep individual story parameters simple. Global setup pieces like authentication headers should be done here too. 17 | 18 | ## Restful 19 | 20 | A partial [Axios config](https://github.com/axios/axios#request-config) can be passed in as a base config. The most common use case would be to establish a `baseURL` so that all stories can have a simpler relative query. 21 | 22 | Or an array of partial Axios configs with ids. Each config's id should be unique and is used to look up the base config referenced by the `base` property in the story parameters. 23 | 24 | ## GraphQL 25 | 26 | A partial [Apollo Client config](https://www.apollographql.com/docs/react/api/core/ApolloClient/#required-fields) can be passed in as a base config. The most common use case would be to establish a `uri` so that all stories can simply define a query without the need for an additional config. 27 | 28 | Or an array of partial Apollo Boost configs with ids. Each config's id should be unique and is used to look up the base config referenced by the `base` property in the story parameters. 29 | 30 | ## Theming 31 | 32 | If you're customizing the theme of your Storybook to match your company's branding, you may wish to switch up the JSON editor theme as well. The `jsonDark` and `jsonLight` options allow you to [pick a theme](https://github.com/mac-s-g/react-json-view#customizing-style) of your choosing. All other component styles should inherit theme from Storybook. 33 | -------------------------------------------------------------------------------- /src/components/Variable/__tests__/Boolean.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | cleanup, 3 | fireEvent, 4 | getNodeText, 5 | render, 6 | RenderResult, 7 | } from '@testing-library/react' 8 | import '@testing-library/jest-dom/extend-expect' 9 | import React from 'react' 10 | 11 | import { BooleanInput, Props, TEST_IDS } from '../Boolean' 12 | 13 | describe('Boolean', () => { 14 | afterEach(cleanup) 15 | 16 | function setup({ 17 | schema = { type: 'boolean' }, 18 | value, 19 | error = null, 20 | isValid = true, 21 | onChange = jest.fn(), 22 | }: Partial = {}): RenderResult & { 23 | props: Props 24 | } { 25 | const props: Props = { 26 | schema, 27 | value, 28 | error, 29 | isValid, 30 | onChange, 31 | } 32 | 33 | return { 34 | ...render(), 35 | props, 36 | } 37 | } 38 | 39 | it('should render', () => { 40 | const { getByTestId } = setup() 41 | 42 | expect(getByTestId(TEST_IDS.root)).toBeInTheDocument() 43 | }) 44 | 45 | it('should render error when invalid', () => { 46 | const error = 'Error message' 47 | const { getByTestId } = setup({ 48 | isValid: false, 49 | error, 50 | }) 51 | 52 | expect(getByTestId(TEST_IDS.error)).toBeInTheDocument() 53 | expect(getNodeText(getByTestId(TEST_IDS.error))).toEqual(error) 54 | }) 55 | 56 | it('should be a controlled input', () => { 57 | const { getByTestId, props, rerender } = setup() 58 | const input = getByTestId(TEST_IDS.input) 59 | 60 | expect(input).not.toBeChecked() 61 | 62 | fireEvent.click(input) 63 | 64 | expect(input).not.toBeChecked() 65 | expect(props.onChange).toHaveBeenCalledWith(true) 66 | 67 | rerender() 68 | 69 | expect(input).toBeChecked() 70 | expect(props.onChange).toHaveBeenCalledTimes(1) 71 | }) 72 | }) 73 | -------------------------------------------------------------------------------- /src/components/Variable/index.tsx: -------------------------------------------------------------------------------- 1 | import { Form } from '@storybook/components' 2 | import { noCase } from 'change-case' 3 | import React, { memo, useCallback } from 'react' 4 | 5 | import { AnySchema, VariableType } from '../../types' 6 | import { isNull, noopTransform } from '../../utilities' 7 | import { BooleanInput } from './Boolean' 8 | import { DateTimeInput } from './Date' 9 | import { NumberInput } from './Number' 10 | import { SelectInput } from './Select' 11 | import { StringInput } from './String' 12 | import { UnknownInput } from './Unknown' 13 | 14 | export interface Props { 15 | name: string 16 | schema: AnySchema 17 | type: VariableType 18 | value: unknown 19 | error: string | null 20 | onChange: (name: string, value: unknown) => void 21 | } 22 | 23 | export const Variable = memo( 24 | ({ name, schema, type, value, error, onChange }: Props) => { 25 | const label = noCase(name, { transform: noopTransform }) 26 | const isValid = isNull(error) 27 | const Component = { 28 | [VariableType.Boolean]: BooleanInput, 29 | [VariableType.Date]: DateTimeInput, 30 | [VariableType.Number]: NumberInput, 31 | [VariableType.Select]: SelectInput, 32 | [VariableType.String]: StringInput, 33 | [VariableType.Unknown]: UnknownInput, 34 | }[type] 35 | const change = useCallback( 36 | (value: unknown) => onChange(name, value), 37 | [name, onChange], 38 | ) 39 | 40 | /* eslint-disable */ 41 | return ( 42 | 43 | 50 | 51 | ) 52 | /* eslint-enable */ 53 | }, 54 | ) 55 | 56 | Variable.displayName = 'Variable' 57 | -------------------------------------------------------------------------------- /src/examples/restful.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Args } from '@storybook/addons' 2 | import React, { ReactElement } from 'react' 3 | 4 | import { 5 | FetchStatus, 6 | HeadlessStoryContext, 7 | Loader, 8 | Prompt, 9 | withHeadless, 10 | } from '../../dist' 11 | 12 | import { User as UserCard, UserProps } from '.' 13 | 14 | export default { 15 | title: 'Examples/Restful', 16 | decorators: [ 17 | withHeadless({ 18 | restful: { 19 | baseURL: 'https://jsonplaceholder.typicode.com/', 20 | }, 21 | }), 22 | ], 23 | parameters: { 24 | headless: { 25 | Users: { 26 | query: 'users', 27 | autoFetchOnInit: true, 28 | }, 29 | User: { 30 | query: 'users/{id}', 31 | variables: { 32 | id: { 33 | type: 'integer', 34 | minimum: 1, 35 | }, 36 | }, 37 | }, 38 | }, 39 | }, 40 | } 41 | 42 | export const Users = ( 43 | args: Args, 44 | { status, data }: HeadlessStoryContext<{ Users?: UserProps[] }>, 45 | ): ReactElement | null => { 46 | switch (status?.Users) { 47 | case FetchStatus.Inactive: 48 | case FetchStatus.Rejected: 49 | return 50 | 51 | case FetchStatus.Loading: 52 | return 53 | 54 | default: 55 | return Array.isArray(data?.Users) ? ( 56 | <> 57 | {data.Users.map((user) => ( 58 | 59 | ))} 60 | 61 | ) : null 62 | } 63 | } 64 | 65 | export const User = ( 66 | args: Args, 67 | { 68 | status, 69 | data, 70 | }: HeadlessStoryContext<{ Users?: UserProps[]; User?: UserProps }>, 71 | ): ReactElement | null => { 72 | switch (status?.User) { 73 | case FetchStatus.Inactive: 74 | case FetchStatus.Rejected: 75 | return 76 | 77 | case FetchStatus.Loading: 78 | return 79 | 80 | default: 81 | return data?.User ? : null 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/examples/components.stories.mdx: -------------------------------------------------------------------------------- 1 | import { DocsContainer, DocsPage, Meta } from '@storybook/addon-docs/blocks' 2 | 3 | 13 | 14 | # Components 15 | 16 | To help provide a better user experience, there are Prompt and Loader helper components provided. 17 | 18 | These components are entirely optional, but will help to direct users to the Headless tab if necessary and provide feedback about the state of active API requests. 19 | 20 | Example: 21 | 22 | ```js 23 | import React, { ReactElement } from 'react' 24 | import { Args } from '@storybook/addons' 25 | import { 26 | FetchStatus, 27 | HeadlessSotryContext, 28 | Loader, 29 | Prompt, 30 | } from 'storybook-addon-headless' 31 | 32 | import { Component } from './Component' 33 | 34 | export const story = ( 35 | args: Args, 36 | { status, data }: HeadlessStoryContext, 37 | ): ReactElement | null => { 38 | switch (status?.Foo) { 39 | case FetchStatus.Inactive: 40 | case FetchStatus.Rejected: 41 | return 42 | 43 | case FetchStatus.Loading: 44 | return 45 | 46 | default: 47 | return data?.Foo ? : null 48 | } 49 | } 50 | ``` 51 | 52 | Feel free to pass in custom a `headline` and/or `message` to the `Prompt` to make it more specific to your story or to the status of the API call. 53 | 54 | **Experimental** _(read: untested)_: 55 | 56 | There are also two methods for those of you not using React, but wanting to use these helper components. `useHeadlessPrompt` and `useHeadlessLoader` will render the React components as standalone apps, but you must provide an HTML element reference that has been rendered and mounted by your framework of choice. 57 | 58 | Theoretically: 59 | 60 | ```js 61 | import { useHeadlessLoader } from 'storybook-addon-headless' 62 | 63 | export const story = () => { 64 | let ref 65 | 66 | onMount(() => { 67 | useHeadlessLoader(ref) 68 | }) 69 | 70 | return
71 | } 72 | ``` 73 | 74 | This will vary depending on how Storybook supports your framework, how stories are written, as well as on how your framework handles DOM element references. 75 | 76 | My apologies if this doesn't work our for your particular use case. 77 | -------------------------------------------------------------------------------- /src/components/Variable/__tests__/Number.tsx: -------------------------------------------------------------------------------- 1 | import { convert, ThemeProvider, themes } from '@storybook/theming' 2 | import { 3 | cleanup, 4 | fireEvent, 5 | getNodeText, 6 | render, 7 | RenderResult, 8 | } from '@testing-library/react' 9 | import '@testing-library/jest-dom/extend-expect' 10 | import React from 'react' 11 | 12 | import { NumberInput, Props, TEST_IDS } from '../Number' 13 | 14 | describe('Number', () => { 15 | afterEach(cleanup) 16 | 17 | function setup({ 18 | schema = { type: 'number' }, 19 | value, 20 | error = null, 21 | isValid = true, 22 | onChange = jest.fn(), 23 | }: Partial = {}): RenderResult & { 24 | props: Props 25 | } { 26 | const props: Props = { 27 | schema, 28 | value, 29 | error, 30 | isValid, 31 | onChange, 32 | } 33 | 34 | return { 35 | ...render( 36 | 37 | 38 | , 39 | ), 40 | props, 41 | } 42 | } 43 | 44 | it('should render', () => { 45 | const { getByTestId } = setup() 46 | 47 | expect(getByTestId(TEST_IDS.root)).toBeInTheDocument() 48 | }) 49 | 50 | it('should render error when invalid', () => { 51 | const error = 'Error message' 52 | const { getByTestId } = setup({ 53 | isValid: false, 54 | error, 55 | }) 56 | 57 | expect(getByTestId(TEST_IDS.error)).toBeInTheDocument() 58 | expect(getNodeText(getByTestId(TEST_IDS.error))).toEqual(error) 59 | }) 60 | 61 | it('should be a controlled input', () => { 62 | const { getByTestId, props, rerender } = setup() 63 | const input = getByTestId(TEST_IDS.input) as HTMLInputElement 64 | const value = 42 65 | 66 | expect(input.value).toEqual('') 67 | 68 | fireEvent.change(input, { target: { value } }) 69 | 70 | expect(input.value).toEqual('') 71 | expect(props.onChange).toHaveBeenCalledWith(value) 72 | 73 | rerender( 74 | 75 | 76 | , 77 | ) 78 | 79 | expect(input.value).toEqual(`${value}`) 80 | expect(props.onChange).toHaveBeenCalledTimes(1) 81 | }) 82 | }) 83 | -------------------------------------------------------------------------------- /src/components/Variable/__tests__/String.tsx: -------------------------------------------------------------------------------- 1 | import { convert, ThemeProvider, themes } from '@storybook/theming' 2 | import { 3 | cleanup, 4 | fireEvent, 5 | getNodeText, 6 | render, 7 | RenderResult, 8 | } from '@testing-library/react' 9 | import '@testing-library/jest-dom/extend-expect' 10 | import React from 'react' 11 | 12 | import { StringInput, Props, TEST_IDS } from '../String' 13 | 14 | describe('String', () => { 15 | afterEach(cleanup) 16 | 17 | function setup({ 18 | schema = { type: 'string' }, 19 | value, 20 | error = null, 21 | isValid = true, 22 | onChange = jest.fn(), 23 | }: Partial = {}): RenderResult & { 24 | props: Props 25 | } { 26 | const props: Props = { 27 | schema, 28 | value, 29 | error, 30 | isValid, 31 | onChange, 32 | } 33 | 34 | return { 35 | ...render( 36 | 37 | 38 | , 39 | ), 40 | props, 41 | } 42 | } 43 | 44 | it('should render', () => { 45 | const { getByTestId } = setup() 46 | 47 | expect(getByTestId(TEST_IDS.root)).toBeInTheDocument() 48 | }) 49 | 50 | it('should render error when invalid', () => { 51 | const error = 'Error message' 52 | const { getByTestId } = setup({ 53 | isValid: false, 54 | error, 55 | }) 56 | 57 | expect(getByTestId(TEST_IDS.error)).toBeInTheDocument() 58 | expect(getNodeText(getByTestId(TEST_IDS.error))).toEqual(error) 59 | }) 60 | 61 | it('should be a controlled input', () => { 62 | const { getByTestId, props, rerender } = setup() 63 | const input = getByTestId(TEST_IDS.input) as HTMLInputElement 64 | const value = 'foo' 65 | 66 | expect(input.value).toEqual('') 67 | 68 | fireEvent.change(input, { target: { value } }) 69 | 70 | expect(input.value).toEqual('') 71 | expect(props.onChange).toHaveBeenCalledWith(value) 72 | 73 | rerender( 74 | 75 | 76 | , 77 | ) 78 | 79 | expect(input.value).toEqual(value) 80 | expect(props.onChange).toHaveBeenCalledTimes(1) 81 | }) 82 | }) 83 | -------------------------------------------------------------------------------- /src/components/Prompt/index.tsx: -------------------------------------------------------------------------------- 1 | import addons from '@storybook/addons' 2 | import { Form, Icons } from '@storybook/components' 3 | import { 4 | convert, 5 | createReset, 6 | Global, 7 | ThemeProvider, 8 | themes, 9 | } from '@storybook/theming' 10 | import React, { memo, ReactElement, ReactNode } from 'react' 11 | import ReactDOM from 'react-dom' 12 | 13 | import { PANEL_TITLE, EVENT_REQUESTED_ADDON } from '../../config' 14 | import { Root, Content } from './styled' 15 | 16 | export interface Props { 17 | headline?: ReactNode 18 | message?: ReactNode 19 | } 20 | 21 | export const TEST_IDS = Object.freeze({ 22 | root: 'PromptRoot', 23 | headline: 'PromptHeadline', 24 | message: 'PromptMessage', 25 | button: 'PromptButton', 26 | }) 27 | 28 | export const Prompt = memo(({ headline =

29 | Something is missing... 30 |

, message =

31 | This component story relies on data fetched from an API. Head over to the {PANEL_TITLE} tab to configure and execute the API call. Once the data has been fetched, head on back here and the component story should be rendered. 32 |

}: Props): ReactElement => { 33 | const theme = convert(themes.normal) 34 | 35 | function emit(): void { 36 | addons.getChannel().emit(EVENT_REQUESTED_ADDON) 37 | } 38 | 39 | return ( 40 | 41 | 42 | 43 | 44 | 45 | {headline && ( 46 |
{headline}
47 | )} 48 | {message && ( 49 |
{message}
50 | )} 51 | 52 | Continue 53 | 54 | 55 |
56 |
57 |
58 | ) 59 | }) 60 | 61 | Prompt.displayName = 'Prompt' 62 | 63 | export function useHeadlessPrompt( 64 | element: HTMLElement, 65 | props: Partial = {}, 66 | ): void { 67 | ReactDOM.render(, element) 68 | } 69 | -------------------------------------------------------------------------------- /src/components/Message/index.tsx: -------------------------------------------------------------------------------- 1 | import { Icons } from '@storybook/components' 2 | import { ThemeProvider } from '@storybook/theming' 3 | import React, { 4 | memo, 5 | ReactElement, 6 | ReactNode, 7 | useCallback, 8 | useEffect, 9 | useState, 10 | } from 'react' 11 | 12 | import { Button, Pre, Root } from './styled' 13 | 14 | export interface Props { 15 | children: ReactNode 16 | collapsible?: boolean 17 | collapsed?: boolean 18 | } 19 | 20 | export const TEST_IDS = Object.freeze({ 21 | root: 'MessageRoot', 22 | content: 'MessageContent', 23 | toggle: 'MessageToggle', 24 | icon: 'MessageIcon', 25 | }) 26 | 27 | export const Message = memo( 28 | ({ 29 | children, 30 | collapsible = false, 31 | collapsed = true, 32 | }: Props): ReactElement | null => { 33 | const [isCollapsed, setIsCollapsed] = useState(collapsed) 34 | const toggle = useCallback( 35 | () => setIsCollapsed(!isCollapsed), 36 | [isCollapsed], 37 | ) 38 | 39 | useEffect(() => { 40 | if (collapsed !== isCollapsed) { 41 | setIsCollapsed(collapsed) 42 | } 43 | }, [collapsed]) // eslint-disable-line react-hooks/exhaustive-deps 44 | 45 | if (children) { 46 | return ( 47 | 53 | 54 |
{children}
55 | {collapsible && ( 56 | 67 | )} 68 |
69 |
70 | ) 71 | } 72 | 73 | return null 74 | }, 75 | ) 76 | 77 | Message.displayName = 'Message' 78 | -------------------------------------------------------------------------------- /src/components/Variables/stories.tsx: -------------------------------------------------------------------------------- 1 | import { action } from '@storybook/addon-actions' 2 | import { object, withKnobs } from '@storybook/addon-knobs' 3 | import React, { ReactElement } from 'react' 4 | 5 | import type { ApiParameters } from '../../types' 6 | import { Variables } from '.' 7 | 8 | export default { 9 | title: 'Variables', 10 | decorators: [withKnobs], 11 | } 12 | 13 | export const VariablesStory = (): ReactElement => { 14 | const parameters = object('parameters', { 15 | query: 'https://server.mock/', 16 | variables: { 17 | Boolean: { 18 | type: 'boolean', 19 | }, 20 | Date: { 21 | type: 'string', 22 | format: 'date', 23 | }, 24 | DateTime: { 25 | type: 'string', 26 | format: 'date-time', 27 | }, 28 | Time: { 29 | type: 'string', 30 | format: 'time', 31 | }, 32 | Float: { 33 | type: 'number', 34 | minimum: 1, 35 | }, 36 | Integer: { 37 | type: 'integer', 38 | minimum: 0, 39 | }, 40 | Select: { 41 | type: 'string', 42 | enum: [ 43 | 'foo', 44 | 'bar', 45 | 'baz', 46 | 'wux', 47 | 'Lorem Ipsum', 48 | { label: 'Item 6', value: '666' }, 49 | ], 50 | }, 51 | String: { 52 | type: 'string', 53 | }, 54 | }, 55 | defaults: { 56 | Float: 1.75, 57 | Select: '666', 58 | String: 'foo bar', 59 | }, 60 | }) 61 | 62 | function onFetch(variables: Record): Promise { 63 | action('onFetch')(variables) 64 | 65 | return new Promise((resolve, reject) => { 66 | setTimeout(() => { 67 | if (Math.random() > 0.5) { 68 | resolve() 69 | } else { 70 | reject() 71 | } 72 | }, 3000 * Math.random()) 73 | }) 74 | } 75 | 76 | return ( 77 | 83 | ) 84 | } 85 | 86 | VariablesStory.storyName = 'Parameters' 87 | -------------------------------------------------------------------------------- /src/components/Prompt/test.tsx: -------------------------------------------------------------------------------- 1 | import addons from '@storybook/addons' 2 | import { 3 | cleanup, 4 | fireEvent, 5 | render, 6 | RenderResult, 7 | } from '@testing-library/react' 8 | import '@testing-library/jest-dom/extend-expect' 9 | import React from 'react' 10 | 11 | import { EVENT_REQUESTED_ADDON } from '../../config' 12 | import { Prompt, Props, TEST_IDS } from '.' 13 | 14 | jest.mock('@storybook/addons', () => { 15 | const emit = jest.fn() 16 | const getChannel = jest.fn(() => ({ emit })) 17 | 18 | return { 19 | getChannel, 20 | } 21 | }) 22 | 23 | describe('Prompt', () => { 24 | afterEach(cleanup) 25 | 26 | const testId = 'CustomTestId' 27 | 28 | function setup(props: Props = {}): RenderResult & { props: Props } { 29 | return { 30 | ...render(), 31 | props, 32 | } 33 | } 34 | 35 | it('should render', () => { 36 | const { getByTestId } = setup() 37 | 38 | expect(getByTestId(TEST_IDS.root)).toBeInTheDocument() 39 | expect(getByTestId(TEST_IDS.headline)).toBeInTheDocument() 40 | expect(getByTestId(TEST_IDS.message)).toBeInTheDocument() 41 | }) 42 | 43 | it('should render a custom headline', () => { 44 | const { getByTestId, queryByTestId, rerender } = setup({ 45 | headline:

My Custom Headline

, 46 | }) 47 | 48 | expect(getByTestId(testId)).toBeInTheDocument() 49 | 50 | rerender() 51 | 52 | expect(queryByTestId(TEST_IDS.headline)).not.toBeInTheDocument() 53 | expect(queryByTestId(testId)).not.toBeInTheDocument() 54 | }) 55 | 56 | it('should render a custom message', () => { 57 | const { getByTestId, queryByTestId, rerender } = setup({ 58 | message:

My custom message

, 59 | }) 60 | 61 | expect(getByTestId(testId)).toBeInTheDocument() 62 | 63 | rerender() 64 | 65 | expect(queryByTestId(TEST_IDS.message)).not.toBeInTheDocument() 66 | expect(queryByTestId(testId)).not.toBeInTheDocument() 67 | }) 68 | 69 | it('should emit event onClick of button', () => { 70 | const { getByTestId } = setup() 71 | 72 | fireEvent.click(getByTestId(TEST_IDS.button)) 73 | 74 | // eslint-disable-next-line @typescript-eslint/unbound-method 75 | expect(addons.getChannel().emit).toHaveBeenCalledWith( 76 | EVENT_REQUESTED_ADDON, 77 | ) 78 | }) 79 | }) 80 | -------------------------------------------------------------------------------- /src/examples/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Card as CardBase, 3 | CardContent, 4 | CardHeader, 5 | CardMedia, 6 | Typography, 7 | } from '@material-ui/core' 8 | import { styled } from '@storybook/theming' 9 | import React, { ReactElement, ReactNode } from 'react' 10 | 11 | const StyledCard = styled(CardBase)` 12 | width: 240px; 13 | display: inline-block; 14 | margin: 10px; 15 | ` 16 | 17 | const StyledMedia = styled(CardMedia)` 18 | height: 0; 19 | padding-top: 56.25%; 20 | ` 21 | 22 | export interface CardProps { 23 | title: string 24 | subhead?: string 25 | image?: string 26 | children: ReactNode 27 | } 28 | 29 | export const Card = ({ 30 | title, 31 | subhead, 32 | image, 33 | children, 34 | }: CardProps): ReactElement => ( 35 | 36 | 37 | {image && } 38 | {children} 39 | 40 | ) 41 | 42 | export interface ArtworkProps { 43 | title: string 44 | imageUrl: string 45 | artist: { 46 | name: string 47 | location: string 48 | } 49 | } 50 | 51 | export const Artwork = ({ 52 | title, 53 | imageUrl, 54 | artist, 55 | }: ArtworkProps): ReactElement => ( 56 | 57 | {artist.name} 58 | {artist.location} 59 | 60 | ) 61 | 62 | export interface ShowProps { 63 | name: string 64 | description: string 65 | cover_image: { 66 | image_versions: string[] 67 | image_url: string 68 | } 69 | } 70 | 71 | export const Show = ({ 72 | name, 73 | description, 74 | cover_image: { image_versions, image_url }, 75 | }: ShowProps): ReactElement => ( 76 | 77 | {description} 78 | 79 | ) 80 | 81 | export interface UserProps { 82 | id: number 83 | name: string 84 | email: string 85 | website: string 86 | company: { 87 | name: string 88 | } 89 | } 90 | 91 | export const User = ({ 92 | id, 93 | name, 94 | email, 95 | website, 96 | company, 97 | }: UserProps): ReactElement => ( 98 | 103 | {email} 104 | {website} 105 | 106 | ) 107 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Getting Started 4 | 5 | #### Install 6 | 7 | ```sh 8 | npm install 9 | ``` 10 | 11 | This will wipe any existing `node_modules` folder and dependency lock files and do a fresh install of all dependencies. 12 | 13 | #### Start 14 | 15 | ```sh 16 | npm run start 17 | ``` 18 | 19 | This first remove any existing generated folders and then will run [Rollup](https://github.com/rollup/rollup) in watch mode and then start the [Storybook](https://github.com/storybookjs/storybook) dev server. Changes to addon files will take a few seconds to be recompiled into the `dist` folder which may also kick off a webpack recompile for Storybook. You will need to refresh the browser after the console reads: 20 | 21 | ```sh 22 | created dist\register.js in [N]s 23 | 24 | [DateTime] waiting for changes... 25 | ``` 26 | 27 | #### Commit 28 | 29 | ```sh 30 | npm run commit 31 | ``` 32 | 33 | This will: 34 | 35 | - Lint the entire project 36 | - Run [Git's Interactive Staging](https://git-scm.com/book/en/v2/Git-Tools-Interactive-Staging) to select the files to commit 37 | - Run [Prettier](https://github.com/prettier/prettier) on all staged files 38 | - Run [Git CZ](https://github.com/streamich/git-cz) for semantic commit messages 39 | - Push the commit to your current branch 40 | 41 | It is important to use this method of committing to maintain code cleanliness, project history, and to release version updates correctly. There is also a mechanism in place to validate commit messages, so it's possible that you'll run into issues trying to commit using a different method. 42 | 43 | #### Upgrade 44 | 45 | ```sh 46 | npm run upgrade 47 | ``` 48 | 49 | This will run [NPM Check](https://github.com/dylang/npm-check) in interactive mode. Dependencies and Dev-dependencies will be upgraded and saved with the expected exactness. Be careful to test functionality after running this command. 50 | 51 | Dev-dependencies should be installed with an exact version. 52 | 53 | Dependencies should be installed with a `^`. 54 | 55 | #### Resources 56 | 57 | Because documentation is not extensive, having to read through Storybook addons and libs source code is necessary: 58 | 59 | - [https://storybook.js.org/docs/addons/writing-addons/](https://storybook.js.org/docs/addons/writing-addons/) 60 | - [https://storybook.js.org/docs/addons/api/](https://storybook.js.org/docs/addons/api/) 61 | - [https://github.com/storybookjs/storybook/tree/next/addons](https://github.com/storybookjs/storybook/tree/next/addons) 62 | - [https://github.com/storybookjs/storybook/tree/next/lib/addons](https://github.com/storybookjs/storybook/tree/next/lib/addons) 63 | - [https://github.com/storybookjs/storybook/tree/next/lib/api](https://github.com/storybookjs/storybook/tree/next/lib/api) 64 | - [https://github.com/storybookjs/storybook/tree/next/lib/components](https://github.com/storybookjs/storybook/tree/next/lib/components) 65 | 66 | It should also be noted that not all available tools will work everywhere. For example, React hooks from the `@storybook/addons` library will not function on the decorator, but should be used on the panel. It's not always clear when and where things will work so it requires a certain amount of trial and error. Please add to these notes on discovered quirks or undocumented restrictions/features. 67 | -------------------------------------------------------------------------------- /src/components/Message/test.tsx: -------------------------------------------------------------------------------- 1 | import { convert, ThemeProvider, themes } from '@storybook/theming' 2 | import { 3 | cleanup, 4 | fireEvent, 5 | render, 6 | RenderResult, 7 | } from '@testing-library/react' 8 | import '@testing-library/jest-dom/extend-expect' 9 | import React from 'react' 10 | 11 | import { Message, Props, TEST_IDS } from '.' 12 | 13 | describe('Message', () => { 14 | afterEach(cleanup) 15 | 16 | function setup({ 17 | children = null, 18 | ...rest 19 | }: Partial = {}): RenderResult & { 20 | props: Props 21 | } { 22 | return { 23 | ...render( 24 | 25 | {children} 26 | , 27 | ), 28 | props: { 29 | children, 30 | ...rest, 31 | }, 32 | } 33 | } 34 | 35 | it('should render null when children is falsy', () => { 36 | const { queryByTestId } = setup() 37 | 38 | expect(queryByTestId(TEST_IDS.root)).not.toBeInTheDocument() 39 | }) 40 | 41 | it('should render message content', () => { 42 | const testId = 'CustomContent' 43 | const { getByTestId } = setup({ 44 | children: Foo, 45 | }) 46 | 47 | expect(getByTestId(TEST_IDS.root)).toBeInTheDocument() 48 | expect(getByTestId(TEST_IDS.content)).toBeInTheDocument() 49 | expect(getByTestId(testId)).toBeInTheDocument() 50 | }) 51 | 52 | it('should render toggle if collapsible', () => { 53 | const { getByTestId } = setup({ 54 | children: 'Foo', 55 | collapsible: true, 56 | }) 57 | 58 | expect(getByTestId(TEST_IDS.toggle)).toBeInTheDocument() 59 | expect(getByTestId(TEST_IDS.toggle)).toHaveAttribute( 60 | 'aria-expanded', 61 | 'false', 62 | ) 63 | }) 64 | 65 | it('should render expanded, if collapsed is false', () => { 66 | const { getByTestId } = setup({ 67 | children: 'Foo', 68 | collapsible: true, 69 | collapsed: false, 70 | }) 71 | 72 | expect(getByTestId(TEST_IDS.toggle)).toHaveAttribute( 73 | 'aria-expanded', 74 | 'true', 75 | ) 76 | }) 77 | 78 | it('should toggle collapsed state on click', () => { 79 | const { getByTestId } = setup({ 80 | children: 'Foo', 81 | collapsible: true, 82 | }) 83 | 84 | expect(getByTestId(TEST_IDS.toggle)).toHaveAttribute( 85 | 'aria-expanded', 86 | 'false', 87 | ) 88 | 89 | fireEvent.click(getByTestId(TEST_IDS.toggle)) 90 | 91 | expect(getByTestId(TEST_IDS.toggle)).toHaveAttribute( 92 | 'aria-expanded', 93 | 'true', 94 | ) 95 | 96 | fireEvent.click(getByTestId(TEST_IDS.toggle)) 97 | 98 | expect(getByTestId(TEST_IDS.toggle)).toHaveAttribute( 99 | 'aria-expanded', 100 | 'false', 101 | ) 102 | 103 | // TODO test that the icon is correct 104 | }) 105 | }) 106 | -------------------------------------------------------------------------------- /src/components/Variable/Date.tsx: -------------------------------------------------------------------------------- 1 | import { Form } from '@storybook/components' 2 | import { formatISO, parseISO } from 'date-fns' 3 | import React, { ChangeEvent, memo } from 'react' 4 | 5 | import type { DateTimeSchema } from '../../types' 6 | import { Error, Row } from './styled' 7 | 8 | export interface Props { 9 | schema: DateTimeSchema 10 | value: string | undefined 11 | error: string | null 12 | isValid: boolean 13 | onChange: (value: string) => void 14 | } 15 | 16 | export enum DateTimeType { 17 | Date = 'date', 18 | DateTime = 'datetime-local', 19 | Time = 'time', 20 | } 21 | 22 | export function parseDateTime( 23 | val: string, 24 | type: DateTimeType, 25 | isUTC: boolean, 26 | ): Date { 27 | if (type === DateTimeType.Time) { 28 | const [hours, minutes] = val.split(':') 29 | const date = new Date() 30 | 31 | date[isUTC ? 'setUTCHours' : 'setHours']( 32 | parseInt(hours, 10), 33 | parseInt(minutes, 10), 34 | 0, 35 | 0, 36 | ) 37 | 38 | return date 39 | } 40 | 41 | return parseISO(val) 42 | } 43 | 44 | export function toInputFormat( 45 | val: string | undefined, 46 | type: DateTimeType, 47 | ): string { 48 | if (!val) { 49 | return '' 50 | } 51 | 52 | const date = parseDateTime(val, type, true) 53 | const representation = 54 | type === DateTimeType.DateTime 55 | ? 'complete' 56 | : type === DateTimeType.Date 57 | ? 'date' 58 | : 'time' 59 | 60 | return formatISO(date, { representation }).replace(/-\d{2}:\d{2}.*/, '') 61 | } 62 | 63 | export function toISOFormat(val: string, type: DateTimeType): string { 64 | const date = parseDateTime(val, type, false) 65 | const iso = date.toISOString() 66 | 67 | if (type === DateTimeType.DateTime) { 68 | return iso 69 | } 70 | 71 | const match = /(\d{4}(?:-\d{2}){2})T(\d{2}(?::\d{2}){2})/.exec(iso) 72 | const [, day, time] = match 73 | 74 | return type === DateTimeType.Date ? day : time 75 | } 76 | 77 | export const TEST_IDS = Object.freeze({ 78 | root: 'DateVariableRoot', 79 | input: 'DateVariableInput', 80 | error: 'DateVariableError', 81 | }) 82 | 83 | export const DateTimeInput = memo( 84 | ({ schema, value, error, isValid, onChange }: Props) => { 85 | const includeDate = schema.format.startsWith('date') 86 | const includeTime = schema.format.endsWith('time') 87 | const type = 88 | includeDate && includeTime 89 | ? DateTimeType.DateTime 90 | : includeDate 91 | ? DateTimeType.Date 92 | : DateTimeType.Time 93 | 94 | function update(event: ChangeEvent): void { 95 | onChange(toISOFormat(event.target.value, type)) 96 | } 97 | 98 | return ( 99 | 100 | 107 | {!isValid && error && ( 108 | {error} 109 | )} 110 | 111 | ) 112 | }, 113 | ) 114 | 115 | DateTimeInput.displayName = 'DateTime' 116 | -------------------------------------------------------------------------------- /src/decorator.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | addons, 3 | DecoratorFunction, 4 | makeDecorator, 5 | OptionsParameter, 6 | StoryContext, 7 | LegacyStoryFn, 8 | WrapperSettings, 9 | } from '@storybook/addons' 10 | import { Channel } from '@storybook/channels' 11 | import React, { memo, ReactElement, useEffect, useState } from 'react' 12 | 13 | import { 14 | DECORATOR_NAME, 15 | EVENT_DATA_UPDATED, 16 | EVENT_INITIALIZED, 17 | PARAM_KEY, 18 | } from './config' 19 | import { 20 | FetchStatus, 21 | HeadlessOptions, 22 | HeadlessParameters, 23 | HeadlessStoryContext, 24 | InitializeMessage, 25 | UpdateMessage, 26 | } from './types' 27 | 28 | interface Props { 29 | channel: Channel 30 | context: StoryContext 31 | options: HeadlessOptions & OptionsParameter 32 | parameters: HeadlessParameters 33 | storyFn: LegacyStoryFn 34 | } 35 | 36 | export function createFilteredRecord( 37 | keys: string[], 38 | values: Record, 39 | defaultValue: T, 40 | ): Record { 41 | return keys.reduce( 42 | (acc, key) => ({ ...acc, [key]: values[key] ?? defaultValue }), 43 | {}, 44 | ) 45 | } 46 | 47 | export const Decorator = memo( 48 | ({ channel, context, parameters, storyFn }: Props): ReactElement => { 49 | const keys = Object.keys(parameters) 50 | const [state, setState] = useState({ 51 | status: createFilteredRecord(keys, {}, FetchStatus.Inactive), 52 | data: createFilteredRecord(keys, {}, null), 53 | errors: createFilteredRecord(keys, {}, null), 54 | }) 55 | const [connected, setConnected] = useState(false) 56 | const ctx: HeadlessStoryContext = { 57 | ...context, 58 | status: createFilteredRecord( 59 | keys, 60 | state.status, 61 | FetchStatus.Inactive, 62 | ), 63 | data: createFilteredRecord(keys, state.data, null), 64 | errors: createFilteredRecord(keys, state.errors, null), 65 | } 66 | 67 | function update(message: UpdateMessage): void { 68 | setState(message) 69 | setConnected(true) 70 | } 71 | 72 | useEffect(() => { 73 | channel.on(EVENT_DATA_UPDATED, update) 74 | 75 | return () => channel.off(EVENT_DATA_UPDATED, update) 76 | }) 77 | 78 | return connected ? (storyFn(ctx) as ReactElement) : null 79 | }, 80 | ) 81 | 82 | Decorator.displayName = 'Decorator' 83 | 84 | export function Wrapper( 85 | storyFn: LegacyStoryFn, 86 | context: StoryContext, 87 | { options, parameters = {} }: WrapperSettings, 88 | ): ReactElement { 89 | const channel = addons.getChannel() 90 | const message: InitializeMessage = { 91 | storyId: context.id, 92 | options: options as HeadlessOptions & OptionsParameter, 93 | } 94 | 95 | channel.emit(EVENT_INITIALIZED, message) 96 | 97 | return ( 98 | 105 | ) 106 | } 107 | 108 | export const withHeadless: ( 109 | options: HeadlessOptions, 110 | ) => DecoratorFunction> = makeDecorator({ 111 | name: DECORATOR_NAME, 112 | parameterName: PARAM_KEY, 113 | skipIfNoParametersOrOptions: true, 114 | wrapper: Wrapper, 115 | }) 116 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting Ray Knight (@ArrayKnight) [array.knight+headless@gmail.com](array.knight+headless@gmail.com). All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /src/examples/graphql.stories.tsx: -------------------------------------------------------------------------------- 1 | import { gql } from '@apollo/client' 2 | import { Args } from '@storybook/addons' 3 | import React, { ReactElement } from 'react' 4 | 5 | import { 6 | FetchStatus, 7 | HeadlessStoryContext, 8 | Loader, 9 | pack, 10 | Prompt, 11 | withHeadless, 12 | } from '../../dist' 13 | 14 | import { 15 | Artwork as ArtworkCard, 16 | ArtworkProps, 17 | Show as ShowCard, 18 | ShowProps, 19 | } from '.' 20 | 21 | export default { 22 | title: 'Examples/GraphQL', 23 | decorators: [ 24 | withHeadless({ 25 | graphql: { 26 | uri: 'https://metaphysics-production.artsy.net/', 27 | }, 28 | }), 29 | ], 30 | parameters: { 31 | headless: { 32 | Artworks: { 33 | query: pack(gql` 34 | { 35 | artworks { 36 | artist { 37 | name 38 | location 39 | } 40 | imageUrl 41 | title 42 | } 43 | } 44 | `), 45 | autoFetchOnInit: true, 46 | }, 47 | Shows: { 48 | query: pack(gql` 49 | query Shows( 50 | $At_a_Fair: Boolean 51 | $Featured: Boolean 52 | $Size: Int 53 | ) { 54 | partner_shows( 55 | at_a_fair: $At_a_Fair 56 | featured: $Featured 57 | size: $Size 58 | ) { 59 | id 60 | name 61 | description 62 | cover_image { 63 | id 64 | image_versions 65 | image_url 66 | } 67 | } 68 | } 69 | `), 70 | variables: { 71 | At_a_Fair: { 72 | type: 'boolean', 73 | }, 74 | Featured: { 75 | type: 'boolean', 76 | }, 77 | Size: { 78 | type: 'integer', 79 | minimum: 1, 80 | }, 81 | }, 82 | defaults: { 83 | Size: 10, 84 | }, 85 | }, 86 | }, 87 | }, 88 | } 89 | 90 | export const Artworks = ( 91 | args: Args, 92 | { 93 | status, 94 | data, 95 | }: HeadlessStoryContext<{ Artworks?: { artworks?: ArtworkProps[] } }>, 96 | ): ReactElement | null => { 97 | switch (status?.Artworks) { 98 | case FetchStatus.Inactive: 99 | case FetchStatus.Rejected: 100 | return 101 | 102 | case FetchStatus.Loading: 103 | return 104 | 105 | default: 106 | return Array.isArray(data?.Artworks?.artworks) ? ( 107 | <> 108 | {data.Artworks.artworks.map((artwork, index) => ( 109 | 110 | ))} 111 | 112 | ) : null 113 | } 114 | } 115 | 116 | export const Shows = ( 117 | args: Args, 118 | { 119 | status, 120 | data, 121 | }: HeadlessStoryContext<{ 122 | Shows?: { 123 | partner_shows?: ShowProps[] 124 | } 125 | }>, 126 | ): ReactElement | null => { 127 | switch (status?.Shows) { 128 | case FetchStatus.Inactive: 129 | case FetchStatus.Rejected: 130 | return 131 | 132 | case FetchStatus.Loading: 133 | return 134 | 135 | default: 136 | return Array.isArray(data?.Shows?.partner_shows) ? ( 137 | <> 138 | {data.Shows.partner_shows.map((show, index) => ( 139 | 140 | ))} 141 | 142 | ) : null 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/components/Select/styled.ts: -------------------------------------------------------------------------------- 1 | import { css, styled } from '@storybook/theming' 2 | 3 | export const Root = styled.div( 4 | ({ theme }) => css` 5 | display: inline-flex; 6 | position: relative; 7 | font-size: ${theme.typography.size.s2 - 1}px; 8 | 9 | &[disabled] { 10 | opacity: 0.5; 11 | cursor: not-allowed; 12 | } 13 | `, 14 | ) 15 | 16 | export const Container = styled.div( 17 | ({ theme }) => css` 18 | box-shadow: ${theme.input.border} 0 0 0 1px inset; 19 | border-radius: ${theme.input.borderRadius}px; 20 | display: flex; 21 | flex: 1 1 100%; 22 | 23 | input { 24 | width: 143px; 25 | min-height: 0; 26 | display: block; 27 | flex: 1 1 100%; 28 | background: none; 29 | 30 | &, 31 | &:focus { 32 | border: 0; 33 | box-shadow: none; 34 | } 35 | } 36 | 37 | &:focus, 38 | &:focus-within { 39 | box-shadow: ${theme.color.secondary} 0 0 0 1px inset; 40 | } 41 | 42 | ${theme.isOpen && 43 | css` 44 | border-bottom-right-radius: 0; 45 | border-bottom-left-radius: 0; 46 | `}; 47 | 48 | ${theme.isValid && 49 | css` 50 | boxshadow: ${theme.color.positive} 0 0 0 1px inset; 51 | `}; 52 | 53 | ${theme.isError && 54 | css` 55 | boxshadow: ${theme.color.negative} 0 0 0 1px inset; 56 | `}; 57 | 58 | ${theme.isWarn && 59 | css` 60 | boxshadow: ${theme.color.warning} 0 0 0 1px inset; 61 | `}; 62 | `, 63 | ) 64 | 65 | export const Chip = styled.div( 66 | ({ theme }) => css` 67 | border-radius: ${theme.input.borderRadius}px; 68 | margin: 2px; 69 | background: ${theme.background.app}; 70 | display: inline-flex; 71 | align-items: center; 72 | flex: 0 0 auto; 73 | order: -1; 74 | 75 | span { 76 | max-width: 75px; 77 | padding: 0.21em 0.5em; 78 | display: block; 79 | white-space: nowrap; 80 | text-overflow: ellipsis; 81 | overflow: hidden; 82 | } 83 | `, 84 | ) 85 | 86 | export const Remove = styled.button( 87 | ({ theme }) => css` 88 | width: 24px; 89 | padding: 5px; 90 | border: 0; 91 | border-left: 1px solid ${theme.input.border}; 92 | background: none; 93 | appearance: none; 94 | cursor: pointer; 95 | opacity: 0.5; 96 | transition: opacity 250ms; 97 | 98 | &:hover, 99 | &:focus { 100 | background: ${theme.background.warning}; 101 | opacity: 1; 102 | } 103 | `, 104 | ) 105 | 106 | export const Toggle = styled.button( 107 | ({ theme }) => css` 108 | width: 30px; 109 | height: 30px; 110 | padding: 5px; 111 | border: 0; 112 | border-left: 1px solid ${theme.input.border}; 113 | background: none; 114 | flex: 0 0 auto; 115 | appearance: none; 116 | cursor: pointer; 117 | 118 | &:hover { 119 | background: ${theme.background.hoverable}; 120 | } 121 | `, 122 | ) 123 | 124 | export const Menu = styled.ul( 125 | ({ theme }) => css` 126 | padding: 0; 127 | border: 1px solid ${theme.input.border}; 128 | border-top: 0; 129 | border-radius: ${theme.input.borderRadius}px; 130 | border-top-right-radius: 0; 131 | border-top-left-radius: 0; 132 | margin: 0; 133 | list-style-type: none; 134 | display: ${theme.isOpen ? 'block' : 'none'}; 135 | background: ${theme.background.content}; 136 | position: absolute; 137 | top: 100%; 138 | right: 0; 139 | left: 0; 140 | z-index: 1; 141 | 142 | &:empty { 143 | &:after { 144 | content: 'No items available'; 145 | padding: 1em; 146 | display: block; 147 | font-style: italic; 148 | text-align: center; 149 | } 150 | } 151 | `, 152 | ) 153 | 154 | export const MenuItem = styled.li( 155 | ({ theme }) => css` 156 | padding: 0.42em 1em; 157 | 158 | ${theme.isHighlighted && 159 | css` 160 | background: ${theme.background.hoverable}; 161 | `}; 162 | 163 | ${theme.isSelected && 164 | css` 165 | background: ${theme.background.positive}; 166 | `}; 167 | `, 168 | ) 169 | -------------------------------------------------------------------------------- /src/components/Panel/Tab.tsx: -------------------------------------------------------------------------------- 1 | import { Theme, useTheme } from '@storybook/theming' 2 | import React, { ComponentType, memo, useEffect, useMemo, useState } from 'react' 3 | import type { InteractionProps, ReactJsonViewProps } from 'react-json-view' 4 | 5 | import type { 6 | ApiParameters, 7 | HeadlessParameter, 8 | HeadlessState, 9 | ObjectLike, 10 | } from '../../types' 11 | import { 12 | getGraphQLUri, 13 | getRestfulUrl, 14 | isDocumentNode, 15 | isGraphQLParameters, 16 | isString, 17 | } from '../../utilities' 18 | import { BrowserOnly } from '../BrowserOnly' 19 | import { Message } from '../Message' 20 | import { Variables } from '../Variables' 21 | import { Separator, TabContent } from './styled' 22 | 23 | interface Props { 24 | name: string 25 | data: unknown 26 | error?: Record 27 | options: HeadlessState['options'] 28 | parameter: HeadlessParameter 29 | onFetch: ( 30 | name: string, 31 | apiParameters: ApiParameters, 32 | variables: Record, 33 | ) => Promise 34 | onUpdate: (name: string, props: InteractionProps) => void 35 | } 36 | 37 | export const Tab = memo( 38 | ({ 39 | name, 40 | data, 41 | error, 42 | options: { graphql, restful, jsonDark, jsonLight }, 43 | parameter, 44 | onFetch, 45 | onUpdate, 46 | }: Props) => { 47 | const theme = useTheme() 48 | const parameters = useMemo( 49 | () => 50 | isString(parameter) || isDocumentNode(parameter) 51 | ? ({ query: parameter } as ApiParameters) 52 | : parameter, 53 | [parameter], 54 | ) 55 | const [Json, setJson] = 56 | useState | null>(null) 57 | const hasData = !!data 58 | const hasError = !!error 59 | 60 | async function fetch( 61 | variables: Record, 62 | ): Promise { 63 | await onFetch(name, parameters, variables) 64 | } 65 | 66 | function update(props: InteractionProps): void { 67 | onUpdate(name, props) 68 | } 69 | 70 | useEffect(() => { 71 | void (async () => { 72 | const lib = await import('react-json-view') 73 | 74 | setJson(() => lib.default) 75 | })() 76 | }, []) 77 | 78 | return ( 79 | 80 | 81 | {isGraphQLParameters(parameters) 82 | ? getGraphQLUri(graphql, parameters) 83 | : getRestfulUrl(restful, parameters, {})} 84 | 85 | 91 | {(hasData || hasError) && ( 92 | <> 93 | 94 | 95 | {() => 96 | !!Json && ( 97 | 114 | ) 115 | } 116 | 117 | 118 | )} 119 | 120 | ) 121 | }, 122 | ) 123 | 124 | Tab.displayName = 'Tab' 125 | -------------------------------------------------------------------------------- /src/components/Variable/__tests__/Date.tsx: -------------------------------------------------------------------------------- 1 | import { describe } from '@jest/globals' 2 | import { convert, ThemeProvider, themes } from '@storybook/theming' 3 | import { 4 | cleanup, 5 | fireEvent, 6 | getNodeText, 7 | render, 8 | RenderResult, 9 | } from '@testing-library/react' 10 | import '@testing-library/jest-dom/extend-expect' 11 | import React from 'react' 12 | 13 | import { 14 | DateTimeInput, 15 | DateTimeType, 16 | parseDateTime, 17 | Props, 18 | TEST_IDS, 19 | toInputFormat, 20 | toISOFormat, 21 | } from '../Date' 22 | 23 | const iso = '2020-10-19T20:30:00.000Z' 24 | const date = new Date(iso) 25 | 26 | describe.skip('parseDateTime', () => { 27 | it('should return a date', () => { 28 | const now = new Date() 29 | 30 | expect(parseDateTime(iso, DateTimeType.DateTime, true)).toEqual(date) 31 | expect(parseDateTime(iso, DateTimeType.Date, true)).toEqual(date) 32 | expect(parseDateTime('20:30:00', DateTimeType.Time, true)).toEqual( 33 | new Date( 34 | now.getFullYear(), 35 | now.getMonth(), 36 | now.getDate(), 37 | 20 - now.getTimezoneOffset() / 60, 38 | 30, 39 | 0, 40 | 0, 41 | ), 42 | ) 43 | expect(parseDateTime('20:30:00', DateTimeType.Time, false)).toEqual( 44 | new Date( 45 | now.getFullYear(), 46 | now.getMonth(), 47 | now.getDate(), 48 | 20, 49 | 30, 50 | 0, 51 | 0, 52 | ), 53 | ) 54 | }) 55 | }) 56 | 57 | describe.skip('toInputFormat', () => { 58 | it('should return an empty string', () => { 59 | expect(toInputFormat(undefined, DateTimeType.DateTime)).toEqual('') 60 | expect(toInputFormat(undefined, DateTimeType.Date)).toEqual('') 61 | expect(toInputFormat(undefined, DateTimeType.Time)).toEqual('') 62 | }) 63 | 64 | it('should return a local-time date-time string', () => { 65 | expect(toInputFormat(iso, DateTimeType.DateTime)).toEqual( 66 | '2020-10-19T13:30:00', 67 | ) 68 | }) 69 | 70 | it('should return a date string', () => { 71 | expect(toInputFormat(iso, DateTimeType.Date)).toEqual('2020-10-19') 72 | }) 73 | 74 | it('should return a time string', () => { 75 | expect(toInputFormat(iso, DateTimeType.Time)).toEqual('20:30:00') 76 | }) 77 | }) 78 | 79 | describe.skip('toISOFormat', () => { 80 | it('should return a date-time string', () => { 81 | expect( 82 | toISOFormat('2020-10-19T13:30:00', DateTimeType.DateTime), 83 | ).toEqual(iso) 84 | }) 85 | 86 | it('should return a date string', () => { 87 | expect(toISOFormat('2020-10-19', DateTimeType.Date)).toEqual( 88 | '2020-10-19', 89 | ) 90 | }) 91 | 92 | it('should return a time string', () => { 93 | expect(toISOFormat('13:30:00', DateTimeType.Time)).toEqual('20:30:00') 94 | }) 95 | }) 96 | 97 | describe('DateTime', () => { 98 | afterEach(cleanup) 99 | 100 | function setup({ 101 | schema = { type: 'string', format: 'date-time' }, 102 | value, 103 | error = null, 104 | isValid = true, 105 | onChange = jest.fn(), 106 | }: Partial = {}): RenderResult & { 107 | props: Props 108 | } { 109 | const props: Props = { 110 | schema, 111 | value, 112 | error, 113 | isValid, 114 | onChange, 115 | } 116 | 117 | return { 118 | ...render( 119 | 120 | 121 | , 122 | ), 123 | props, 124 | } 125 | } 126 | 127 | it('should render', () => { 128 | const { getByTestId } = setup() 129 | 130 | expect(getByTestId(TEST_IDS.root)).toBeInTheDocument() 131 | }) 132 | 133 | it('should render error when invalid', () => { 134 | const error = 'Error message' 135 | const { getByTestId } = setup({ 136 | isValid: false, 137 | error, 138 | }) 139 | 140 | expect(getByTestId(TEST_IDS.error)).toBeInTheDocument() 141 | expect(getNodeText(getByTestId(TEST_IDS.error))).toEqual(error) 142 | }) 143 | 144 | it.skip('should be a controlled input', () => { 145 | const { getByTestId, props, rerender } = setup() 146 | const input = getByTestId(TEST_IDS.input) as HTMLInputElement 147 | 148 | expect(input.value).toEqual('') 149 | 150 | fireEvent.change(input, { 151 | target: { value: toInputFormat(iso, DateTimeType.DateTime) }, 152 | }) 153 | 154 | expect(input.value).toEqual('') 155 | expect(props.onChange).toHaveBeenCalledWith(iso) 156 | 157 | rerender( 158 | 159 | 160 | , 161 | ) 162 | 163 | expect(toISOFormat(input.value, DateTimeType.DateTime)).toEqual( 164 | toISOFormat(iso, DateTimeType.DateTime), 165 | ) 166 | expect(props.onChange).toHaveBeenCalledTimes(1) 167 | }) 168 | }) 169 | -------------------------------------------------------------------------------- /src/components/Select/index.tsx: -------------------------------------------------------------------------------- 1 | import { Form, Icons } from '@storybook/components' 2 | import { InputStyleProps } from '@storybook/components/dist/ts3.9/form/input/input' 3 | import { ThemeProvider } from '@storybook/theming' 4 | import { useCombobox } from 'downshift' 5 | import React, { memo, useState } from 'react' 6 | 7 | import type { Item } from '../../types' 8 | import { isArray, isUndefined } from '../../utilities' 9 | import { Chip, Container, Menu, MenuItem, Remove, Root, Toggle } from './styled' 10 | 11 | export type Props = { items: Item[] } & Pick & 12 | ( 13 | | { 14 | selected: Item | undefined 15 | isMulti?: false 16 | onChange: (item: Item | null) => void 17 | } 18 | | { 19 | selected: Item[] | undefined 20 | isMulti: true 21 | onChange: (items: Item[]) => void 22 | } 23 | ) 24 | 25 | export const TEST_IDS = Object.freeze({ 26 | root: 'SelectRoot', 27 | input: 'SelectInput', 28 | chip: 'SelectChip', 29 | remove: 'SelectRemove', 30 | toggle: 'SelectToggle', 31 | menu: 'SelectMenu', 32 | item: 'SelectItem', 33 | }) 34 | 35 | export const Select = memo((props: Props) => { 36 | const { items, selected, isMulti, valid } = props 37 | const selectedItems = isUndefined(selected) 38 | ? [] 39 | : isArray(selected) 40 | ? selected 41 | : [selected] 42 | const [filteredItems, setFilteredItems] = useState(items) 43 | const { 44 | getComboboxProps, 45 | getInputProps, 46 | getItemProps, 47 | getMenuProps, 48 | getToggleButtonProps, 49 | highlightedIndex, 50 | isOpen, 51 | reset, 52 | } = useCombobox({ 53 | items: filteredItems, 54 | onInputValueChange: ({ inputValue }) => { 55 | setFilteredItems( 56 | items.filter(({ label }) => 57 | label.toLowerCase().includes(inputValue.toLowerCase()), 58 | ), 59 | ) 60 | }, 61 | onSelectedItemChange: ({ selectedItem }) => { 62 | if (selectedItem) { 63 | update( 64 | isMulti 65 | ? Array.from(new Set([...selectedItems, selectedItem])) 66 | : [selectedItem], 67 | ) 68 | } 69 | }, 70 | onIsOpenChange: (state) => { 71 | if (!state.isOpen) { 72 | reset() 73 | } 74 | }, 75 | }) 76 | 77 | function update(updated: Item[]): void { 78 | switch (props.isMulti) { 79 | case true: 80 | return props.onChange(updated) 81 | 82 | case false: 83 | case undefined: 84 | return props.onChange(updated[0] || null) 85 | } 86 | } 87 | 88 | function remove(item: Item): () => void { 89 | return () => 90 | update(selectedItems.filter(({ value }) => value !== item.value)) 91 | } 92 | 93 | return ( 94 | 102 | 103 | 104 | 108 | {selectedItems.map((item, index) => ( 109 | 113 | {item.label} 114 | 118 | 119 | 120 | 121 | ))} 122 | 127 | 128 | 129 | 130 | 131 | {filteredItems.map((item, index) => ( 132 | value === item.value, 138 | ), 139 | }} 140 | > 141 | 145 | {item.label} 146 | 147 | 148 | ))} 149 | 150 | 151 | 152 | ) 153 | }) 154 | 155 | Select.displayName = 'Select' 156 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "storybook-addon-headless", 3 | "author": "Ray Knight ", 4 | "description": "Storybook addon to preview content from a headless CMS (or any GraphQL/REST API) in components", 5 | "version": "0.0.0-development", 6 | "license": "MIT", 7 | "scripts": { 8 | "prebuild": "npm run clean", 9 | "build": "npm-run-all build:*", 10 | "build:dist": "npm run rollup", 11 | "build:docs": "npm run storybook:build", 12 | "clean": "rm -rf dist && rm -rf docs", 13 | "commit": "npm run lint && git add -i && npx git-cz", 14 | "lint": "eslint \"src/**/*.[j|t]s?(x)\"", 15 | "rollup": "rollup -c", 16 | "rollup:watch": "rollup -c -w", 17 | "semantic-release": "semantic-release", 18 | "prestart": "npm run clean", 19 | "start": "npm-run-all -p -r rollup:watch storybook:start:dist", 20 | "start:dev": "npm run storybook:start:dev", 21 | "storybook:start:dev": "start-storybook -c .storybook-dev", 22 | "storybook:start:dist": "wait-on dist/register.js && start-storybook -c .storybook-dist", 23 | "storybook:build": "wait-on dist/register.js && build-storybook -c .storybook-dist -o docs", 24 | "test": "jest", 25 | "test:coverage": "jest --verbose --coverage", 26 | "update": "npm-check --update" 27 | }, 28 | "husky": { 29 | "hooks": { 30 | "pre-commit": "lint-staged", 31 | "commit-msg": "commitlint --edit $1", 32 | "post-commit": "git push -u origin $(git rev-parse --abbrev-ref HEAD)" 33 | } 34 | }, 35 | "lint-staged": { 36 | "**/*.{ts,tsx,json,md}": [ 37 | "prettier --write" 38 | ] 39 | }, 40 | "commitlint": { 41 | "extends": [ 42 | "@commitlint/config-conventional" 43 | ] 44 | }, 45 | "devDependencies": { 46 | "@commitlint/cli": "^16.1.0", 47 | "@commitlint/config-conventional": "^16.0.0", 48 | "@material-ui/core": "^4.12.3", 49 | "@storybook/addon-actions": "^6.4.18", 50 | "@storybook/addon-docs": "^6.4.18", 51 | "@storybook/addon-knobs": "^6.4.0", 52 | "@storybook/react": "^6.4.18", 53 | "@testing-library/jest-dom": "^5.16.2", 54 | "@testing-library/react": "^12.1.2", 55 | "@types/ajv-keywords": "^3.5.0", 56 | "@types/jest": "^27.4.0", 57 | "@types/jest-expect-message": "^1.0.3", 58 | "@types/react": "^17.0.39", 59 | "@types/react-dom": "^17.0.11", 60 | "@typescript-eslint/eslint-plugin": "^5.10.2", 61 | "@typescript-eslint/parser": "^5.10.2", 62 | "babel-loader": "^8.2.3", 63 | "babel-preset-react-app": "^10.0.1", 64 | "eslint": "^8.8.0", 65 | "eslint-config-prettier": "^8.3.0", 66 | "eslint-config-react": "^1.1.7", 67 | "eslint-plugin-jest-dom": "^4.0.1", 68 | "eslint-plugin-prettier": "^4.0.0", 69 | "eslint-plugin-react": "^7.28.0", 70 | "eslint-plugin-react-hooks": "^4.3.0", 71 | "eslint-plugin-testing-library": "^5.0.5", 72 | "husky": "^7.0.4", 73 | "jest": "^27.5.0", 74 | "jest-environment-jsdom": "^27.5.0", 75 | "jest-expect-message": "^1.0.2", 76 | "jest-mock-console": "^1.2.3", 77 | "lint-staged": "^12.3.3", 78 | "npm-check": "^5.9.2", 79 | "npm-run-all": "^4.1.5", 80 | "prettier": "^2.5.1", 81 | "react-is": "^17.0.2", 82 | "rollup": "^2.67.1", 83 | "rollup-plugin-typescript2": "^0.31.2", 84 | "semantic-release": "^19.0.2", 85 | "ts-jest": "^27.1.3", 86 | "typescript": "^4.5.5", 87 | "wait-on": "^6.0.0" 88 | }, 89 | "dependencies": { 90 | "@apollo/client": "^3.5.8", 91 | "@storybook/addons": "^6.4.18", 92 | "@storybook/api": "^6.4.18", 93 | "@storybook/components": "^6.4.18", 94 | "@storybook/core": "^6.4.18", 95 | "@storybook/core-events": "^6.4.18", 96 | "@storybook/theming": "^6.4.18", 97 | "ajv": "^8.10.0", 98 | "ajv-formats": "^2.1.1", 99 | "ajv-keywords": "^5.1.0", 100 | "axios": "^0.25.0", 101 | "change-case": "^4.1.2", 102 | "date-fns": "^2.28.0", 103 | "downshift": "^6.1.7", 104 | "graphql": "^16.3.0", 105 | "qs": "^6.10.3", 106 | "react": "^17.0.2", 107 | "react-dom": "^17.0.2", 108 | "react-json-view": "^1.21.3", 109 | "react-storage-hooks": "^4.0.1" 110 | }, 111 | "private": false, 112 | "repository": { 113 | "type": "git", 114 | "url": "https://github.com/ArrayKnight/storybook-addon-headless.git" 115 | }, 116 | "homepage": "https://storybook-addon-headless.netlify.com/", 117 | "bugs": { 118 | "url": "https://github.com/ArrayKnight/storybook-addon-headless/issues" 119 | }, 120 | "release": { 121 | "branch": "master" 122 | }, 123 | "files": [ 124 | "dist", 125 | "register.js", 126 | "README.md" 127 | ], 128 | "main": "dist/index.js", 129 | "types": "dist/index.d.ts", 130 | "keywords": [ 131 | "storybook", 132 | "storybookjs", 133 | "storybook-addon", 134 | "addon", 135 | "headless", 136 | "headless-cms", 137 | "rest", 138 | "graphql", 139 | "data-state" 140 | ], 141 | "engines": { 142 | "node": ">=16" 143 | }, 144 | "storybook": { 145 | "displayName": "Headless Storybook", 146 | "supportedFrameworks": [ 147 | "react" 148 | ], 149 | "icon": "https://image.flaticon.com/icons/png/512/603/603197.png?w=1800" 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/examples/parameters.stories.mdx: -------------------------------------------------------------------------------- 1 | import { DocsContainer, DocsPage, Meta } from '@storybook/addon-docs/blocks' 2 | 3 | 13 | 14 | # Parameters 15 | 16 | For each endpoint you need to establish add an entry to the parameters object. It's possible to do a mix of Restful and GraphQL queries as well as simple and advanced configs. 17 | 18 | Example: 19 | 20 | ```js 21 | import { gql } from '@apollo/client' 22 | import { pack } from 'storybook-addon-headless' 23 | 24 | { 25 | Foos: '/foos' // Simple Restful 26 | Foo: { // Advanced Restful 27 | query: '/foos/{id}', 28 | // Variables, etc 29 | }, 30 | Bars: pack(gql``), // Simple GraphQL 31 | Bar: { // Advanced GraphQL 32 | query: pack(gql``), 33 | // Variables, etc 34 | } 35 | } 36 | ``` 37 | 38 | ## Base 39 | 40 | If multiple base configs have been provided via options, use this property to select which base config to merge with. 41 | 42 | ## Config 43 | 44 | If a specific query needs to augment the base config, you can optionally pass a partial config to be merged with the base config that may have been supplied in the options. See options about what configs are accepted. 45 | 46 | ## Query [required] 47 | 48 | For Restful requests the query should be a string. Depending on the options passed, this might be an absolute or relative path. 49 | 50 | For GraphQL requests the query must be a `pack`ed GraphQL Tag DocumentNode. 51 | 52 | Example: 53 | 54 | ```js 55 | import { gql } from '@apollo/client' 56 | import { pack } from 'storybook-addon-headless' 57 | 58 | pack(gql` 59 | { 60 | entities { 61 | id 62 | } 63 | } 64 | `) 65 | ``` 66 | 67 | ## Variable Types 68 | 69 | If your query requires variables/parameters, pass an object of variable schemas where the key is the variable name and the value is the schema. In order to define a variable type, a matching [Ajv](https://ajv.js.org/#validation-keywords) schema. 70 | 71 | Example: 72 | 73 | ```js 74 | { 75 | variables: { 76 | foo: { 77 | type: 'integer', 78 | multipleOf: 3, 79 | }, 80 | }, 81 | } 82 | ``` 83 | 84 | ### Boolean 85 | 86 | ```js 87 | { 88 | type: 'boolean' 89 | } 90 | ``` 91 | 92 | This schema will render a checkbox input. 93 | 94 | ### Date 95 | 96 | ```js 97 | { 98 | type: 'string', 99 | format: 'date', 100 | // Optional additional rules 101 | } 102 | ``` 103 | 104 | This schema will render a date input. There are optional [keywords](https://ajv.js.org/keywords.html#formatmaximum--formatminimum-and-formatexclusivemaximum--formatexclusiveminimum-proposed) for additional validation. 105 | 106 | ### DateTime 107 | 108 | ```js 109 | { 110 | type: 'string', 111 | format: 'date-time', 112 | // Optional additional rules 113 | } 114 | ``` 115 | 116 | This schema will render a date time input. Time entered by the user is in local timezone, but the value is converted to UTC. There are optional [keywords](https://ajv.js.org/keywords.html#formatmaximum--formatminimum-and-formatexclusivemaximum--formatexclusiveminimum-proposed) for additional validation. 117 | 118 | ### Time 119 | 120 | ```js 121 | { 122 | type: 'string', 123 | format: 'time', 124 | // Optional additional rules 125 | } 126 | ``` 127 | 128 | This schema will render a time input. Time entered by the user is in local timezone, but the value is converted to UTC. There are optional [keywords](https://ajv.js.org/keywords.html#formatmaximum--formatminimum-and-formatexclusivemaximum--formatexclusiveminimum-proposed) for additional validation. 129 | 130 | ### Number 131 | 132 | ```js 133 | { 134 | type: 'number' | 'integer', 135 | // Optional additional rules 136 | } 137 | ``` 138 | 139 | This schema will render a number input. There are optional [keywords](https://ajv.js.org/keywords.html#keywords-for-numbers) for additional validation. 140 | 141 | ### Select 142 | 143 | ```js 144 | { 145 | type: any, // Type should match the type of the values in enum 146 | enum: any[], 147 | } 148 | ``` 149 | 150 | This schema will render a select dropdown. If values are not basic types or if you want to differentiate the label from the value, you can use an object with `label` and `value` keys. 151 | 152 | Example: 153 | 154 | ```js 155 | { 156 | type: ['integer', 'null'], 157 | enum: [ 158 | 42, 159 | { label: 'Seven', value: 7 }, 160 | { label: 'None of the above', value: null }, 161 | ] 162 | } 163 | ``` 164 | 165 | ### String 166 | 167 | ```js 168 | { 169 | type: 'string', 170 | // Optional additional rules 171 | } 172 | ``` 173 | 174 | This schema will render a text input. There are optional [keywords](https://ajv.js.org/keywords.html#keywords-for-strings) for additional validation. 175 | 176 | ## Default Values 177 | 178 | To provide default values for any/all of your variables, pass an object of values where the key is the variable name and the value is the default. 179 | 180 | Example: 181 | 182 | ```js 183 | { 184 | defaults: { 185 | foo: 3 186 | } 187 | } 188 | ``` 189 | 190 | ## Transforms 191 | 192 | To transform values before the query is sent, pass an object of values where the key is the variable name and the value is a function that accepts and returns a value. The output value will not be validated against the schema and can therefore be any value, it's up to you to pass something valid to the query. 193 | 194 | Example: 195 | 196 | ```js 197 | { 198 | transforms: { 199 | foo: (value) => value * 10000 200 | } 201 | } 202 | ``` 203 | 204 | ## Other Features 205 | 206 | ### autoFetchOnInit 207 | 208 | If you would like data to be fetched on story load, pass `autoFetchOnInit: true`. This also requires that the query variables (if present) have default values that are valid. 209 | 210 | ### convertToFormData 211 | 212 | For Restful queries, if you're setting up a POST request, it might be necessary to pass data through as Multipart Form-data. In that case, pass `convertToFormData: true`. 213 | -------------------------------------------------------------------------------- /src/components/Variable/stories.tsx: -------------------------------------------------------------------------------- 1 | import { action } from '@storybook/addon-actions' 2 | import { text, withKnobs } from '@storybook/addon-knobs' 3 | import React, { ReactElement, useState } from 'react' 4 | 5 | import { VariableType } from '../../types' 6 | import { Variable } from '.' 7 | 8 | export default { 9 | title: 'Variable', 10 | component: Variable, 11 | decorators: [withKnobs], 12 | } 13 | 14 | export const BooleanStory = (): ReactElement => { 15 | const name = text('name', 'Boolean') 16 | const error = text('error', '') || null 17 | const [value, setValue] = useState(false) 18 | 19 | function onChange(_: string, val: boolean): void { 20 | action('onChange')(val) 21 | 22 | setValue(val) 23 | } 24 | 25 | return ( 26 | 34 | ) 35 | } 36 | 37 | BooleanStory.storyName = 'Boolean' 38 | 39 | export const DateStory = (): ReactElement => { 40 | const name = text('name', 'Date') 41 | const error = text('error', '') || null 42 | const [value, setValue] = useState('') 43 | 44 | function onChange(_: string, val: string): void { 45 | action('onChange')(val) 46 | 47 | setValue(val) 48 | } 49 | 50 | return ( 51 | 59 | ) 60 | } 61 | 62 | DateStory.storyName = 'Date' 63 | 64 | export const DateTimeStory = (): ReactElement => { 65 | const name = text('name', 'DateTime') 66 | const error = text('error', '') || null 67 | const [value, setValue] = useState('') 68 | 69 | function onChange(_: string, val: string): void { 70 | action('onChange')(val) 71 | 72 | setValue(val) 73 | } 74 | 75 | return ( 76 | 84 | ) 85 | } 86 | 87 | DateTimeStory.storyName = 'DateTime' 88 | 89 | export const TimeStory = (): ReactElement => { 90 | const name = text('name', 'Time') 91 | const error = text('error', '') || null 92 | const [value, setValue] = useState('') 93 | 94 | function onChange(_: string, val: string): void { 95 | action('onChange')(val) 96 | 97 | setValue(val) 98 | } 99 | 100 | return ( 101 | 109 | ) 110 | } 111 | 112 | TimeStory.storyName = 'Time' 113 | 114 | export const FloatStory = (): ReactElement => { 115 | const name = text('name', 'Float') 116 | const error = text('error', '') || null 117 | const [value, setValue] = useState('') 118 | 119 | function onChange(_: string, val: number): void { 120 | action('onChange')(val) 121 | 122 | setValue(`${val}`) 123 | } 124 | 125 | return ( 126 | 134 | ) 135 | } 136 | 137 | FloatStory.storyName = 'Float' 138 | 139 | export const IntegerStory = (): ReactElement => { 140 | const name = text('name', 'Integer') 141 | const error = text('error', '') || null 142 | const [value, setValue] = useState('') 143 | 144 | function onChange(_: string, val: number): void { 145 | action('onChange')(val) 146 | 147 | setValue(`${val}`) 148 | } 149 | 150 | return ( 151 | 159 | ) 160 | } 161 | 162 | IntegerStory.storyName = 'Integer' 163 | 164 | export const SelectStory = (): ReactElement => { 165 | const name = text('name', 'Select') 166 | const error = text('error', '') || null 167 | const [value, setValue] = useState< 168 | boolean | null | number | string | undefined 169 | >(undefined) 170 | 171 | function onChange(_: string, val: boolean | null | number | string): void { 172 | action('onChange')(val) 173 | 174 | setValue(val) 175 | } 176 | 177 | return ( 178 | 203 | ) 204 | } 205 | 206 | SelectStory.storyName = 'Select' 207 | 208 | export const StringStory = (): ReactElement => { 209 | const name = text('name', 'String') 210 | const error = text('error', '') || null 211 | const [value, setValue] = useState('') 212 | 213 | function onChange(_: string, val: string): void { 214 | action('onChange')(val) 215 | 216 | setValue(val) 217 | } 218 | 219 | return ( 220 | 228 | ) 229 | } 230 | 231 | StringStory.storyName = 'String' 232 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Storybook Addon Headless 2 | 3 | Storybook Addon Headless allows you to preview data from a headless CMS inside stories in [Storybook](https://storybook.js.org/). It supports Restful and GraphQL APIs with the help of [Axios](https://github.com/axios/axios) and [Apollo Client](https://github.com/apollographql/apollo-client) respectively. And each query can handle variables which are validated using [Ajv](https://github.com/epoberezkin/ajv). 4 | 5 | ### Upgrading to v2 6 | 7 | _Dependencies **Storybook@6** and **Apollo@3** have been released!_ 8 | 9 | Be aware of the change to Storybook's story parameters, StoryContext (where `data` is accessed) is now the second parameter. 10 | 11 | ## Examples 12 | 13 | Check out examples and detailed documentation: 14 | 15 | - [https://storybook-addon-headless.netlify.com/?path=/story/examples](https://storybook-addon-headless.netlify.com/?path=/story/examples) 16 | - [https://github.com/ArrayKnight/storybook-addon-headless/tree/master/src/examples](https://github.com/ArrayKnight/storybook-addon-headless/tree/master/src/examples) 17 | - [https://medium.com/arrayknight/how-to-get-real-data-into-storybook-8915f5371b6](https://medium.com/arrayknight/how-to-get-real-data-into-storybook-8915f5371b6) 18 | 19 | | Headless | Story | 20 | | :--------------------------------------------------------------------------------------------------------: | :-----------------------------------------------------------------------------------------------------: | 21 | | ![](https://raw.githubusercontent.com/ArrayKnight/storybook-addon-headless/master/src/assets/headless.png) | ![](https://raw.githubusercontent.com/ArrayKnight/storybook-addon-headless/master/src/assets/story.png) | 22 | 23 | ## Getting Started 24 | 25 | #### Install 26 | 27 | First of all, you need to install Headless into your project as a dev dependency. 28 | 29 | ```sh 30 | npm install --save-dev storybook-addon-headless 31 | ``` 32 | 33 | #### Register 34 | 35 | Then, configure it as an addon by adding it to your `addons.js` file (located in the Storybook config directory). 36 | 37 | ```js 38 | import 'storybook-addon-headless' 39 | ``` 40 | 41 | Or to the `addons` parameter in your `main.js` file (located in the Storybook config directory). 42 | 43 | ```js 44 | module.exports = { 45 | addons: ['storybook-addon-headless'], 46 | ..., 47 | } 48 | ``` 49 | 50 | #### Decorate 51 | 52 | Depending on the need of your project, you can either, add the `withHeadless` decorator: 53 | 54 | - Globally in `config.js` via `addDecorator(withHeadless({ ... }))` 55 | - Locally via `storiesOf('Name', module).addDecorator(withHeadless({ ... }))` 56 | - Locally to a story via CSF: 57 | 58 | ```js 59 | export default { 60 | ..., 61 | decorators: [withHeadless({ ... })], 62 | ..., 63 | } 64 | ``` 65 | 66 | You can find options documented as [HeadlessOptions](https://github.com/ArrayKnight/storybook-addon-headless/blob/master/src/types/options.ts) and on the [documentation site](https://storybook-addon-headless.netlify.com/?path=/story/options--page). 67 | 68 | ##### [Options](https://storybook-addon-headless.netlify.com/?path=/story/options--page) 69 | 70 | ```js 71 | { 72 | graphql?: GraphQLOptionsTypes 73 | restful?: RestfulOptionsTypes 74 | jsonDark?: ReactJsonViewThemeKey 75 | jsonLight?: ReactJsonViewThemeKey 76 | } 77 | ``` 78 | 79 | Under the covers, this addon uses Axios for Restful queries and Apollo Client for GraphQL queries. These configs are optional, though you'll likely want to use one or both. The configs will also be merged with the optional configs being passed through the parameters. 80 | 81 | #### [Parameters](https://storybook-addon-headless.netlify.com/?path=/story/parameters--page) 82 | 83 | Parameters are added locally via: 84 | 85 | - `storiesOf('Name', module).addParameters({ headless: { ... } })` 86 | - `add(name, storyFn, { headless: { ... } })` 87 | - Via CSF: 88 | 89 | ```js 90 | export default { 91 | ..., 92 | parameters: { 93 | headless: { ... } 94 | }, 95 | ..., 96 | } 97 | ``` 98 | 99 | You can find parameters document as [HeadlessParameters](https://github.com/ArrayKnight/storybook-addon-headless/blob/master/src/types/parameters.ts) and on the [documentation site](https://storybook-addon-headless.netlify.com/?path=/story/parameters--page). 100 | 101 | ```js 102 | { 103 | headless: { 104 | [name]: HeadlessParameter, 105 | ..., 106 | } 107 | } 108 | ``` 109 | 110 | `name` is the string to represent the query and data. It will be shown in the tab for the query and be the accessor on the data object in the story context. 111 | 112 | `HeadlessParameter` represents several different possible options: 113 | 114 | - `string`: Restful URL 115 | - `PackedDocumentNode`: A `pack`ed GraphQL Tag `DocumentNode` 116 | - `GraphQLParameters`: [An object](https://github.com/ArrayKnight/storybook-addon-headless/blob/master/src/types/parameters.ts) with a `PackedDocumentNode` as a query and some optional parameters 117 | - `RestfulParameters`: [An object](https://github.com/ArrayKnight/storybook-addon-headless/blob/master/src/types/parameters.ts) with a Restful URL string as a query and some optional parameters 118 | 119 | Due to the way a `DocumentNode` is converted to JSON, to maintain the original source query use the `pack` utility method. 120 | 121 | #### [Components](https://storybook-addon-headless.netlify.com/?path=/story/components--page) 122 | 123 | To help provide a better user experience, there are Prompt and Loader helper components provided. 124 | 125 | These components are entirely optional, but will help to direct users to the Headless tab if necessary and provide feedback about the state of active API requests. 126 | 127 | You can find basic usage in the [examples](https://github.com/ArrayKnight/storybook-addon-headless/tree/master/src/examples). 128 | 129 | **Experimental** _(read: untested)_: 130 | 131 | There are also two methods for those of you not using React, but wanting to use these helper components. `useHeadlessPrompt` and `useHeadlessLoader` will render the React components as standalone apps, but you must provide an HTML element reference that has been rendered and mounted by your framework of choice. 132 | 133 | ### Produced @ [GenUI](https://www.genui.com/) 134 | 135 | This addon was developed while I was employed at GenUI, a software product development firm in Seattle, WA, USA. Interested in knowing more, starting a new project or working with us? Come check us out at [https://www.genui.com/](https://www.genui.com/) 136 | -------------------------------------------------------------------------------- /src/components/Variables/index.tsx: -------------------------------------------------------------------------------- 1 | import addons from '@storybook/addons' 2 | import { Form, Icons } from '@storybook/components' 3 | import React, { memo, useEffect, useState } from 'react' 4 | 5 | import { 6 | ApiParameters, 7 | FetchStatus, 8 | SelectSchema, 9 | VariableState, 10 | VariableType, 11 | } from '../../types' 12 | import { 13 | ajv, 14 | getVariableType, 15 | hasOwnProperty, 16 | isItem, 17 | isNull, 18 | noopTransform, 19 | } from '../../utilities' 20 | import { Variable } from '../Variable' 21 | import { Fieldset, Actions } from './styled' 22 | import { EVENT_REQUESTED_STORY } from '../../config' 23 | 24 | export interface Props { 25 | hasData: boolean 26 | hasError: boolean 27 | parameters: ApiParameters 28 | onFetch: (variables: Record) => Promise 29 | } 30 | 31 | export function areValid(obj: Record): boolean { 32 | return Object.values(obj).every(({ error }) => isNull(error)) 33 | } 34 | 35 | export function createState( 36 | defaults: ApiParameters['defaults'], 37 | variables: ApiParameters['variables'], 38 | ): Record { 39 | return Object.entries(variables).reduce( 40 | (obj: Record, [name, schema]) => { 41 | const type = getVariableType(schema) 42 | const validator = ajv.compile( 43 | type === VariableType.Select 44 | ? { 45 | ...schema, 46 | enum: (schema as SelectSchema).enum.map( 47 | (option: unknown) => 48 | isItem(option) ? option.value : option, 49 | ), 50 | } 51 | : schema, 52 | ) 53 | const value = 54 | defaults[name] ?? 55 | (type === VariableType.Boolean ? false : undefined) 56 | const isValid = validator(value) 57 | const dirty = hasOwnProperty(defaults, name) && !isValid 58 | const [error] = validator.errors || [] 59 | const message = error?.message || null 60 | 61 | return { 62 | ...obj, 63 | [name]: { 64 | schema, 65 | type, 66 | validator, 67 | dirty, 68 | error: message, 69 | value, 70 | }, 71 | } 72 | }, 73 | {}, 74 | ) 75 | } 76 | 77 | export const Variables = memo( 78 | ({ hasData, hasError, parameters, onFetch }: Props) => { 79 | const { 80 | autoFetchOnInit = false, 81 | defaults = {}, 82 | variables = {}, 83 | transforms = {}, 84 | } = parameters 85 | const [states, setStates] = useState(createState(defaults, variables)) 86 | const [status, setStatus] = useState(FetchStatus.Inactive) 87 | const [isValid, setIsValid] = useState(areValid(states)) 88 | const isInactive = status === FetchStatus.Inactive 89 | const isLoading = status === FetchStatus.Loading 90 | const isRejected = status === FetchStatus.Rejected 91 | 92 | async function change(name: string, value: unknown): Promise { 93 | const { [name]: state } = states 94 | const { validator } = state 95 | 96 | await validator(value) 97 | 98 | const [error] = validator.errors || [] 99 | const message = error?.message || null 100 | const updated = { 101 | ...states, 102 | [name]: { 103 | ...state, 104 | dirty: true, 105 | error: message, 106 | value, 107 | }, 108 | } 109 | 110 | setStatus(FetchStatus.Inactive) 111 | 112 | setStates(updated) 113 | 114 | setIsValid(areValid(updated)) 115 | } 116 | 117 | async function fetch(): Promise { 118 | setStatus(FetchStatus.Loading) 119 | 120 | try { 121 | await onFetch( 122 | Object.entries(states).reduce( 123 | (acc, [name, { value }]) => ({ 124 | ...acc, 125 | [name]: (transforms[name] || noopTransform)(value), 126 | }), 127 | {}, 128 | ), 129 | ) 130 | 131 | setStatus(FetchStatus.Resolved) 132 | } catch { 133 | setStatus(FetchStatus.Rejected) 134 | } 135 | } 136 | 137 | function back(): void { 138 | addons.getChannel().emit(EVENT_REQUESTED_STORY) 139 | } 140 | 141 | useEffect(() => { 142 | if (autoFetchOnInit && isValid && !hasData && !hasError) { 143 | void fetch() 144 | } 145 | 146 | if (hasData) { 147 | setStatus(FetchStatus.Resolved) 148 | } 149 | 150 | if (hasError) { 151 | setStatus(FetchStatus.Rejected) 152 | } 153 | }, []) // eslint-disable-line react-hooks/exhaustive-deps 154 | 155 | return ( 156 | <> 157 |
158 | {Object.entries(states).map( 159 | ([name, { schema, type, dirty, error, value }]) => ( 160 | 169 | ), 170 | )} 171 |
172 | 173 | 177 | {!isInactive && ( 178 | 187 | )} 188 | Fetch{isInactive ? null : isLoading ? 'ing' : 'ed'} 189 | 190 | 191 | 192 | Back to Story 193 | 194 | 195 | 196 | ) 197 | }, 198 | ) 199 | 200 | Variables.displayName = 'Variables' 201 | -------------------------------------------------------------------------------- /src/components/Panel/index.tsx: -------------------------------------------------------------------------------- 1 | import { useChannel, useParameter, useStorybookApi } from '@storybook/api' 2 | import { TabsState } from '@storybook/components' 3 | import { ThemeProvider } from '@storybook/theming' 4 | import React, { memo } from 'react' 5 | import type { InteractionProps } from 'react-json-view' 6 | import { useStorageState } from 'react-storage-hooks' 7 | 8 | import { 9 | ADDON_ID, 10 | EVENT_DATA_UPDATED, 11 | EVENT_INITIALIZED, 12 | EVENT_REQUESTED_ADDON, 13 | EVENT_REQUESTED_STORY, 14 | PARAM_KEY, 15 | STORAGE_KEY, 16 | } from '../../config' 17 | import { 18 | ApiParameters, 19 | FetchStatus, 20 | HeadlessParameters, 21 | HeadlessState, 22 | InitializeMessage, 23 | UpdateMessage, 24 | } from '../../types' 25 | import { 26 | fetchViaGraphQL, 27 | fetchViaRestful, 28 | errorToJSON, 29 | isGraphQLParameters, 30 | isRestfulParameters, 31 | isFunction, 32 | generateUrl, 33 | } from '../../utilities' 34 | import { ErrorBoundary } from '../ErrorBoundary' 35 | import { Tab } from './Tab' 36 | import { Content, Root } from './styled' 37 | 38 | export const initialState: HeadlessState = { 39 | storyId: '', 40 | options: { 41 | graphql: {}, 42 | restful: {}, 43 | jsonDark: 'colors', 44 | jsonLight: 'rjv-default', 45 | }, 46 | status: {}, 47 | data: {}, 48 | errors: {}, 49 | } 50 | 51 | interface Props { 52 | active?: boolean 53 | } 54 | 55 | export const Panel = memo(({ active }: Props) => { 56 | const api = useStorybookApi() 57 | const headlessParameters = useParameter(PARAM_KEY, {}) 58 | const [state, setStorageState] = useStorageState( 59 | sessionStorage, 60 | STORAGE_KEY, 61 | initialState, 62 | ) 63 | const emit = useChannel({ 64 | [EVENT_INITIALIZED]: ({ storyId, options }: InitializeMessage) => { 65 | setState((prev) => ({ 66 | storyId, 67 | options: { 68 | ...prev.options, 69 | ...options, 70 | }, 71 | })) 72 | }, 73 | [EVENT_REQUESTED_ADDON]: () => { 74 | const storyId = api.getCurrentStoryData()?.id 75 | 76 | if (storyId) { 77 | api.navigateUrl(generateUrl(`/${ADDON_ID}/${storyId}`), {}) 78 | } 79 | }, 80 | [EVENT_REQUESTED_STORY]: () => { 81 | const storyId = api.getCurrentStoryData()?.id 82 | 83 | if (storyId) { 84 | api.selectStory(storyId) 85 | } 86 | }, 87 | }) 88 | 89 | function setState( 90 | update: 91 | | Partial 92 | | ((prev: HeadlessState) => Partial), 93 | ): void { 94 | setStorageState((prev) => { 95 | const next: HeadlessState = { 96 | ...prev, 97 | ...(isFunction(update) ? update(prev) : update), 98 | } 99 | const { status, data, errors } = next 100 | 101 | notify({ 102 | status, 103 | data, 104 | errors, 105 | }) 106 | 107 | return next 108 | }) 109 | } 110 | 111 | function notify(message: UpdateMessage): void { 112 | emit(EVENT_DATA_UPDATED, message) 113 | } 114 | 115 | function update( 116 | name: string, 117 | status: FetchStatus, 118 | data: unknown, 119 | error: Record | null, 120 | ): void { 121 | setState((prev) => ({ 122 | status: { 123 | ...prev.status, 124 | [name]: status, 125 | }, 126 | data: { 127 | ...prev.data, 128 | [name]: data, 129 | }, 130 | errors: { 131 | ...prev.errors, 132 | [name]: error, 133 | }, 134 | })) 135 | } 136 | 137 | async function fetch( 138 | name: string, 139 | apiParameters: ApiParameters, 140 | variables: Record, 141 | ): Promise { 142 | if ( 143 | isGraphQLParameters(apiParameters) || 144 | isRestfulParameters(apiParameters) 145 | ) { 146 | update(name, FetchStatus.Loading, null, null) 147 | } 148 | 149 | try { 150 | const response = await (isGraphQLParameters(apiParameters) 151 | ? fetchViaGraphQL( 152 | state.options.graphql, 153 | apiParameters, 154 | variables, 155 | ) 156 | : isRestfulParameters(apiParameters) 157 | ? fetchViaRestful( 158 | state.options.restful, 159 | apiParameters, 160 | variables, 161 | ) 162 | : Promise.reject(new Error('Invalid config, skipping fetch'))) 163 | 164 | update(name, FetchStatus.Resolved, response, null) 165 | } catch (error) { 166 | update( 167 | name, 168 | FetchStatus.Resolved, 169 | null, 170 | errorToJSON(error as Error), 171 | ) 172 | } 173 | } 174 | 175 | function updateData(name: string, { updated_src }: InteractionProps): void { 176 | update(name, FetchStatus.Resolved, updated_src, null) 177 | } 178 | 179 | if (state.storyId === api.getCurrentStoryData()?.id) { 180 | return ( 181 | 182 | 183 | 184 | 185 | {Object.entries(headlessParameters).map( 186 | ([name, parameter]) => ( 187 | // Must exist here (not inside of Tab) with these attributes for TabsState to function 188 |
189 | 190 | 199 | 200 |
201 | ), 202 | )} 203 |
204 |
205 |
206 |
207 | ) 208 | } 209 | 210 | return null 211 | }) 212 | 213 | Panel.displayName = 'Panel' 214 | -------------------------------------------------------------------------------- /src/__tests__/utilities.ts: -------------------------------------------------------------------------------- 1 | import { gql } from '@apollo/client' 2 | import { describe } from '@jest/globals' 3 | 4 | import { 5 | convertToItem, 6 | errorToJSON, 7 | fetchViaGraphQL, 8 | functionToTag, 9 | getBaseOptions, 10 | getGraphQLUri, 11 | getRestfulUrl, 12 | getVariableType, 13 | hasOwnProperty, 14 | isArray, 15 | isBoolean, 16 | isBooleanSchema, 17 | isDateTimeSchema, 18 | isDocumentNode, 19 | isFunction, 20 | isGraphQLParameters, 21 | isItem, 22 | isNil, 23 | isNull, 24 | isNumber, 25 | isNumberSchema, 26 | isObject, 27 | isObjectLike, 28 | isRestfulParameters, 29 | isSelectSchema, 30 | isString, 31 | isStringSchema, 32 | isUndefined, 33 | noopTransform, 34 | objectToTag, 35 | pack, 36 | unpack, 37 | } from '../utilities' 38 | 39 | const valuesMap = { 40 | Array: [] as unknown[], 41 | Boolean: true, 42 | Class: class ClassValue {}, 43 | Function: function FunctionValue() {}, // eslint-disable-line @typescript-eslint/no-empty-function 44 | Lambda: (): null => null, 45 | Null: null as null, 46 | NaN: NaN, 47 | Number: 42, 48 | Object: {}, 49 | String: 'foo', 50 | Undefined: undefined as undefined, 51 | WrappedBoolean: new Boolean(true), 52 | WrappedNumber: new Number(52), 53 | WrappedString: new String('foo'), 54 | } 55 | const values = Object.entries(valuesMap) as Array< 56 | [keyof typeof valuesMap, typeof valuesMap[keyof typeof valuesMap]] 57 | > 58 | const valuesAreOfType = values.reduce( 59 | (acc, [key]) => ({ ...acc, [key]: false }), 60 | {} as Record, 61 | ) 62 | 63 | describe('convertToItem', () => { 64 | it('should convert value to item', () => { 65 | const entries = [ 66 | ['True', true], 67 | ['False', false], 68 | ['Null', null], 69 | ['Undefined', undefined], 70 | ['42', 42], 71 | ['Foo', 'foo'], 72 | ['Unhandled item conversion', ['foo']], 73 | ['Unhandled item conversion', {}], 74 | ] 75 | 76 | entries.forEach(([label, value]) => { 77 | expect( 78 | convertToItem(value), 79 | `Failed to convert item: ${label}, ${value}`, 80 | ).toEqual({ 81 | label, 82 | value, 83 | }) 84 | }) 85 | }) 86 | 87 | it('should pass through item value', () => { 88 | const item = { label: 'Foo', value: 'foo' } 89 | 90 | expect(convertToItem(item)).toEqual(item) 91 | }) 92 | }) 93 | 94 | describe.skip('fetchViaGraphQL', () => { 95 | it('should', () => { 96 | expect(fetchViaGraphQL) 97 | }) 98 | }) 99 | 100 | describe('errorToJSON', () => { 101 | it('should convert error to object', () => { 102 | const { message, stack } = errorToJSON(new Error('Foo')) 103 | 104 | expect(message).toEqual('Foo') 105 | expect(stack).toEqual(expect.any(String)) 106 | }) 107 | }) 108 | 109 | describe('functionToTag', () => { 110 | it('should convert function to tag', () => { 111 | // eslint-disable-next-line @typescript-eslint/no-empty-function 112 | expect(functionToTag(function Foo() {})).toEqual('function Foo() { }') 113 | }) 114 | }) 115 | 116 | describe('getBaseOptions', () => { 117 | const defaultOptions = { id: 'default' } 118 | const otherOptions = { id: 'other' } 119 | const options = [defaultOptions, otherOptions] 120 | 121 | it('should return only base options', () => { 122 | const option = { uri: 'foo' } 123 | 124 | expect(getBaseOptions(option, {})).toEqual(option) 125 | expect(getBaseOptions(option, { base: 'default' })).toEqual(option) 126 | expect(getBaseOptions(option, { base: 'unknown' })).toEqual(option) 127 | }) 128 | 129 | it('should return default base options', () => { 130 | expect(getBaseOptions(options, {})).toEqual(defaultOptions) 131 | expect(getBaseOptions(options, { base: 'default' })).toEqual( 132 | defaultOptions, 133 | ) 134 | }) 135 | 136 | it('should return specific base options', () => { 137 | expect(getBaseOptions(options, { base: 'other' })).toEqual(otherOptions) 138 | }) 139 | 140 | it('should return empty options for unknown base', () => { 141 | expect(getBaseOptions(options, { base: 'unknown' })).toEqual({}) 142 | }) 143 | }) 144 | 145 | describe('getGraphQLUri', () => { 146 | it('should return uri', () => { 147 | const uri = 'https://www.foo.bar' 148 | const query = ` 149 | { 150 | Foo { 151 | id 152 | bar 153 | } 154 | } 155 | ` 156 | // Remove first 12 spaces because the query above is indented that far 157 | const queryShiftedLeft = query.replace(/^ {12}/gm, '') 158 | 159 | expect( 160 | getGraphQLUri( 161 | { uri }, 162 | { 163 | query: pack(gql(query)), 164 | }, 165 | ), 166 | ).toEqual(`${uri}\r\n${queryShiftedLeft}`) 167 | }) 168 | }) 169 | 170 | describe('getRestfulUrl', () => { 171 | const baseURL = 'https://www.foo.bar/api' 172 | 173 | it('should return url', () => { 174 | const query = '/bar' 175 | 176 | expect(getRestfulUrl({ baseURL }, { query }, {})).toEqual( 177 | `${baseURL}${query}`, 178 | ) 179 | }) 180 | 181 | it('should return url with variables', () => { 182 | const query = '/bar/{id}' 183 | 184 | expect(getRestfulUrl({ baseURL }, { query }, { id: 42 })).toEqual( 185 | `${baseURL}/bar/42`, 186 | ) 187 | }) 188 | }) 189 | 190 | describe.skip('getVariableType', () => { 191 | it('should', () => { 192 | expect(getVariableType) 193 | }) 194 | }) 195 | 196 | describe.skip('hasOwnProperty', () => { 197 | it('should', () => { 198 | expect(hasOwnProperty) 199 | }) 200 | }) 201 | 202 | describe('isArray', () => { 203 | it('should return if value is an array', () => { 204 | const valuesAreArray = { 205 | ...valuesAreOfType, 206 | Array: true, 207 | } 208 | 209 | values.forEach(([key, value]) => { 210 | expect(isArray(value), key).toEqual(valuesAreArray[key]) 211 | }) 212 | }) 213 | }) 214 | 215 | describe('isBoolean', () => { 216 | it('should return if value is a boolean', () => { 217 | const valuesAreBoolean = { 218 | ...valuesAreOfType, 219 | Boolean: true, 220 | } 221 | 222 | values.forEach(([key, value]) => { 223 | expect(isBoolean(value), key).toEqual(valuesAreBoolean[key]) 224 | }) 225 | }) 226 | }) 227 | 228 | describe.skip('isBooleanSchema', () => { 229 | it('should', () => { 230 | expect(isBooleanSchema) 231 | }) 232 | }) 233 | 234 | describe.skip('isDateTimeSchema', () => { 235 | it('should', () => { 236 | expect(isDateTimeSchema) 237 | }) 238 | }) 239 | 240 | describe.skip('isDocumentNode', () => { 241 | it('should', () => { 242 | expect(isDocumentNode) 243 | }) 244 | }) 245 | 246 | describe('isFunction', () => { 247 | it('should return if value is a function', () => { 248 | const valuesAreFunction = { 249 | ...valuesAreOfType, 250 | Class: true, 251 | Function: true, 252 | Lambda: true, 253 | } 254 | 255 | values.forEach(([key, value]) => { 256 | expect(isFunction(value), key).toEqual(valuesAreFunction[key]) 257 | }) 258 | }) 259 | }) 260 | 261 | describe.skip('isGraphQLParameters', () => { 262 | it('should', () => { 263 | expect(isGraphQLParameters) 264 | }) 265 | }) 266 | 267 | describe('isItem', () => { 268 | it('should return if value is an item', () => { 269 | const invalidObjects = [ 270 | { foo: 'bar' }, // missing label + value 271 | { value: false }, // missing label 272 | { label: 'Foo' }, // missing value 273 | { label: true, value: false }, // label is wrong type 274 | ] 275 | 276 | values.forEach(([key, value]) => { 277 | expect(isItem(value), key).toBeFalsy() 278 | }) 279 | 280 | invalidObjects.forEach((value) => { 281 | expect(isItem(value), `${value}`).toBeFalsy() 282 | }) 283 | 284 | expect(isItem({ label: 'Foo', value: true })).toBeTruthy() 285 | }) 286 | }) 287 | 288 | describe('isNil', () => { 289 | it('should return if value is null or undefined', () => { 290 | const valuesAreNil = { 291 | ...valuesAreOfType, 292 | Null: true, 293 | Undefined: true, 294 | } 295 | 296 | values.forEach(([key, value]) => { 297 | expect(isNil(value), key).toEqual(valuesAreNil[key]) 298 | }) 299 | }) 300 | }) 301 | 302 | describe('isNull', () => { 303 | it('should return if value is null', () => { 304 | const valuesAreNull = { 305 | ...valuesAreOfType, 306 | Null: true, 307 | } 308 | 309 | values.forEach(([key, value]) => { 310 | expect(isNull(value), key).toEqual(valuesAreNull[key]) 311 | }) 312 | }) 313 | }) 314 | 315 | describe('isNumber', () => { 316 | it('should return if value is a number, but not NaN', () => { 317 | const valuesAreNumber = { 318 | ...valuesAreOfType, 319 | Number: true, 320 | } 321 | 322 | values.forEach(([key, value]) => { 323 | expect(isNumber(value), key).toEqual(valuesAreNumber[key]) 324 | }) 325 | }) 326 | }) 327 | 328 | describe.skip('isNumberSchema', () => { 329 | it('should', () => { 330 | expect(isNumberSchema) 331 | }) 332 | }) 333 | 334 | describe('isObject', () => { 335 | it('should return if value is an object', () => { 336 | const valuesAreObject = { 337 | ...valuesAreOfType, 338 | Object: true, 339 | } 340 | 341 | values.forEach(([key, value]) => { 342 | expect(isObject(value), key).toEqual(valuesAreObject[key]) 343 | }) 344 | }) 345 | }) 346 | 347 | describe('isObjectLike', () => { 348 | it('should return if value is an object or object wrapped', () => { 349 | const valuesAreObjectLike = { 350 | ...valuesAreOfType, 351 | Array: true, 352 | Object: true, 353 | WrappedBoolean: true, 354 | WrappedNumber: true, 355 | WrappedString: true, 356 | } 357 | 358 | values.forEach(([key, value]) => { 359 | expect(isObjectLike(value), key).toEqual(valuesAreObjectLike[key]) 360 | }) 361 | }) 362 | }) 363 | 364 | describe.skip('isRestfulParameters', () => { 365 | it('should', () => { 366 | expect(isRestfulParameters) 367 | }) 368 | }) 369 | 370 | describe.skip('isSelectSchema', () => { 371 | it('should', () => { 372 | expect(isSelectSchema) 373 | }) 374 | }) 375 | 376 | describe('isString', () => { 377 | it('should return if value is a string', () => { 378 | const valuesAreString = { 379 | ...valuesAreOfType, 380 | String: true, 381 | } 382 | 383 | values.forEach(([key, value]) => { 384 | expect(isString(value), key).toEqual(valuesAreString[key]) 385 | }) 386 | }) 387 | }) 388 | 389 | describe.skip('isStringSchema', () => { 390 | it('should', () => { 391 | expect(isStringSchema) 392 | }) 393 | }) 394 | 395 | describe('isUndefined', () => { 396 | it('should return if value is undefined', () => { 397 | const valuesAreUndefined = { 398 | ...valuesAreOfType, 399 | Undefined: true, 400 | } 401 | 402 | values.forEach(([key, value]) => { 403 | expect(isUndefined(value), key).toEqual(valuesAreUndefined[key]) 404 | }) 405 | }) 406 | }) 407 | 408 | describe('noopTransform', () => { 409 | it('should return the value', () => { 410 | values.forEach(([key, value]) => { 411 | expect(noopTransform(value), key).toBe(value) 412 | }) 413 | }) 414 | }) 415 | 416 | describe('objectToTag', () => { 417 | it('should convert object to tag', () => { 418 | expect(objectToTag({})).toEqual('[object Object]') 419 | }) 420 | }) 421 | 422 | describe.skip('pack', () => { 423 | it('should', () => { 424 | expect(pack) 425 | }) 426 | }) 427 | 428 | describe.skip('unpack', () => { 429 | it('should', () => { 430 | expect(unpack) 431 | }) 432 | }) 433 | -------------------------------------------------------------------------------- /src/utilities.ts: -------------------------------------------------------------------------------- 1 | import { ApolloClient, DocumentNode, InMemoryCache } from '@apollo/client' 2 | import Ajv from 'ajv' 3 | import addFormats from 'ajv-formats' 4 | import defineKeywords from 'ajv-keywords' 5 | import axios, { AxiosPromise } from 'axios' 6 | import { sentenceCase } from 'change-case' 7 | import qs from 'qs' 8 | import { useEffect, useState } from 'react' 9 | 10 | import { 11 | AnySchema, 12 | BaseParameters, 13 | BooleanSchema, 14 | DateTimeSchema, 15 | GraphQLOptions, 16 | GraphQLOptionsTypes, 17 | GraphQLParameters, 18 | Item, 19 | NumberSchema, 20 | OneOrMore, 21 | PackedDocumentNode, 22 | RestfulOptions, 23 | RestfulOptionsTypes, 24 | RestfulParameters, 25 | SelectSchema, 26 | StringSchema, 27 | VariableType, 28 | } from './types' 29 | 30 | export const ajv = new Ajv({ $data: true }) 31 | 32 | addFormats(ajv) 33 | defineKeywords(ajv) 34 | 35 | export function convertToItem(value: unknown): Item { 36 | switch (true) { 37 | case isBoolean(value): 38 | case isNil(value): 39 | case isNumber(value): 40 | case isString(value): 41 | return { 42 | label: isNumber(value) ? `${value}` : sentenceCase(`${value}`), 43 | value, 44 | } 45 | 46 | case isItem(value): 47 | return value as Item 48 | 49 | default: 50 | return { 51 | label: 'Unhandled item conversion', 52 | value, 53 | } 54 | } 55 | } 56 | 57 | export function errorToJSON(error: Error): Record { 58 | return Object.getOwnPropertyNames(error).reduce( 59 | (obj, key) => ({ 60 | ...obj, 61 | [key]: error[key as keyof Error], 62 | }), 63 | {}, 64 | ) 65 | } 66 | 67 | export async function fetchViaGraphQL( 68 | options: GraphQLOptionsTypes, 69 | parameters: GraphQLParameters, 70 | variables: Record, 71 | ): Promise { 72 | const opts = getBaseOptions(options, parameters) 73 | const { config = {}, query } = parameters 74 | const instance = new ApolloClient({ 75 | ...opts, 76 | ...config, 77 | cache: new InMemoryCache(), 78 | }) 79 | 80 | const { data, error, errors } = await instance.query({ 81 | query: unpack(query), 82 | variables, 83 | fetchPolicy: 'network-only', 84 | }) 85 | 86 | if (error || (errors && errors.length)) { 87 | throw error || errors[0] 88 | } 89 | 90 | return data 91 | } 92 | 93 | export async function fetchViaRestful( 94 | options: RestfulOptionsTypes, 95 | parameters: RestfulParameters, 96 | variables: Record, 97 | ): Promise { 98 | const { config = {}, convertToFormData } = parameters 99 | const opts = getBaseOptions(options, parameters) 100 | const formData = new FormData() 101 | 102 | if (convertToFormData) { 103 | Object.entries(variables).forEach(([key, value]) => { 104 | formData.append( 105 | key, 106 | isString(value) || value instanceof Blob 107 | ? value 108 | : JSON.stringify(value), 109 | ) 110 | }) 111 | } 112 | 113 | const { data } = await (axios({ 114 | url: getRestfulUrl(opts, parameters, variables), 115 | ...opts, 116 | ...config, 117 | data: convertToFormData ? formData : variables, 118 | }) as AxiosPromise) 119 | 120 | return data 121 | } 122 | 123 | export function functionToTag(func: (...args: unknown[]) => unknown): string { 124 | return Function.prototype.toString.call(func) 125 | } 126 | 127 | export function getBaseOptions( 128 | options: OneOrMore, 129 | { base }: BaseParameters, 130 | ): T { 131 | if (isArray(options)) { 132 | const name = base || 'default' 133 | 134 | return options.find(({ id }) => id === name) || ({} as T) 135 | } 136 | 137 | return options 138 | } 139 | 140 | export function getGraphQLUri( 141 | options: GraphQLOptionsTypes, 142 | parameters: GraphQLParameters, 143 | ): string { 144 | const base = 145 | { ...getBaseOptions(options, parameters), ...(parameters.config || {}) } 146 | .uri || '' 147 | let query = parameters.query.source 148 | const match = /([ \t]+)[^\s]/.exec(query) 149 | 150 | if (!isNull(match)) { 151 | const [, space] = match 152 | 153 | query = query.replace(new RegExp(`^${space}`, 'gm'), '') 154 | } 155 | 156 | return query ? `${base}\r\n${query}` : `${base}` 157 | } 158 | 159 | export function getRestfulUrl( 160 | options: RestfulOptionsTypes, 161 | parameters: RestfulParameters, 162 | variables: Record, 163 | ): string { 164 | const base = 165 | { ...getBaseOptions(options, parameters), ...(parameters.config || {}) } 166 | .baseURL || '' 167 | const path = Object.entries(variables).reduce( 168 | (url, [name, value]) => url.replace(`{${name}}`, `${value}`), 169 | parameters.query, 170 | ) 171 | 172 | return `${base}${path}` 173 | } 174 | 175 | export function getVariableType(schema: AnySchema): VariableType { 176 | switch (true) { 177 | case isBooleanSchema(schema): 178 | return VariableType.Boolean 179 | 180 | case isDateTimeSchema(schema): 181 | return VariableType.Date 182 | 183 | case isNumberSchema(schema): 184 | return VariableType.Number 185 | 186 | case isSelectSchema(schema): 187 | return VariableType.Select 188 | 189 | case isStringSchema(schema): 190 | return VariableType.String 191 | 192 | default: 193 | return VariableType.Unknown 194 | } 195 | } 196 | 197 | export function generateUrl(path: string): string { 198 | const { location } = document 199 | const query = qs.parse(location.search, { ignoreQueryPrefix: true }) 200 | 201 | return `${location.origin + location.pathname}?${qs.stringify( 202 | { ...query, path }, 203 | { encode: false }, 204 | )}` 205 | } 206 | 207 | export function hasOwnProperty(instance: unknown, property: string): boolean { 208 | return Object.prototype.hasOwnProperty.call(instance, property) 209 | } 210 | 211 | export function isArray(value: unknown): value is T[] { 212 | return Array.isArray(value) 213 | } 214 | 215 | export function isBoolean(value: unknown): value is boolean { 216 | return value === true || value === false 217 | } 218 | 219 | export function isBooleanSchema(value: unknown): value is BooleanSchema { 220 | return isObject(value) && value.type === 'boolean' 221 | } 222 | 223 | export function isDateTimeSchema(value: unknown): value is DateTimeSchema { 224 | return ( 225 | isObject(value) && 226 | (value.format === 'date' || 227 | value.format === 'date-time' || 228 | value.format === 'time') 229 | ) 230 | } 231 | 232 | // TODO any more validation necessary? 233 | const validateDocument = ajv.compile({ 234 | type: 'object', 235 | properties: { 236 | kind: { 237 | type: 'string', 238 | pattern: '^Document$', 239 | }, 240 | definitions: { 241 | type: 'array', 242 | minItems: 1, 243 | }, 244 | source: { 245 | type: 'string', 246 | }, 247 | }, 248 | required: ['kind', 'definitions'], 249 | }) 250 | 251 | export function isDocumentNode( 252 | value: unknown, 253 | ): value is DocumentNode | PackedDocumentNode { 254 | return !!validateDocument(value) 255 | } 256 | 257 | export function isFunction( 258 | value: unknown, 259 | ): value is (...args: unknown[]) => unknown { 260 | return typeof value === 'function' 261 | } 262 | 263 | // TODO any more validation necessary? 264 | const validateGraphQLParameters = ajv.compile({ 265 | type: 'object', 266 | properties: { 267 | query: { 268 | type: 'object', // TODO share + reuse schemas (Document for in GraphQLParameters) 269 | }, 270 | variables: { 271 | type: 'object', 272 | }, 273 | }, 274 | required: ['query'], 275 | }) 276 | 277 | export function isGraphQLParameters( 278 | value: unknown, 279 | ): value is GraphQLParameters { 280 | return ( 281 | !!validateGraphQLParameters(value) && 282 | isDocumentNode((value as GraphQLParameters).query) 283 | ) 284 | } 285 | 286 | export function isItem(value: unknown): value is Item { 287 | return ( 288 | isObject(value) && 289 | hasOwnProperty(value, 'label') && 290 | isString((value as { label: unknown }).label) && 291 | hasOwnProperty(value, 'value') 292 | ) 293 | } 294 | 295 | export function isNil(value: unknown): value is null | undefined { 296 | return isNull(value) || isUndefined(value) 297 | } 298 | 299 | export function isNull(value: unknown): value is null { 300 | return value === null 301 | } 302 | 303 | export function isNumber(value: unknown): value is number { 304 | return typeof value === 'number' && !isNaN(value) 305 | } 306 | 307 | export function isNumberSchema(value: unknown): value is NumberSchema { 308 | return ( 309 | isObject(value) && 310 | (value.type === 'integer' || value.type === 'number') 311 | ) 312 | } 313 | 314 | export function isObject(value: unknown): value is T { 315 | if (!isObjectLike(value) || objectToTag(value) !== '[object Object]') { 316 | return false 317 | } 318 | 319 | const prototype = Object.getPrototypeOf(Object(value)) as unknown 320 | 321 | if (isNull(prototype)) { 322 | return true 323 | } 324 | 325 | const Ctor = 326 | Object.prototype.hasOwnProperty.call(prototype, 'constructor') && 327 | prototype.constructor 328 | 329 | return ( 330 | isFunction(Ctor) && 331 | Ctor instanceof Ctor && 332 | functionToTag(Ctor) === functionToTag(Object) 333 | ) 334 | } 335 | 336 | export function isObjectLike(value: unknown): boolean { 337 | return !isNull(value) && typeof value === 'object' 338 | } 339 | 340 | // TODO any more validation necessary? 341 | const validateRestfulParameters = ajv.compile({ 342 | type: 'object', 343 | properties: { 344 | query: { 345 | type: 'string', 346 | }, 347 | variables: { 348 | type: 'object', 349 | }, 350 | }, 351 | required: ['query'], 352 | }) 353 | 354 | export function isRestfulParameters( 355 | value: unknown, 356 | ): value is RestfulParameters { 357 | return !!validateRestfulParameters(value) 358 | } 359 | 360 | export function isSelectSchema(value: unknown): value is SelectSchema { 361 | return ( 362 | isObject(value) && 363 | isArray(value.enum) && 364 | value.enum.length > 1 365 | ) 366 | } 367 | 368 | export function isString(value: unknown): value is string { 369 | return typeof value === 'string' 370 | } 371 | 372 | export function isStringSchema(value: unknown): value is StringSchema { 373 | return isObject(value) && value.type === 'string' 374 | } 375 | 376 | export function isUndefined(value: unknown): value is undefined { 377 | return value === undefined 378 | } 379 | 380 | export function noopTransform(value: T): T { 381 | return value 382 | } 383 | 384 | export function objectToTag(value: unknown): string { 385 | return Object.prototype.toString.call(value) 386 | } 387 | 388 | export function pack({ 389 | kind, 390 | definitions, 391 | loc, 392 | }: DocumentNode): PackedDocumentNode { 393 | return { 394 | kind, 395 | definitions: definitions.map((definition) => 396 | JSON.stringify(definition), 397 | ), 398 | source: loc?.source.body, 399 | } 400 | } 401 | 402 | export function unpack({ 403 | kind, 404 | definitions, 405 | }: PackedDocumentNode): DocumentNode { 406 | return { 407 | kind, 408 | definitions: definitions.map((definition) => JSON.parse(definition)), 409 | } 410 | } 411 | 412 | export function useImmediateEffect(effect: () => void): void { 413 | const [complete, setComplete] = useState(false) 414 | 415 | if (complete) { 416 | effect() 417 | } 418 | 419 | useEffect(() => setComplete(true), []) 420 | } 421 | --------------------------------------------------------------------------------