Merge pull request #147 from AppFlowy-IO/storybook

chore: create storybook to display error related UI components
This commit is contained in:
Nathan.fooo
2025-11-15 21:54:55 +08:00
committed by GitHub
13 changed files with 1331 additions and 39 deletions

View File

@@ -1,5 +1,6 @@
node_modules/
dist/
storybook-static/
.eslintrc.cjs
tsconfig.json
vite.config.ts

3
.gitignore vendored
View File

@@ -37,3 +37,6 @@ cypress/snapshots/**/__diff_output__/
cypress/screenshots
cypress/videos
.serena
*storybook.log
storybook-static

61
.storybook/main.ts Normal file
View File

@@ -0,0 +1,61 @@
import type { StorybookConfig } from '@storybook/react-vite';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const config: StorybookConfig = {
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
addons: [
'@chromatic-com/storybook',
'@storybook/addon-docs',
'@storybook/addon-onboarding',
'@storybook/addon-a11y',
'@storybook/addon-vitest',
],
framework: {
name: '@storybook/react-vite',
options: {},
},
typescript: {
reactDocgen: 'react-docgen-typescript',
reactDocgenTypescriptOptions: {
shouldExtractLiteralValuesFromEnum: true,
propFilter: (prop) => {
if (prop.parent) {
return !prop.parent.fileName.includes('node_modules');
}
return true;
},
shouldRemoveUndefinedFromOptional: true,
},
},
// Exclude plugin files and other non-component files from react-docgen
features: {
buildStoriesJson: true,
},
async viteFinal(config) {
if (config.resolve) {
const existingAlias = Array.isArray(config.resolve.alias)
? config.resolve.alias
: config.resolve.alias
? Object.entries(config.resolve.alias).map(([find, replacement]) => ({
find,
replacement: replacement as string,
}))
: [];
config.resolve.alias = [
...existingAlias,
{ find: 'src/', replacement: path.resolve(__dirname, '../src/') },
{ find: '@/', replacement: path.resolve(__dirname, '../src/') },
];
}
// PostCSS config is automatically picked up from postcss.config.cjs
// No need to configure it explicitly, but ensure CSS processing is enabled
return config;
},
};
export default config;

87
.storybook/preview.tsx Normal file
View File

@@ -0,0 +1,87 @@
import type { Preview } from '@storybook/react-vite';
import React, { useEffect } from 'react';
import { I18nextProvider } from 'react-i18next';
import { BrowserRouter } from 'react-router-dom';
import { AFConfigContext } from '@/components/main/app.hooks';
import '@/i18n/config';
import { i18nInstance } from '@/i18n/config';
import '@/styles/app.scss';
import '@/styles/global.css';
// Set dark mode attribute early, before React renders
if (typeof window !== 'undefined') {
const isDark = localStorage.getItem('dark-mode') === 'true' ||
window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.setAttribute('data-dark-mode', isDark ? 'true' : 'false');
}
// Mock AFConfigContext value for Storybook
const mockAFConfigValue = {
service: undefined,
isAuthenticated: true, // Set to true to prevent redirects in Storybook
currentUser: {
email: 'storybook@example.com',
name: 'Storybook User',
uid: 'storybook-uid',
avatar: null,
uuid: 'storybook-uuid',
latestWorkspaceId: 'storybook-workspace-id',
},
updateCurrentUser: async () => {
// Mock implementation
},
openLoginModal: () => {
// Mock implementation
console.log('Login modal would open here');
},
};
const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
layout: 'fullscreen',
},
decorators: [
(Story) => {
// Ensure dark mode is set on mount and watch for changes
useEffect(() => {
const updateDarkMode = () => {
const isDark = localStorage.getItem('dark-mode') === 'true' ||
window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.setAttribute('data-dark-mode', isDark ? 'true' : 'false');
};
updateDarkMode();
// Listen for system theme changes
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addEventListener('change', updateDarkMode);
return () => {
mediaQuery.removeEventListener('change', updateDarkMode);
};
}, []);
return (
<BrowserRouter>
<AFConfigContext.Provider value={mockAFConfigValue}>
<I18nextProvider i18n={i18nInstance}>
<div id="body" className="bg-background-primary text-text-primary" style={{ height: '100vh', width: '100%' }}>
<Story />
</div>
</I18nextProvider>
</AFConfigContext.Provider>
</BrowserRouter>
);
},
],
};
export default preview;

