= {}): 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 |
60 |
66 |
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 | |  |  |
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 |
--------------------------------------------------------------------------------