refactor: split storybook (#2706)

This commit is contained in:
Himself65
2023-06-07 16:55:06 +08:00
committed by GitHub
parent f4be15baec
commit c4c4ec6a67
35 changed files with 195 additions and 165 deletions

View File

@@ -0,0 +1,61 @@
import { runCli } from '@magic-works/i18n-codegen';
import type { StorybookConfig } from '@storybook/react-vite';
import { fileURLToPath } from 'node:url';
import { mergeConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';
import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin';
runCli(
{
config: fileURLToPath(
new URL('../../../.i18n-codegen.json', import.meta.url)
),
watch: false,
},
error => {
console.error(error);
process.exit(1);
}
);
export default {
stories: ['../src/**/*.stories.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],
staticDirs: ['../../../apps/web/public'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-interactions',
'@storybook/addon-storysource',
'storybook-dark-mode',
],
framework: {
name: '@storybook/react-vite',
},
async viteFinal(config, { configType }) {
return mergeConfig(config, {
assetsInclude: ['**/*.md'],
plugins: [
vanillaExtractPlugin(),
tsconfigPaths({
root: fileURLToPath(new URL('../../../', import.meta.url)),
}),
],
define: {
'process.env': {},
},
resolve: {
alias: {
'dotenv/config': fileURLToPath(
new URL('../../../scripts/vitest/dotenv-config.ts', import.meta.url)
),
'next/config': fileURLToPath(
new URL(
'../../../scripts/vitest/next-config-mock.ts',
import.meta.url
)
),
},
},
});
},
} as StorybookConfig;

View File

@@ -0,0 +1,3 @@
<script>
window.global = window;
</script>

View File

@@ -0,0 +1,66 @@
import '@affine/component/theme/global.css';
import '@affine/component/theme/theme.css';
import { LOCALES, createI18n } from '@affine/i18n';
import { ThemeProvider, useTheme } from 'next-themes';
import { ComponentType, useEffect } from 'react';
import { useDarkMode } from 'storybook-dark-mode';
export const parameters = {
backgrounds: { disable: true },
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
};
export const globalTypes = {
locale: {
name: 'Locale',
description: 'Internationalization locale',
defaultValue: 'en',
toolbar: {
icon: 'globe',
items: LOCALES.map(locale => ({
title: locale.originalName,
value: locale.tag,
right: locale.flagEmoji,
})),
},
},
};
const createI18nDecorator = () => {
const i18n = createI18n();
const withI18n = (Story: any, context: any) => {
const locale = context.globals.locale;
useEffect(() => {
i18n.changeLanguage(locale);
}, [locale]);
return <Story {...context} />;
};
return withI18n;
};
const Component = () => {
const isDark = useDarkMode();
const theme = useTheme();
useEffect(() => {
theme.setTheme(isDark ? 'dark' : 'light');
}, [isDark]);
return null;
};
export const decorators = [
(Story: ComponentType) => {
return (
<ThemeProvider>
<Component />
<Story />
</ThemeProvider>
);
},
createI18nDecorator(),
];

View File

@@ -0,0 +1,50 @@
{
"name": "@affine/storybook",
"private": true,
"scripts": {
"storybook": "storybook dev -p 6006",
"build-storybook": "NODE_OPTIONS=--max_old_space_size=4096 storybook build",
"test-storybook": "test-storybook"
},
"dependencies": {
"@affine/component": "workspace:*",
"@affine/i18n": "workspace:*",
"@storybook/addon-actions": "^7.0.18",
"@storybook/addon-essentials": "^7.0.18",
"@storybook/addon-interactions": "^7.0.18",
"@storybook/addon-links": "^7.0.18",
"@storybook/addon-storysource": "^7.0.18",
"@storybook/blocks": "^7.0.18",
"@storybook/builder-vite": "^7.0.18",
"@storybook/jest": "^0.1.0",
"@storybook/react": "^7.0.18",
"@storybook/react-vite": "^7.0.18",
"@storybook/test-runner": "^0.10.0",
"@storybook/testing-library": "^0.1.0",
"@vitejs/plugin-react": "^4.0.0",
"concurrently": "^8.1.0",
"jest-mock": "^29.5.0",
"serve": "^14.2.0",
"storybook": "^7.0.18",
"storybook-dark-mode": "^3.0.0",
"wait-on": "^7.0.1"
},
"devDependencies": {
"@blocksuite/blocks": "0.0.0-20230601122821-16196c35-nightly",
"@blocksuite/editor": "0.0.0-20230601122821-16196c35-nightly",
"@blocksuite/global": "0.0.0-20230601122821-16196c35-nightly",
"@blocksuite/icons": "^2.1.19",
"@blocksuite/lit": "0.0.0-20230601122821-16196c35-nightly",
"@blocksuite/store": "0.0.0-20230601122821-16196c35-nightly",
"react": "18.3.0-canary-16d053d59-20230506",
"react-dom": "18.3.0-canary-16d053d59-20230506"
},
"peerDependencies": {
"@blocksuite/blocks": "*",
"@blocksuite/editor": "*",
"@blocksuite/global": "*",
"@blocksuite/icons": "*",
"@blocksuite/lit": "*",
"@blocksuite/store": "*"
}
}

View File

@@ -0,0 +1,36 @@
import { BrowserWarning, DownloadTips } from '@affine/component/affine-banner';
import type { StoryFn } from '@storybook/react';
import { useState } from 'react';
export default {
title: 'AFFiNE/Banner',
component: BrowserWarning,
};
export const Default: StoryFn = () => {
const [closed, setIsClosed] = useState(true);
return (
<div>
<BrowserWarning
message={<span>test</span>}
show={closed}
onClose={() => {
setIsClosed(false);
}}
/>
</div>
);
};
export const Download: StoryFn = () => {
const [, setIsClosed] = useState(true);
return (
<div>
<DownloadTips
onClose={() => {
setIsClosed(false);
}}
/>
</div>
);
};

View File

@@ -0,0 +1,24 @@
import { AffineLoading } from '@affine/component/affine-loading';
import type { StoryFn } from '@storybook/react';
export default {
title: 'AFFiNE/Loading',
component: AffineLoading,
};
export const Default: StoryFn = ({ width, loop, autoplay, autoReverse }) => (
<div
style={{
width: width,
height: width,
}}
>
<AffineLoading loop={loop} autoplay={autoplay} autoReverse={autoReverse} />
</div>
);
Default.args = {
width: 100,
loop: true,
autoplay: true,
autoReverse: true,
};

View File

@@ -0,0 +1,162 @@
import { IconButton } from '@affine/component';
import {
AppSidebar,
AppSidebarFallback,
appSidebarOpenAtom,
} from '@affine/component/app-sidebar';
import { AddPageButton } from '@affine/component/app-sidebar';
import { CategoryDivider } from '@affine/component/app-sidebar';
import {
navHeaderStyle,
sidebarButtonStyle,
} from '@affine/component/app-sidebar';
import { MenuLinkItem } from '@affine/component/app-sidebar';
import { QuickSearchInput } from '@affine/component/app-sidebar';
import {
SidebarContainer,
SidebarScrollableContainer,
} from '@affine/component/app-sidebar';
import {
DeleteTemporarilyIcon,
SettingsIcon,
SidebarIcon,
} from '@blocksuite/icons';
import type { Meta, StoryFn } from '@storybook/react';
import { useAtom } from 'jotai';
import { type PropsWithChildren, useState } from 'react';
export default {
title: 'Components/AppSidebar',
component: AppSidebar,
} satisfies Meta;
const Container = ({ children }: PropsWithChildren) => (
<main
style={{
position: 'relative',
width: '100vw',
height: 'calc(100vh - 40px)',
overflow: 'hidden',
display: 'flex',
flexDirection: 'row',
}}
>
{children}
</main>
);
const Main = () => {
const [open, setOpen] = useAtom(appSidebarOpenAtom);
return (
<div>
<div className={navHeaderStyle}>
{!open && (
<IconButton
className={sidebarButtonStyle}
onClick={() => {
setOpen(true);
}}
>
<SidebarIcon width={24} height={24} />
</IconButton>
)}
</div>
</div>
);
};
export const Default: StoryFn = () => {
return (
<>
<Container>
<AppSidebar />
<Main />
</Container>
</>
);
};
export const Fallback = () => {
return (
<Container>
<AppSidebarFallback />
<Main />
</Container>
);
};
export const WithItems: StoryFn = () => {
const [collapsed, setCollapsed] = useState(false);
return (
<Container>
<AppSidebar>
<SidebarContainer>
<QuickSearchInput />
<div style={{ height: '20px' }} />
<MenuLinkItem
icon={<SettingsIcon />}
href="/test"
onClick={() => alert('opened')}
>
Settings
</MenuLinkItem>
<MenuLinkItem
icon={<SettingsIcon />}
href="/test"
onClick={() => alert('opened')}
>
Settings
</MenuLinkItem>
<MenuLinkItem
icon={<SettingsIcon />}
href="/test"
onClick={() => alert('opened')}
>
Settings
</MenuLinkItem>
</SidebarContainer>
<SidebarScrollableContainer>
<CategoryDivider label="Favorites" />
<MenuLinkItem
collapsed={collapsed}
onCollapsedChange={setCollapsed}
icon={<SettingsIcon />}
href="/test"
onClick={() => alert('opened')}
>
Collapsible Item
</MenuLinkItem>
<MenuLinkItem
collapsed={!collapsed}
onCollapsedChange={setCollapsed}
icon={<SettingsIcon />}
href="/test"
onClick={() => alert('opened')}
>
Collapsible Item
</MenuLinkItem>
<MenuLinkItem
icon={<SettingsIcon />}
href="/test"
onClick={() => alert('opened')}
>
Settings
</MenuLinkItem>
<CategoryDivider label="Others" />
<MenuLinkItem
icon={<DeleteTemporarilyIcon />}
href="/test"
onClick={() => alert('opened')}
>
Trash
</MenuLinkItem>
</SidebarScrollableContainer>
<SidebarContainer>
<AddPageButton />
</SidebarContainer>
</AppSidebar>
<Main />
</Container>
);
};

View File

@@ -0,0 +1,125 @@
/* deepscan-disable USELESS_ARROW_FUNC_BIND */
import type { EditorProps } from '@affine/component/block-suite-editor';
import { BlockSuiteEditor } from '@affine/component/block-suite-editor';
import { __unstableSchemas, AffineSchemas } from '@blocksuite/blocks/models';
import type { EditorContainer } from '@blocksuite/editor';
import type { Page } from '@blocksuite/store';
import { createMemoryStorage, Workspace } from '@blocksuite/store';
import { expect } from '@storybook/jest';
import type { Meta, StoryFn } from '@storybook/react';
import { useState } from 'react';
const blockSuiteWorkspace = new Workspace({
id: 'test',
blobStorages: [createMemoryStorage],
});
function initPage(page: Page): void {
// Add page block and surface block at root level
const pageBlockId = page.addBlock('affine:page', {
title: new page.Text('Hello, world!'),
});
page.addBlock('affine:surface', {}, pageBlockId);
const frameId = page.addBlock('affine:frame', {}, pageBlockId);
page.addBlock(
'affine:paragraph',
{
text: new page.Text('This is a paragraph.'),
},
frameId
);
page.resetHistory();
}
blockSuiteWorkspace.register(AffineSchemas).register(__unstableSchemas);
const page = blockSuiteWorkspace.createPage('page0');
initPage(page);
type BlockSuiteMeta = Meta<typeof BlockSuiteEditor>;
export default {
title: 'BlockSuite/Editor',
component: BlockSuiteEditor,
} satisfies BlockSuiteMeta;
const Template: StoryFn<EditorProps> = (props: Partial<EditorProps>) => {
return (
<div
style={{
height: '100vh',
width: '100vw',
overflow: 'auto',
}}
>
<BlockSuiteEditor onInit={initPage} page={page} mode="page" {...props} />
<div
style={{
position: 'absolute',
right: 12,
bottom: 12,
}}
id="toolWrapper"
/>
</div>
);
};
export const Empty = Template.bind({});
Empty.play = async ({ canvasElement }) => {
await new Promise<void>(resolve => {
setTimeout(() => resolve(), 500);
});
const editorContainer = canvasElement.querySelector(
'[data-testid="editor-page0"]'
) as HTMLDivElement;
expect(editorContainer).not.toBeNull();
const editor = editorContainer.querySelector(
'editor-container'
) as EditorContainer;
expect(editor).not.toBeNull();
};
Empty.args = {
mode: 'page',
};
export const Error: StoryFn = () => {
const [props, setProps] = useState<Pick<EditorProps, 'page' | 'onInit'>>({
page: null!,
onInit: null!,
});
return (
<BlockSuiteEditor
{...props}
mode="page"
onReset={() => {
setProps({
page,
onInit: initPage,
});
}}
/>
);
};
Error.play = async ({ canvasElement }) => {
{
const editorContainer = canvasElement.querySelector(
'[data-testid="editor-page0"]'
);
expect(editorContainer).toBeNull();
}
{
const button = canvasElement.querySelector(
'[data-testid="error-fallback-reset-button"]'
) as HTMLButtonElement;
expect(button).not.toBeNull();
button.click();
await new Promise<void>(resolve => setTimeout(() => resolve(), 50));
}
{
const editorContainer = canvasElement.querySelector(
'[data-testid="editor-page0"]'
);
expect(editorContainer).not.toBeNull();
}
};

View File

@@ -0,0 +1,38 @@
/* deepscan-disable USELESS_ARROW_FUNC_BIND */
import { Breadcrumbs } from '@affine/component';
import { Link, Typography } from '@mui/material';
import { expect } from '@storybook/jest';
import type { Meta, StoryFn } from '@storybook/react';
import { within } from '@storybook/testing-library';
export default {
title: 'AFFiNE/Breadcrumbs',
component: Breadcrumbs,
} as Meta<typeof Breadcrumbs>;
const Template: StoryFn = args => <Breadcrumbs {...args} />;
export const Primary = Template.bind(undefined);
Primary.play = async ({ canvasElement }) => {
const canvas = within(canvasElement);
const text = canvas.getByText('AFFiNE');
expect(text.getAttribute('data-testid')).toBe('affine');
};
Primary.args = {
children: [
<Link
data-testid="affine"
key="1"
underline="hover"
color="inherit"
href="/"
>
AFFiNE
</Link>,
<Link key="2" underline="hover" color="inherit" href="/Docs/">
Docs
</Link>,
<Typography key="3" color="text.primary">
Introduction
</Typography>,
],
};

View File

@@ -0,0 +1,137 @@
/* deepscan-disable USELESS_ARROW_FUNC_BIND */
import type { ButtonProps } from '@affine/component';
import { Button } from '@affine/component';
import { DropdownButton } from '@affine/component';
import { RadioButton, RadioButtonGroup } from '@affine/component';
import { Menu } from '@affine/component';
import { toast } from '@affine/component';
import type { Meta, StoryFn } from '@storybook/react';
import { useState } from 'react';
export default {
title: 'AFFiNE/Button',
component: Button,
argTypes: {
hoverBackground: { control: 'color' },
hoverColor: { control: 'color' },
},
} as Meta<ButtonProps>;
const Template: StoryFn<ButtonProps> = args => <Button {...args} />;
export const Primary = Template.bind(undefined);
Primary.args = {
type: 'primary',
children: 'This is a primary button',
onClick: () => toast('Click button'),
};
export const Default = Template.bind(undefined);
Default.args = {
type: 'default',
children: 'This is a default button',
onClick: () => toast('Click button'),
};
export const Light = Template.bind(undefined);
Light.args = {
type: 'light',
children: 'This is a light button',
onClick: () => toast('Click button'),
};
export const Warning = Template.bind(undefined);
Warning.args = {
type: 'warning',
children: 'This is a warning button',
onClick: () => toast('Click button'),
};
export const Danger = Template.bind(undefined);
Danger.args = {
type: 'danger',
children: 'This is a danger button',
onClick: () => toast('Click button'),
};
export const Dropdown: StoryFn = ({ onClickDropDown, ...props }) => {
const [open, setOpen] = useState(false);
return (
<>
<DropdownButton onClickDropDown={onClickDropDown} {...props}>
Dropdown Button
</DropdownButton>
<Menu
visible={open}
placement="bottom-end"
trigger={['click']}
width={235}
disablePortal={true}
onClickAway={() => {
setOpen(false);
}}
content={<>Dropdown Menu</>}
>
<DropdownButton
onClick={() => {
toast('Click button');
setOpen(false);
}}
onClickDropDown={() => setOpen(!open)}
>
Dropdown with Menu
</DropdownButton>
</Menu>
</>
);
};
Dropdown.args = {
onClick: () => toast('Click button'),
onClickDropDown: () => toast('Click dropdown'),
};
export const RadioGroup: StoryFn = ({ ...props }) => {
return (
<>
<RadioButtonGroup {...props}>
<RadioButton value="all">All</RadioButton>
<RadioButton value="page">Page</RadioButton>
<RadioButton value="edgeless">Edgeless</RadioButton>
</RadioButtonGroup>
<RadioButtonGroup {...props}>
<RadioButton value="all">Long long text and some more</RadioButton>
<RadioButton value="page">Lorem ipsum dolor sit amet</RadioButton>
<RadioButton value="edgeless">Long long text</RadioButton>
</RadioButtonGroup>
</>
);
};
RadioGroup.args = {
defaultValue: 'all',
onValueChange: (value: string) => toast(`Radio value: ${value}`),
};
export const Test: StoryFn<ButtonProps> = () => {
const [input, setInput] = useState('');
return (
<>
<input
type="text"
data-testid="test-input"
value={input}
onChange={e => setInput(e.target.value)}
/>
<Button
onClick={() => {
setInput('');
}}
data-testid="clear-button"
>
clear
</Button>
</>
);
};
Test.storyName = 'Click Test';

View File

@@ -0,0 +1,54 @@
import { toast } from '@affine/component';
import { BlockCard } from '@affine/component/card/block-card';
import { WorkspaceCard } from '@affine/component/card/workspace-card';
import { WorkspaceFlavour } from '@affine/workspace/type';
import { EdgelessIcon, PageIcon } from '@blocksuite/icons';
import { Workspace } from '@blocksuite/store';
export default {
title: 'AFFiNE/Card',
component: WorkspaceCard,
};
const blockSuiteWorkspace = new Workspace({
id: 'blocksuite-local',
});
blockSuiteWorkspace.meta.setName('Hello World');
export const AffineWorkspaceCard = () => {
return (
<WorkspaceCard
workspace={{
flavour: WorkspaceFlavour.LOCAL,
id: 'local',
blockSuiteWorkspace,
providers: [],
}}
onClick={() => {}}
onSettingClick={() => {}}
currentWorkspaceId={null}
/>
);
};
export const AffineBlockCard = () => {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<BlockCard title={'New Page'} onClick={() => toast('clicked')} />
<BlockCard
title={'New Page'}
desc={'Write with a blank page'}
right={<PageIcon width={20} height={20} />}
onClick={() => toast('clicked page')}
/>
<BlockCard
title={'New Edgeless'}
desc={'Draw with a blank whiteboard'}
left={<PageIcon width={20} height={20} />}
right={<EdgelessIcon width={20} height={20} />}
onClick={() => toast('clicked edgeless')}
/>
</div>
);
};

View File

@@ -0,0 +1,47 @@
import { ChangeLog } from '@affine/component/changeLog';
import type { StoryFn } from '@storybook/react';
import { within } from '@storybook/testing-library';
import { useState } from 'react';
export default {
title: 'AFFiNE/ChangeLog',
component: ChangeLog,
};
export const Default: StoryFn = () => (
<div
style={{
width: '256px',
height: '100vh',
}}
>
<ChangeLog onCloseWhatsNew={() => {}} />
</div>
);
export const Close: StoryFn = () => {
const [closed, setIsClosed] = useState(false);
return (
<>
<div>Closed: {closed ? 'true' : 'false'}</div>
<div
style={{
width: '256px',
height: '100vh',
}}
>
<ChangeLog
onCloseWhatsNew={() => {
setIsClosed(true);
}}
/>
</div>
</>
);
};
Close.play = async ({ canvasElement }) => {
const element = within(canvasElement);
await new Promise(resolve => setTimeout(resolve, 2000));
element.getByTestId('change-log-close-button').click();
};

View File

@@ -0,0 +1,35 @@
import { Button } from '@affine/component';
import type { ContactModalProps } from '@affine/component/contact-modal';
import { ContactModal } from '@affine/component/contact-modal';
import type { StoryFn } from '@storybook/react';
import { useState } from 'react';
export default {
title: 'AFFiNE/ContactModal',
component: ContactModal,
};
export const Basic: StoryFn<ContactModalProps> = args => {
const [open, setOpen] = useState(false);
return (
<>
<Button
onClick={() => {
setOpen(true);
}}
>
Open
</Button>
<ContactModal
{...args}
open={open}
onClose={() => {
setOpen(false);
}}
/>
</>
);
};
Basic.args = {
logoSrc: '/imgs/affine-text-logo.png',
};

View File

@@ -0,0 +1,64 @@
import { BlockSuiteEditor } from '@affine/component/block-suite-editor';
import { ImagePreviewModal } from '@affine/component/image-preview-modal';
import { initPage } from '@affine/env/blocksuite';
import { WorkspaceFlavour } from '@affine/workspace/type';
import { createEmptyBlockSuiteWorkspace } from '@affine/workspace/utils';
import type { Meta } from '@storybook/react';
export default {
title: 'Component/ImagePreviewModal',
component: ImagePreviewModal,
} satisfies Meta;
const workspace = createEmptyBlockSuiteWorkspace(
'test',
WorkspaceFlavour.LOCAL
);
const page = workspace.createPage('page0');
initPage(page);
fetch(new URL('@affine-test/fixtures/large-image.png', import.meta.url))
.then(res => res.arrayBuffer())
.then(async buffer => {
const id = await workspace.blobs.set(
new Blob([buffer], { type: 'image/png' })
);
const frameId = page.getBlockByFlavour('affine:frame')[0].id;
page.addBlock(
'affine:paragraph',
{
text: new page.Text('Please double click the image to preview it.'),
},
frameId
);
page.addBlock(
'affine:embed',
{
sourceId: id,
},
frameId
);
});
export const Default = () => {
return (
<>
<div
style={{
height: '100vh',
width: '100vw',
overflow: 'auto',
}}
>
<BlockSuiteEditor mode="page" page={page} onInit={initPage} />
</div>
<div
style={{
position: 'absolute',
right: 12,
bottom: 12,
}}
id="toolWrapper"
/>
</>
);
};

View File

@@ -0,0 +1,18 @@
import { Meta } from '@storybook/blocks';
<Meta title="Introduction" />
# AFFiNE UI Storybook
This is a UI component dev environment for AFFiNE UI.
It allows you to browse the AFFiNE UI components,
view the different states of each component,
and interactively develop and test components.
## Bug Reporting
If you find a bug, please file an issue on [GitHub](https://github.com/toeverything/AFFiNE/issues)
## Contributing
We welcome contributions from the community! [Get started here](https://github.com/toeverything/AFFiNE/blob/master/docs/BUILDING.md)

View File

@@ -0,0 +1,202 @@
import {
expandNotificationCenterAtom,
NotificationCenter,
pushNotificationAtom,
} from '@affine/component/notification-center';
import type { Meta } from '@storybook/react';
import { useAtomValue, useSetAtom } from 'jotai';
export default {
title: 'AFFiNE/NotificationCenter',
component: NotificationCenter,
} satisfies Meta<typeof NotificationCenter>;
let id = 0;
export const Basic = () => {
const push = useSetAtom(pushNotificationAtom);
const expand = useAtomValue(expandNotificationCenterAtom);
return (
<>
<div>{expand ? 'expanded' : 'collapsed'}</div>
<div>
<button
onClick={() => {
const key = id++;
push({
key: `${key}`,
title: `${key} title`,
message: `${key} message`,
timeout: 3000,
progressingBar: true,
undo: async () => {
console.log('undo');
},
type: 'info',
});
}}
>
Push timeout notification
</button>
</div>
<div>
<button
onClick={() => {
const key = id++;
push({
key: `${key}`,
title: `${key} title`,
message: `${key} message`,
type: 'info',
});
}}
>
Push notification with no timeout
</button>
</div>
<div>
<button
onClick={() => {
const key = id++;
push({
key: `${key}`,
title: `${key} title`,
message: ``,
type: 'info',
});
}}
>
Push notification with no message
</button>
</div>
<div>
<button
onClick={() => {
const key = id++;
push({
key: `${key}`,
title: `${key} title`,
message: ``,
type: 'success',
theme: 'light',
});
}}
>
light success
</button>
</div>
<div>
<button
onClick={() => {
const key = id++;
push({
key: `${key}`,
title: `${key} title`,
message: ``,
type: 'success',
theme: 'dark',
});
}}
>
dark success
</button>
</div>
<div>
<button
onClick={() => {
const key = id++;
push({
key: `${key}`,
title: `${key} title`,
message: ``,
type: 'info',
theme: 'light',
});
}}
>
light info
</button>
</div>
<div>
<button
onClick={() => {
const key = id++;
push({
key: `${key}`,
title: `${key} title`,
message: ``,
type: 'info',
theme: 'dark',
});
}}
>
dark info
</button>
</div>
<div>
<button
onClick={() => {
const key = id++;
push({
key: `${key}`,
title: `${key} title`,
message: ``,
type: 'warning',
theme: 'light',
});
}}
>
light warning
</button>
</div>
<div>
<button
onClick={() => {
const key = id++;
push({
key: `${key}`,
title: `${key} title`,
message: ``,
type: 'warning',
theme: 'dark',
});
}}
>
dark warning
</button>
</div>
<div>
<button
onClick={() => {
const key = id++;
push({
key: `${key}`,
title: `${key} title`,
message: ``,
type: 'error',
theme: 'light',
});
}}
>
light error
</button>
</div>
<div>
<button
onClick={() => {
const key = id++;
push({
key: `${key}`,
title: `${key} title`,
message: ``,
type: 'error',
theme: 'dark',
});
}}
>
dark error
</button>
</div>
<NotificationCenter />
</>
);
};

View File

@@ -0,0 +1,15 @@
/* deepscan-disable USELESS_ARROW_FUNC_BIND */
import { TourModal } from '@affine/component/tour-modal';
import type { StoryFn } from '@storybook/react';
export default {
title: 'AFFiNE/TourModal',
component: TourModal,
};
export const Basic: StoryFn = () => {
return <TourModal open={true} onClose={() => {}} />;
};
Basic.args = {
logoSrc: '/imgs/affine-text-logo.png',
};

View File

@@ -0,0 +1,11 @@
import { PageDetailSkeleton } from '@affine/component/page-detail-skeleton';
import type { Meta } from '@storybook/react';
export default {
title: 'AFFiNE/PageDetailSkeleton',
component: PageDetailSkeleton,
} satisfies Meta<typeof PageDetailSkeleton>;
export const Basic = () => {
return <PageDetailSkeleton />;
};

View File

@@ -0,0 +1,190 @@
import { Empty } from '@affine/component';
import { toast } from '@affine/component';
import { AffineLoading } from '@affine/component/affine-loading';
import { PageListTrashView } from '@affine/component/page-list/all-page';
import { PageList } from '@affine/component/page-list/all-page';
import { NewPageButton } from '@affine/component/page-list/components/new-page-buttton';
import type { OperationCellProps } from '@affine/component/page-list/operation-cell';
import { OperationCell } from '@affine/component/page-list/operation-cell';
import { PageIcon } from '@blocksuite/icons';
import { expect } from '@storybook/jest';
import type { StoryFn } from '@storybook/react';
import { userEvent } from '@storybook/testing-library';
export default {
title: 'AFFiNE/PageList',
component: AffineLoading,
};
export const AffineOperationCell: StoryFn<OperationCellProps> = ({
...props
}) => <OperationCell {...props} />;
AffineOperationCell.args = {
title: 'Example Page',
favorite: false,
isPublic: true,
onToggleFavoritePage: () => toast('Toggle favorite page'),
onDisablePublicSharing: () => toast('Disable public sharing'),
onOpenPageInNewTab: () => toast('Open page in new tab'),
onRemoveToTrash: () => toast('Remove to trash'),
};
AffineOperationCell.play = async ({ canvasElement }) => {
{
const button = canvasElement.querySelector(
'[data-testid="page-list-operation-button"]'
) as HTMLButtonElement;
expect(button).not.toBeNull();
userEvent.click(button);
}
};
export const AffineNewPageButton: StoryFn<typeof NewPageButton> = ({
...props
}) => <NewPageButton {...props} />;
AffineNewPageButton.args = {
createNewPage: () => toast('Create new page'),
createNewEdgeless: () => toast('Create new edgeless'),
};
AffineNewPageButton.play = async ({ canvasElement }) => {
const button = canvasElement.querySelector('button') as HTMLButtonElement;
expect(button).not.toBeNull();
const dropdown = button.querySelector('svg') as SVGSVGElement;
expect(dropdown).not.toBeNull();
userEvent.click(dropdown);
};
export const AffineAllPageList: StoryFn<typeof PageList> = ({ ...props }) => (
<PageList {...props} />
);
AffineAllPageList.args = {
isPublicWorkspace: false,
onCreateNewPage: () => toast('Create new page'),
onCreateNewEdgeless: () => toast('Create new edgeless'),
onImportFile: () => toast('Import file'),
list: [
{
pageId: '1',
favorite: false,
icon: <PageIcon />,
isPublicPage: true,
title: 'Today Page',
preview: 'this is page preview',
createDate: new Date(),
updatedDate: new Date(),
bookmarkPage: () => toast('Bookmark page'),
onClickPage: () => toast('Click page'),
onDisablePublicSharing: () => toast('Disable public sharing'),
onOpenPageInNewTab: () => toast('Open page in new tab'),
removeToTrash: () => toast('Remove to trash'),
},
{
pageId: '3',
favorite: false,
icon: <PageIcon />,
isPublicPage: true,
title:
'1 Example Public Page with long title that will be truncated because it is too too long',
preview:
'this is page preview and it is very long and will be truncated because it is too long and it is very long and will be truncated because it is too long',
createDate: new Date('2021-01-01'),
updatedDate: new Date('2021-01-02'),
bookmarkPage: () => toast('Bookmark page'),
onClickPage: () => toast('Click page'),
onDisablePublicSharing: () => toast('Disable public sharing'),
onOpenPageInNewTab: () => toast('Open page in new tab'),
removeToTrash: () => toast('Remove to trash'),
},
{
pageId: '2',
favorite: true,
isPublicPage: false,
icon: <PageIcon />,
title: '2 Favorited Page 2021',
createDate: new Date('2021-01-02'),
updatedDate: new Date('2021-01-01'),
bookmarkPage: () => toast('Bookmark page'),
onClickPage: () => toast('Click page'),
onDisablePublicSharing: () => toast('Disable public sharing'),
onOpenPageInNewTab: () => toast('Open page in new tab'),
removeToTrash: () => toast('Remove to trash'),
},
{
pageId: '4',
favorite: false,
isPublicPage: false,
icon: <PageIcon />,
title: 'page created in 2023-04-01',
createDate: new Date('2023-04-01'),
updatedDate: new Date('2023-04-01'),
bookmarkPage: () => toast('Bookmark page'),
onClickPage: () => toast('Click page'),
onDisablePublicSharing: () => toast('Disable public sharing'),
onOpenPageInNewTab: () => toast('Open page in new tab'),
removeToTrash: () => toast('Remove to trash'),
},
],
};
export const AffineEmptyAllPageList: StoryFn<typeof PageList> = ({
...props
}) => <PageList {...props} />;
AffineEmptyAllPageList.args = {
isPublicWorkspace: false,
onCreateNewPage: () => toast('Create new page'),
onCreateNewEdgeless: () => toast('Create new edgeless'),
onImportFile: () => toast('Import file'),
list: [],
fallback: <Empty description="empty description" />,
};
export const AffinePublicPageList: StoryFn<typeof PageList> = ({
...props
}) => <PageList {...props} />;
AffinePublicPageList.args = {
...AffineAllPageList.args,
isPublicWorkspace: true,
};
export const AffineAllPageMobileList: StoryFn<typeof PageList> = ({
...props
}) => <PageList {...props} />;
AffineAllPageMobileList.args = AffineAllPageList.args;
AffineAllPageMobileList.parameters = {
viewport: {
defaultViewport: 'mobile2',
},
};
export const AffineTrashPageList: StoryFn<typeof PageListTrashView> = ({
...props
}) => <PageListTrashView {...props} />;
AffineTrashPageList.args = {
list: [
{
pageId: '1',
icon: <PageIcon />,
title: 'Example Page',
preview: 'this is trash page preview',
createDate: new Date('2021-01-01'),
trashDate: new Date('2021-01-01'),
onClickPage: () => toast('Click page'),
onPermanentlyDeletePage: () => toast('Permanently delete page'),
onRestorePage: () => toast('Restore page'),
},
{
pageId: '2',
icon: <PageIcon />,
title: 'Example Page with long title that will be truncated',
createDate: new Date('2021-01-01'),
onClickPage: () => toast('Click page'),
onPermanentlyDeletePage: () => toast('Permanently delete page'),
onRestorePage: () => toast('Restore page'),
},
],
};

View File

@@ -0,0 +1,129 @@
import { toast } from '@affine/component';
import { PublicLinkDisableModal } from '@affine/component/share-menu/disable-public-link';
import { ShareMenu } from '@affine/component/share-menu/share-menu';
import { StyledDisableButton } from '@affine/component/share-menu/styles';
import { PermissionType, WorkspaceType } from '@affine/workspace/affine/api';
import type {
AffineLegacyCloudWorkspace,
LocalWorkspace,
} from '@affine/workspace/type';
import { WorkspaceFlavour } from '@affine/workspace/type';
import { createEmptyBlockSuiteWorkspace } from '@affine/workspace/utils';
import type { Page } from '@blocksuite/store';
import { expect } from '@storybook/jest';
import type { StoryFn } from '@storybook/react';
import { useState } from 'react';
export default {
title: 'AFFiNE/ShareMenu',
component: ShareMenu,
};
function initPage(page: Page): void {
// Add page block and surface block at root level
const pageBlockId = page.addBlock('affine:page', {
title: new page.Text('Hello, world!'),
});
page.addBlock('affine:surface', {}, pageBlockId);
const frameId = page.addBlock('affine:frame', {}, pageBlockId);
page.addBlock(
'affine:paragraph',
{
text: new page.Text('This is a paragraph.'),
},
frameId
);
page.resetHistory();
}
const blockSuiteWorkspace = createEmptyBlockSuiteWorkspace(
'test-workspace',
WorkspaceFlavour.LOCAL
);
initPage(blockSuiteWorkspace.createPage({ id: 'page0' }));
initPage(blockSuiteWorkspace.createPage({ id: 'page1' }));
initPage(blockSuiteWorkspace.createPage({ id: 'page2' }));
const localWorkspace: LocalWorkspace = {
id: 'test-workspace',
flavour: WorkspaceFlavour.LOCAL,
blockSuiteWorkspace,
providers: [],
};
const affineWorkspace: AffineLegacyCloudWorkspace = {
id: 'test-workspace',
flavour: WorkspaceFlavour.AFFINE,
blockSuiteWorkspace,
providers: [],
public: false,
type: WorkspaceType.Normal,
permission: PermissionType.Owner,
};
async function unimplemented() {
toast('work in progress');
}
export const Basic: StoryFn = () => {
return (
<ShareMenu
currentPage={blockSuiteWorkspace.getPage('page0') as Page}
workspace={localWorkspace}
onEnableAffineCloud={unimplemented}
onOpenWorkspaceSettings={unimplemented}
togglePagePublic={unimplemented}
toggleWorkspacePublish={unimplemented}
/>
);
};
Basic.play = async ({ canvasElement }) => {
{
const button = canvasElement.querySelector(
'[data-testid="share-menu-button"]'
) as HTMLButtonElement;
expect(button).not.toBeNull();
button.click();
}
await new Promise(resolve => setTimeout(resolve, 100));
{
const button = canvasElement.querySelector(
'[data-testid="share-menu-enable-affine-cloud-button"]'
);
expect(button).not.toBeNull();
}
};
export const AffineBasic: StoryFn = () => {
return (
<ShareMenu
currentPage={blockSuiteWorkspace.getPage('page0') as Page}
workspace={affineWorkspace}
onEnableAffineCloud={unimplemented}
onOpenWorkspaceSettings={unimplemented}
togglePagePublic={unimplemented}
toggleWorkspacePublish={unimplemented}
/>
);
};
export const DisableModal: StoryFn = () => {
const [open, setOpen] = useState(false);
return (
<>
<StyledDisableButton onClick={() => setOpen(!open)}>
Disable Public Link
</StyledDisableButton>
<PublicLinkDisableModal
open={open}
onConfirmDisable={() => {
toast('Disabled');
setOpen(false);
}}
onClose={() => setOpen(false)}
/>
</>
);
};

View File

@@ -0,0 +1,12 @@
/* deepscan-disable USELESS_ARROW_FUNC_BIND */
import { Switch } from '@affine/component';
import type { StoryFn } from '@storybook/react';
export default {
title: 'AFFiNE/Switch',
component: Switch,
};
export const Basic: StoryFn = () => {
return <Switch />;
};

View File

@@ -0,0 +1,83 @@
import type { WorkspaceAvatarProps } from '@affine/component/workspace-avatar';
import { WorkspaceAvatar } from '@affine/component/workspace-avatar';
import { WorkspaceFlavour } from '@affine/workspace/type';
import { Workspace } from '@blocksuite/store';
import type { Meta, StoryFn } from '@storybook/react';
export default {
title: 'AFFiNE/WorkspaceAvatar',
component: WorkspaceAvatar,
argTypes: {
size: {
control: {
type: 'range',
min: 20,
max: 100,
},
},
},
} satisfies Meta<WorkspaceAvatarProps>;
const basicBlockSuiteWorkspace = new Workspace({
id: 'blocksuite-local',
});
basicBlockSuiteWorkspace.meta.setName('Hello World');
export const Basic: StoryFn<WorkspaceAvatarProps> = props => {
return (
<WorkspaceAvatar
{...props}
workspace={{
flavour: WorkspaceFlavour.LOCAL,
id: 'local',
blockSuiteWorkspace: basicBlockSuiteWorkspace,
providers: [],
}}
/>
);
};
Basic.args = {
size: 40,
};
const avatarBlockSuiteWorkspace = new Workspace({
id: 'blocksuite-local',
});
avatarBlockSuiteWorkspace.meta.setName('Hello World');
fetch(new URL('@affine-test/fixtures/smile.png', import.meta.url))
.then(res => res.arrayBuffer())
.then(async buffer => {
const id = await avatarBlockSuiteWorkspace.blobs.set(
new Blob([buffer], { type: 'image/png' })
);
avatarBlockSuiteWorkspace.meta.setAvatar(id);
});
export const BlobExample: StoryFn<WorkspaceAvatarProps> = props => {
return (
<WorkspaceAvatar
{...props}
workspace={{
flavour: WorkspaceFlavour.LOCAL,
id: 'local',
blockSuiteWorkspace: avatarBlockSuiteWorkspace,
providers: [],
}}
/>
);
};
BlobExample.args = {
size: 40,
};
export const Empty: StoryFn<WorkspaceAvatarProps> = props => {
return <WorkspaceAvatar {...props} workspace={null} />;
};
Empty.args = {
size: 40,
};

View File

@@ -0,0 +1,72 @@
import type { WorkspaceListProps } from '@affine/component/workspace-list';
import { WorkspaceList } from '@affine/component/workspace-list';
import { WorkspaceFlavour } from '@affine/workspace/type';
import { createEmptyBlockSuiteWorkspace } from '@affine/workspace/utils';
import { arrayMove } from '@dnd-kit/sortable';
import type { Meta } from '@storybook/react';
import { useState } from 'react';
export default {
title: 'AFFiNE/WorkspaceList',
component: WorkspaceList,
} satisfies Meta<WorkspaceListProps>;
export const Default = () => {
const [items, setItems] = useState(() => {
const items = [
{
id: '1',
flavour: WorkspaceFlavour.LOCAL,
blockSuiteWorkspace: createEmptyBlockSuiteWorkspace(
'1',
WorkspaceFlavour.LOCAL
),
providers: [],
},
{
id: '2',
flavour: WorkspaceFlavour.LOCAL,
blockSuiteWorkspace: createEmptyBlockSuiteWorkspace(
'2',
WorkspaceFlavour.LOCAL
),
providers: [],
},
{
id: '3',
flavour: WorkspaceFlavour.LOCAL,
blockSuiteWorkspace: createEmptyBlockSuiteWorkspace(
'3',
WorkspaceFlavour.LOCAL
),
providers: [],
},
] satisfies WorkspaceListProps['items'];
items.forEach(item => {
item.blockSuiteWorkspace.meta.setName(item.id);
});
return items;
});
return (
<WorkspaceList
currentWorkspaceId={null}
items={items}
onClick={() => {}}
onSettingClick={() => {}}
onDragEnd={event => {
const { active, over } = event;
if (active.id !== over?.id) {
setItems(items => {
const oldIndex = items.findIndex(item => item.id === active.id);
const newIndex = items.findIndex(item => item.id === over?.id);
return arrayMove(items, oldIndex, newIndex);
});
}
}}
/>
);
};

View File

@@ -0,0 +1,18 @@
{
"extends": "../../tsconfig.json",
"exclude": ["lib"],
"include": ["src"],
"compilerOptions": {
"composite": true,
"noEmit": false,
"outDir": "lib"
},
"references": [
{
"path": "../component"
},
{
"path": "./tsconfig.node.json"
}
]
}

View File

@@ -0,0 +1,16 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"composite": true,
"module": "ESNext",
"jsx": "react-jsx",
"moduleResolution": "Node",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"noEmit": false,
"outDir": "lib/.storybook"
},
"include": [".storybook/**/*"],
"exclude": ["lib"],
"references": [{ "path": "../i18n" }]
}