View File

@@ -25,7 +25,9 @@
"test:integration": "cypress run --spec 'cypress/e2e/**/*.cy.ts'",
"coverage": "cross-env COVERAGE=true pnpm run test:unit && cross-env COVERAGE=true pnpm run test:components",
"generate-tokens": "node scripts/system-token/convert-tokens.cjs",
"generate-protobuf": "pbjs -t static-module -w es6 -o ./src/proto/messages.js ./src/proto/messages.proto & pbts -o ./src/proto/messages.d.ts ./src/proto/messages.js"
"generate-protobuf": "pbjs -t static-module -w es6 -o ./src/proto/messages.js ./src/proto/messages.proto & pbts -o ./src/proto/messages.d.ts ./src/proto/messages.js",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
"dependencies": {
"@appflowyinc/editor": "^0.1.13",
@@ -172,9 +174,16 @@
"@babel/preset-env": "^7.24.7",
"@babel/preset-react": "^7.24.7",
"@babel/preset-typescript": "^7.24.7",
"@chromatic-com/storybook": "^4.1.2",
"@cypress/code-coverage": "^3.12.39",
"@istanbuljs/nyc-config-babel": "^3.0.0",
"@istanbuljs/nyc-config-typescript": "^1.0.2",
"@storybook/addon-a11y": "^10.0.7",
"@storybook/addon-docs": "^10.0.7",
"@storybook/addon-onboarding": "^10.0.7",
"@storybook/addon-vitest": "^10.0.7",
"@storybook/react": "^10.0.7",
"@storybook/react-vite": "^10.0.7",
"@svgr/plugin-svgo": "^8.0.1",
"@testing-library/react": "^16.0.0",
"@types/cypress-image-snapshot": "^3.1.9",
@@ -235,6 +244,7 @@
"prettier-plugin-tailwindcss": "^0.2.2",
"protobufjs-cli": "^1.1.3",
"rollup-plugin-visualizer": "^5.12.0",
"storybook": "^10.0.7",
"style-dictionary": "^3.9.2",
"tailwindcss": "^3.2.7",
"ts-jest": "^29.1.1",

799
pnpm-lock.yaml generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -3550,6 +3550,27 @@
"description": "You've made too many requests. Please wait a moment and try again.",
"retry": "Try again later"
},
"notInvitee": {
"title": "Access denied",
"description": "This invitation wasn't sent to your account. Please contact the sender for a new invitation.",
"goToHomepage": "Go to homepage"
},
"gone": {
"title": "Resource deleted",
"description": "This resource has been permanently deleted and is no longer available.",
"goToHomepage": "Go to homepage"
},
"timeout": {
"title": "Request timeout",
"description": "The request took too long to complete. Please check your connection and try again.",
"retry": "Try again"
},
"unknown": {
"title": "Something went wrong",
"description": "An unexpected error occurred. Please try again or contact support if the problem persists.",
"goToHomepage": "Go to homepage",
"retry": "Try again"
},
"asGuest": {
"title": "You can now collaborate with others on\n<page/>",
"viewPage": "View page",

View File

@@ -1,9 +1,9 @@
import { ReactEditor } from 'slate-react';
import { Range } from 'slate';
import { getBlockEntry } from '@/application/slate-yjs/utils/editor';
import { YjsEditor } from '@/application/slate-yjs';
import { isEmbedBlockTypes } from '@/application/slate-yjs/command/const';
import { getBlockEntry } from '@/application/slate-yjs/utils/editor';
import { BlockType } from '@/application/types';
import { Range } from 'slate';
import { ReactEditor } from 'slate-react';
export const clipboardFormatKey = 'x-appflowy-fragment';
@@ -35,7 +35,7 @@ export const withCopy = (editor: ReactEditor) => {
return;
}
setFragmentData(<DataTransfer>data);
setFragmentData(data as DataTransfer);
};
return editor;

View File

