Skip to content

Commit

Permalink
Merge pull request #26827 from storybookjs/save-from-controls
Browse files Browse the repository at this point in the history
Core: Save from controls
  • Loading branch information
ndelangen committed May 2, 2024
2 parents 1ef9050 + 5aa7cfc commit 84b322b
Show file tree
Hide file tree
Showing 114 changed files with 11,176 additions and 2,861 deletions.
2 changes: 2 additions & 0 deletions code/addons/controls/package.json
Expand Up @@ -52,13 +52,15 @@
},
"dependencies": {
"@storybook/blocks": "workspace:*",
"dequal": "^2.0.2",
"lodash": "^4.17.21",
"ts-dedent": "^2.0.0"
},
"devDependencies": {
"@storybook/client-logger": "workspace:*",
"@storybook/components": "workspace:*",
"@storybook/core-common": "workspace:*",
"@storybook/icons": "^1.2.5",
"@storybook/manager-api": "workspace:*",
"@storybook/node-logger": "workspace:*",
"@storybook/preview-api": "workspace:*",
Expand Down
67 changes: 50 additions & 17 deletions code/addons/controls/src/ControlsPanel.tsx
@@ -1,5 +1,6 @@
import type { FC } from 'react';
import React, { useEffect, useState } from 'react';
import { dequal as deepEqual } from 'dequal';
import React, { useEffect, useMemo, useState } from 'react';
import { global } from '@storybook/global';
import {
useArgs,
useGlobals,
Expand All @@ -8,19 +9,41 @@ import {
useStorybookState,
} from '@storybook/manager-api';
import { PureArgsTable as ArgsTable, type PresetColor, type SortType } from '@storybook/blocks';

import { styled } from '@storybook/theming';
import type { ArgTypes } from '@storybook/types';

import { PARAM_KEY } from './constants';
import { SaveStory } from './SaveStory';

// Remove undefined values (top-level only)
const clean = (obj: { [key: string]: any }) =>
Object.entries(obj).reduce(
(acc, [key, value]) => (value !== undefined ? Object.assign(acc, { [key]: value }) : acc),
{} as typeof obj
);

const AddonWrapper = styled.div({
display: 'grid',
gridTemplateRows: '1fr 39px',
height: '100%',
maxHeight: '100vh',
overflowY: 'auto',
});

interface ControlsParameters {
sort?: SortType;
expanded?: boolean;
presetColors?: PresetColor[];
}

export const ControlsPanel: FC = () => {
interface ControlsPanelProps {
saveStory: () => Promise<unknown>;
createStory: (storyName: string) => Promise<unknown>;
}

export const ControlsPanel = ({ saveStory, createStory }: ControlsPanelProps) => {
const [isLoading, setIsLoading] = useState(true);
const [args, updateArgs, resetArgs] = useArgs();
const [args, updateArgs, resetArgs, initialArgs] = useArgs();
const [globals] = useGlobals();
const rows = useArgTypes();
const { expanded, sort, presetColors } = useParameter<ControlsParameters>(PARAM_KEY, {});
Expand All @@ -42,18 +65,28 @@ export const ControlsPanel: FC = () => {
return acc;
}, {} as ArgTypes);

const hasUpdatedArgs = useMemo(
() => !!args && !!initialArgs && !deepEqual(clean(args), clean(initialArgs)),
[args, initialArgs]
);

return (
<ArgsTable
key={path} // resets state when switching stories
compact={!expanded && hasControls}
rows={withPresetColors}
args={args}
globals={globals}
updateArgs={updateArgs}
resetArgs={resetArgs}
inAddonPanel
sort={sort}
isLoading={isLoading}
/>
<AddonWrapper>
<ArgsTable
key={path} // resets state when switching stories
compact={!expanded && hasControls}
rows={withPresetColors}
args={args}
globals={globals}
updateArgs={updateArgs}
resetArgs={resetArgs}
inAddonPanel
sort={sort}
isLoading={isLoading}
/>
{hasControls && hasUpdatedArgs && global.CONFIG_TYPE === 'DEVELOPMENT' && (
<SaveStory {...{ resetArgs, saveStory, createStory }} />
)}
</AddonWrapper>
);
};
59 changes: 59 additions & 0 deletions code/addons/controls/src/SaveStory.stories.tsx
@@ -0,0 +1,59 @@
import React from 'react';
import { action } from '@storybook/addon-actions';
import type { Meta, StoryObj } from '@storybook/react';

import { SaveStory } from './SaveStory';
import { expect, fireEvent, fn, userEvent, within } from '@storybook/test';

const meta = {
component: SaveStory,
args: {
saveStory: fn((...args) => Promise.resolve(action('saveStory')(...args))),
createStory: fn((...args) => Promise.resolve(action('createStory')(...args))),
resetArgs: fn(action('resetArgs')),
},
parameters: {
layout: 'fullscreen',
},
decorators: [
(Story) => (
<div style={{ minHeight: '100vh' }}>
<Story />
</div>
),
],
} satisfies Meta<typeof SaveStory>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {};

export const Creating = {
play: async ({ canvasElement }) => {
const createButton = await within(canvasElement).findByRole('button', { name: /Create/i });
await fireEvent.click(createButton);
await new Promise((resolve) => setTimeout(resolve, 300));
},
} satisfies Story;

export const Created: Story = {
play: async (context) => {
await Creating.play(context);

const dialog = await within(document.body).findByRole('dialog');
const input = await within(dialog).findByRole('textbox');
await userEvent.type(input, 'MyNewStory');

fireEvent.submit(dialog.getElementsByTagName('form')[0]);
await expect(context.args.createStory).toHaveBeenCalledWith('MyNewStory');
},
};

export const CreatingFailed: Story = {
args: {
// eslint-disable-next-line local-rules/no-uncategorized-errors
createStory: fn((...args) => Promise.reject<any>(new Error('Story already exists.'))),
},
play: Created.play,
};
219 changes: 219 additions & 0 deletions code/addons/controls/src/SaveStory.tsx
@@ -0,0 +1,219 @@
import {
Bar as BaseBar,
Button,
Form,
IconButton,
Modal,
TooltipNote,
WithTooltip,
} from '@storybook/components';
import { AddIcon, CheckIcon, UndoIcon } from '@storybook/icons';
import { keyframes, styled } from '@storybook/theming';
import React from 'react';

const slideIn = keyframes({
from: { transform: 'translateY(40px)' },
to: { transform: 'translateY(0)' },
});

const highlight = keyframes({
from: { background: 'var(--highlight-bg-color)' },
to: {},
});

const Container = styled.div({
containerType: 'size',
position: 'sticky',
bottom: 0,
height: 39,
overflow: 'hidden',
zIndex: 1,
});

const Bar = styled(BaseBar)(({ theme }) => ({
'--highlight-bg-color': theme.base === 'dark' ? '#153B5B' : '#E0F0FF',
display: 'flex',
flexDirection: 'row-reverse', // hide Info rather than Actions on overflow
alignItems: 'center',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 6,
padding: '6px 10px',
animation: `${slideIn} 300ms, ${highlight} 2s`,
background: theme.background.bar,
borderTop: `1px solid ${theme.appBorderColor}`,
fontSize: theme.typography.size.s2,

'@container (max-width: 799px)': {
flexDirection: 'row',
justifyContent: 'flex-end',
},
}));

const Info = styled.div({
display: 'flex',
flex: '99 0 auto',
alignItems: 'center',
marginLeft: 10,
gap: 6,
});

const Actions = styled.div(({ theme }) => ({
display: 'flex',
flex: '1 0 0',
alignItems: 'center',
gap: 2,
color: theme.color.mediumdark,
fontSize: theme.typography.size.s2,
}));

const Label = styled.div({
'@container (max-width: 799px)': {
lineHeight: 0,
textIndent: '-9999px',
'&::after': {
content: 'attr(data-short-label)',
display: 'block',
lineHeight: 'initial',
textIndent: '0',
},
},
});

const ModalInput = styled(Form.Input)(({ theme }) => ({
'::placeholder': {
color: theme.color.mediumdark,
},
'&:invalid:not(:placeholder-shown)': {
boxShadow: `${theme.color.negative} 0 0 0 1px inset`,
},
}));

type SaveStoryProps = {
saveStory: () => Promise<unknown>;
createStory: (storyName: string) => Promise<unknown>;
resetArgs: () => void;
};

export const SaveStory = ({ saveStory, createStory, resetArgs }: SaveStoryProps) => {
const inputRef = React.useRef<HTMLInputElement>(null);
const [saving, setSaving] = React.useState(false);
const [creating, setCreating] = React.useState(false);
const [storyName, setStoryName] = React.useState('');
const [errorMessage, setErrorMessage] = React.useState(null);

const onSaveStory = async () => {
if (saving) return;
setSaving(true);
await saveStory().catch(() => {});
setSaving(false);
};

const onShowForm = () => {
setCreating(true);
setStoryName('');
setTimeout(() => inputRef.current?.focus(), 0);
};
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
.replace(/^[^a-z]/i, '')
.replace(/[^a-z0-9-_ ]/gi, '')
.replaceAll(/([-_ ]+[a-z0-9])/gi, (match) => match.toUpperCase().replace(/[-_ ]/g, ''));
setStoryName(value.charAt(0).toUpperCase() + value.slice(1));
};
const onSubmitForm = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (saving) return;
try {
setErrorMessage(null);
setSaving(true);
await createStory(storyName.replace(/^[^a-z]/i, '').replaceAll(/[^a-z0-9]/gi, ''));
setCreating(false);
setSaving(false);
} catch (e: any) {
setErrorMessage(e.message);
setSaving(false);
}
};

return (
<Container>
<Bar>
<Actions>
<WithTooltip
as="div"
hasChrome={false}
trigger="hover"
tooltip={<TooltipNote note="Save changes to story" />}
>
<IconButton aria-label="Save changes to story" disabled={saving} onClick={onSaveStory}>
<CheckIcon />
<Label data-short-label="Save">Update story</Label>
</IconButton>
</WithTooltip>

<WithTooltip
as="div"
hasChrome={false}
trigger="hover"
tooltip={<TooltipNote note="Create new story with these settings" />}
>
<IconButton aria-label="Create new story with these settings" onClick={onShowForm}>
<AddIcon />
<Label data-short-label="New">Create new story</Label>
</IconButton>
</WithTooltip>

<WithTooltip
as="div"
hasChrome={false}
trigger="hover"
tooltip={<TooltipNote note="Reset changes" />}
>
<IconButton aria-label="Reset changes" onClick={() => resetArgs()}>
<UndoIcon />
<span>Reset</span>
</IconButton>
</WithTooltip>
</Actions>

<Info>
<Label data-short-label="Unsaved changes">
You modified this story. Do you want to save your changes?
</Label>
</Info>

<Modal width={350} open={creating} onOpenChange={setCreating}>
<Form onSubmit={onSubmitForm}>
<Modal.Content>
<Modal.Header>
<Modal.Title>Create new story</Modal.Title>
<Modal.Description>
This will add a new story to your existing stories file.
</Modal.Description>
</Modal.Header>
<ModalInput
onChange={onChange}
placeholder="Story export name"
readOnly={saving}
ref={inputRef}
value={storyName}
/>
<Modal.Actions>
<Button disabled={saving || !storyName} size="medium" type="submit" variant="solid">
Create
</Button>
<Modal.Dialog.Close asChild>
<Button disabled={saving} size="medium" type="reset">
Cancel
</Button>
</Modal.Dialog.Close>
</Modal.Actions>
</Modal.Content>
</Form>
{errorMessage && <Modal.Error>{errorMessage}</Modal.Error>}
</Modal>
</Bar>
</Container>
);
};

0 comments on commit 84b322b

Please sign in to comment.