@@ -0,0 +1,234 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import React from 'react';
import { ErrorType } from '@/application/utils/error-utils';
import { AppContext } from '@/components/app/app.hooks';
import { AFConfigContext } from '@/components/main/app.hooks';
import RecordNotFound from './RecordNotFound';
const mockAppContext = {
currentWorkspaceId: 'test-workspace-id',
outline: [],
rendered: true,
// eslint-disable-next-line @typescript-eslint/no-empty-function
toView: async () => {},
loadViewMeta: async () => {
throw new Error('Not implemented in story');
},
loadView: async () => {
throw new Error('Not implemented in story');
},
createRowDoc: async () => {
throw new Error('Not implemented in story');
},
// eslint-disable-next-line @typescript-eslint/no-empty-function
appendBreadcrumb: () => {},
// eslint-disable-next-line @typescript-eslint/no-empty-function
onRendered: () => {},
// eslint-disable-next-line @typescript-eslint/no-empty-function
updatePage: async () => {},
addPage: async () => 'test-page-id',
// eslint-disable-next-line @typescript-eslint/no-empty-function
deletePage: async () => {},
// eslint-disable-next-line @typescript-eslint/no-empty-function
openPageModal: () => {},
loadViews: async () => [],
// eslint-disable-next-line @typescript-eslint/no-empty-function
setWordCount: () => {},
uploadFile: async () => {
throw new Error('Not implemented in story');
},
eventEmitter: undefined,
awarenessMap: {},
};
const mockAFConfigValue = {
service: undefined,
isAuthenticated: true,
currentUser: {
email: 'storybook@example.com',
name: 'Storybook User',
uid: 'storybook-uid',
avatar: null,
uuid: 'storybook-uuid',
latestWorkspaceId: 'storybook-workspace-id',
},
updateCurrentUser: async () => {
// Mock implementation
},
openLoginModal: () => {
// Mock implementation
},
};
const meta = {
title: 'Error Pages/RecordNotFound',
component: RecordNotFound,
parameters: {
layout: 'fullscreen',
},
tags: ['autodocs'],
decorators: [
(Story: React.ComponentType) => (
<AFConfigContext.Provider value={mockAFConfigValue}>
<AppContext.Provider value={mockAppContext}>
<Story />
</AppContext.Provider>
</AFConfigContext.Provider>
),
],
} satisfies Meta<typeof RecordNotFound>;
export default meta;
type Story = StoryObj<typeof meta>;
export const PageNotFound: Story = {
args: {
error: {
type: ErrorType.PageNotFound,
message: 'Page or resource not found',
statusCode: 404,
},
},
};
export const Unauthorized: Story = {
args: {
error: {
type: ErrorType.Unauthorized,
message: 'You need to sign in to access this resource',
statusCode: 401,
},
},
};
export const Forbidden: Story = {
args: {
error: {
type: ErrorType.Forbidden,
message: 'You do not have permission to access this resource',
statusCode: 403,
},
},
};
export const ForbiddenWithViewId: Story = {
args: {
viewId: 'test-view-id',
error: {
type: ErrorType.Forbidden,
message: 'You do not have permission to access this resource',
statusCode: 403,
},
},
};
export const ServerError: Story = {
args: {
error: {
type: ErrorType.ServerError,
message: 'Server error. Please try again later.',
statusCode: 500,
},
},
};
export const NetworkError: Story = {
args: {
error: {
type: ErrorType.NetworkError,
message: 'Network connection failed. Please check your internet connection.',
},
},
};
export const InvalidLink: Story = {
args: {
error: {
type: ErrorType.InvalidLink,
message: 'Invalid or expired link',
code: 1068,
statusCode: 400,
},
},
};
export const AlreadyJoined: Story = {
args: {
error: {
type: ErrorType.AlreadyJoined,
message: 'You have already joined this workspace',
code: 1073,
statusCode: 409,
},
},
};
export const NotInvitee: Story = {
args: {
error: {
type: ErrorType.NotInvitee,
message: 'You are not the intended recipient of this invitation',
code: 1041,
statusCode: 403,
},
},
};
export const Gone: Story = {
args: {
error: {
type: ErrorType.Gone,
message: 'This resource has been deleted',
statusCode: 410,
},
},
};
export const Timeout: Story = {
args: {
error: {
type: ErrorType.Timeout,
message: 'Request timed out. Please try again.',
statusCode: 408,
},
},
};
export const RateLimited: Story = {
args: {
error: {
type: ErrorType.RateLimited,
message: 'Too many requests. Please try again later.',
statusCode: 429,
},
},
};
export const Unknown: Story = {
args: {
error: {
type: ErrorType.Unknown,
message: 'An unexpected error occurred',
},
},
};
export const LegacyNotFound: Story = {
args: {
isViewNotFound: true,
},
};
export const LegacyRecordNotFound: Story = {
args: {
isViewNotFound: false,
},
};
export const NoContent: Story = {
args: {
noContent: true,
},
};

View File

@@ -1,3 +1,4 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ReactComponent as ErrorIcon } from '@/assets/icons/error.svg';
@@ -6,6 +7,7 @@ import { ReactComponent as WarningIcon } from '@/assets/icons/warning.svg';
import emptyImageSrc from '@/assets/images/empty.png';
import { AppError, ErrorType } from '@/application/utils/error-utils';
import LandingPage from '@/components/_shared/landing-page/LandingPage';
import { Progress } from '@/components/ui/progress';
import { useCurrentWorkspaceId } from '@/components/app/app.hooks';
import { RequestAccessContent } from '@/components/app/share/RequestAccessContent';
@@ -22,8 +24,22 @@ function RecordNotFound({
}) {
const { t } = useTranslation();
const currentWorkspaceId = useCurrentWorkspaceId();
const [retrying, setRetrying] = useState(false);
const goToHomepage = () => {
window.location.href = '/app';
};
const goToLogin = () => {
window.location.href = '/';
};
const handleRetry = async () => {
setRetrying(true);
await new Promise((resolve) => setTimeout(resolve, 300));
window.location.reload();
};
// NEW: If error is provided, render specific error page based on error type
if (error) {
switch (error.type) {
case ErrorType.PageNotFound:
@@ -33,7 +49,7 @@ function RecordNotFound({
title={t('landingPage.pageNotFound.title')}
description={t('landingPage.pageNotFound.description')}
primaryAction={{
onClick: () => window.open('/app', '_self'),
onClick: goToHomepage,
label: t('landingPage.pageNotFound.goToHomepage'),
}}
/>
@@ -46,14 +62,13 @@ function RecordNotFound({
title={t('landingPage.unauthorized.title')}
description={t('landingPage.unauthorized.description')}
primaryAction={{
onClick: () => window.open('/', '_self'),
onClick: goToLogin,
label: t('landingPage.unauthorized.signIn'),
}}
/>
);
case ErrorType.Forbidden:
// If viewId and workspaceId available, show request access
if (viewId && currentWorkspaceId) {
return <RequestAccessContent viewId={viewId} workspaceId={currentWorkspaceId} />;
}
@@ -63,6 +78,10 @@ function RecordNotFound({
Logo={NoAccessIcon}
title={t('landingPage.forbidden.title')}
description={t('landingPage.forbidden.description')}
primaryAction={{
onClick: goToHomepage,
label: t('landingPage.pageNotFound.goToHomepage'),
}}
/>
);
@@ -73,8 +92,15 @@ function RecordNotFound({
title={t('landingPage.serverError.title')}
description={t('landingPage.serverError.description')}
primaryAction={{
onClick: () => window.location.reload(),
label: t('landingPage.serverError.retry'),
onClick: handleRetry,
label: retrying ? (
<span className='flex items-center gap-2'>
<Progress />
{t('landingPage.serverError.retry')}
</span>
) : (
t('landingPage.serverError.retry')
),
}}
/>
);
@@ -86,8 +112,15 @@ function RecordNotFound({
title={t('landingPage.networkError.title')}
description={t('landingPage.networkError.description')}
primaryAction={{
onClick: () => window.location.reload(),
label: t('landingPage.networkError.retry'),
onClick: handleRetry,
label: retrying ? (
<span className='flex items-center gap-2'>
<Progress />
{t('landingPage.networkError.retry')}
</span>
) : (
t('landingPage.networkError.retry')
),
}}
/>
);
@@ -99,7 +132,7 @@ function RecordNotFound({
title={t('landingPage.invalidLink.title')}
description={t('landingPage.invalidLink.description')}
primaryAction={{
onClick: () => window.open('/app', '_self'),
onClick: goToHomepage,
label: t('landingPage.invalidLink.goToHomepage'),
}}
/>
@@ -112,12 +145,58 @@ function RecordNotFound({
title={t('landingPage.alreadyJoined.title')}
description={t('landingPage.alreadyJoined.description')}
primaryAction={{
onClick: () => window.open('/app', '_self'),
onClick: goToHomepage,
label: t('landingPage.alreadyJoined.goToWorkspace'),
}}
/>
);
case ErrorType.NotInvitee:
return (
<LandingPage
Logo={NoAccessIcon}
title={t('landingPage.notInvitee.title')}
description={t('landingPage.notInvitee.description')}
primaryAction={{
onClick: goToHomepage,
label: t('landingPage.notInvitee.goToHomepage'),
}}
/>
);
case ErrorType.Gone:
return (
<LandingPage
Logo={WarningIcon}
title={t('landingPage.gone.title')}
description={t('landingPage.gone.description')}
primaryAction={{
onClick: goToHomepage,
label: t('landingPage.gone.goToHomepage'),
}}
/>
);
case ErrorType.Timeout:
return (
<LandingPage
Logo={WarningIcon}
title={t('landingPage.timeout.title')}
description={t('landingPage.timeout.description')}
primaryAction={{
onClick: handleRetry,
label: retrying ? (
<span className='flex items-center gap-2'>
<Progress />
{t('landingPage.timeout.retry')}
</span>
) : (
t('landingPage.timeout.retry')
),
}}
/>
);
case ErrorType.RateLimited:
return (
<LandingPage
@@ -125,24 +204,50 @@ function RecordNotFound({
title={t('landingPage.rateLimited.title')}
description={t('landingPage.rateLimited.description')}
primaryAction={{
onClick: () => window.location.reload(),
label: t('landingPage.rateLimited.retry'),
onClick: handleRetry,
label: retrying ? (
<span className='flex items-center gap-2'>
<Progress />
{t('landingPage.rateLimited.retry')}
</span>
) : (
t('landingPage.rateLimited.retry')
),
}}
/>
);
case ErrorType.Unknown:
default:
// Unknown error - fall through to legacy handling
break;
return (
<LandingPage
Logo={ErrorIcon}
title={t('landingPage.unknown.title')}
description={t('landingPage.unknown.description')}
primaryAction={{
onClick: handleRetry,
label: retrying ? (
<span className='flex items-center gap-2'>
<Progress />
{t('landingPage.unknown.retry')}
</span>
) : (
t('landingPage.unknown.retry')
),
}}
secondaryAction={{
onClick: goToHomepage,
label: t('landingPage.unknown.goToHomepage'),
}}
/>
);
}
}
// LEGACY: If viewId is provided without error object, render the request access component
if (viewId && currentWorkspaceId && !error) {
return <RequestAccessContent viewId={viewId} workspaceId={currentWorkspaceId} />;
}
// LEGACY: Original fallback rendering
return (
<div className={'flex h-full w-full flex-col items-center justify-center px-4'}>
{!noContent && (

View File

@@ -3,7 +3,7 @@ import { toast } from 'sonner';
import { APP_EVENTS } from '@/application/constants';
import { UIVariant, ViewLayout, ViewMetaProps, YDoc } from '@/application/types';
import { AppError, determineErrorType } from '@/application/utils/error-utils';
import { AppError, determineErrorType, formatErrorForLogging } from '@/application/utils/error-utils';
import Help from '@/components/_shared/help/Help';
import { findView } from '@/components/_shared/outline/utils';
import { AIChat } from '@/components/ai-chat';
@@ -76,7 +76,7 @@ function AppPage() {
const appError = determineErrorType(e);
setError(appError);
console.error('[AppPage] Error loading view:', appError);
console.error('[AppPage] Error loading view:', formatErrorForLogging(e));
}
},
[loadView]

View File

@@ -49,6 +49,7 @@
},
"include": [
"src",
".storybook",
"vite.config.ts",
"cypress.config.ts",
"cypress",

View File

@@ -6,6 +6,8 @@
"**/*.cy.tsx",
"**/*.test.ts",
"**/*.test.tsx",
"**/*.stories.ts",
"**/*.stories.tsx",
"**/__tests__/**",
"cypress/**/*",
"dist",