Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion cypress/e2e/page/publish-page.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ describe('Publish Page Test', () => {
if (err.message.includes('No workspace or service found') ||
err.message.includes('createThemeNoVars_default is not a function') ||
err.message.includes('View not found') ||
err.message.includes('Record not found') ||
err.message.includes('Request failed') ||
err.message.includes('Failed to execute \'writeText\' on \'Clipboard\': Document is not focused') ||
err.name === 'NotAllowedError') {
return false;
Expand Down Expand Up @@ -671,7 +673,9 @@ describe('Publish Page Test', () => {
cy.on('uncaught:exception', (err: Error) => {
if (err.message.includes('No workspace or service found') ||
err.message.includes('createThemeNoVars_default is not a function') ||
err.message.includes('View not found')) {
err.message.includes('View not found') ||
err.message.includes('Record not found') ||
err.message.includes('Request failed')) {
return false;
}
return true;
Expand Down
72 changes: 72 additions & 0 deletions src/components/_shared/landing-page/ErrorPage.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import type { Meta, StoryObj } from '@storybook/react-vite';

import { withContextsMinimal } from '../../../../.storybook/decorators';

import { ErrorPage } from './ErrorPage';

const meta = {
title: 'Landing Pages/ErrorPage',
component: ErrorPage,
parameters: {
layout: 'fullscreen',
},
decorators: [withContextsMinimal],
tags: ['autodocs'],
} satisfies Meta<typeof ErrorPage>;

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

export const Default: Story = {};

export const WithRetry: Story = {
args: {
onRetry: async () => {
await new Promise((resolve) => setTimeout(resolve, 2000));
},
},
};

export const WithErrorDetails: Story = {
args: {
error: {
code: 1012,
message: 'You do not have permission to access this workspace.',
},
onRetry: async () => {
await new Promise((resolve) => setTimeout(resolve, 2000));
},
},
};

export const WithNetworkError: Story = {
args: {
error: {
code: -1,
message: 'Network error. Please check your connection.',
},
onRetry: async () => {
await new Promise((resolve) => setTimeout(resolve, 2000));
},
},
};

export const WithLongErrorMessage: Story = {
args: {
error: {
code: 500,
message: 'Internal server error: The request could not be processed due to an unexpected condition. Please try again later or contact support if the problem persists. Request ID: abc123-def456-ghi789',
},
onRetry: async () => {
await new Promise((resolve) => setTimeout(resolve, 2000));
},
},
};

export const WithErrorMessageOnly: Story = {
args: {
error: {
message: 'Something unexpected happened.',
},
},
};
71 changes: 55 additions & 16 deletions src/components/_shared/landing-page/ErrorPage.tsx
Original file line number Diff line number Diff line change
@@ -1,33 +1,72 @@
import { useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';

import { ReactComponent as ErrorLogo } from '@/assets/icons/warning_logo.svg';
import LandingPage from '@/components/_shared/landing-page/LandingPage';
import { Progress } from '@/components/ui/progress';

export function ErrorPage({ onRetry }: { onRetry?: () => Promise<void> }) {
interface ErrorPageProps {
onRetry?: () => Promise<void>;
error?: {
code?: number;
message?: string;
};
}

export function ErrorPage({ onRetry, error }: ErrorPageProps) {
const { t } = useTranslation();

const [loading, setLoading] = useState(false);

const handleCopyError = useCallback(async () => {
if (!error) return;

const errorText = error.code
? `Error: ${error.message}\nCode: ${error.code}`
: `Error: ${error.message}`;

try {
await navigator.clipboard.writeText(errorText);
toast.success('Error details copied to clipboard', { duration: 3000 });
} catch (e) {
console.error('Failed to copy:', e);
toast.error('Failed to copy error details', { duration: 3000 });
}
}, [error]);

return (
<LandingPage
Logo={ErrorLogo}
title={t('landingPage.error.title')}
description={
<Trans
i18nKey={'landingPage.error.description'}
components={{
support: (
<span
onClick={() => window.open('mailto:[email protected]', '_blank')}
className='cursor-pointer text-text-action'
>
[email protected]
</span>
),
}}
/>
<>
<div>
{t('landingPage.error.descriptionShort', 'This might be due to a network issue or a temporary server error. Please check your internet connection or try again later.')}
</div>
<div className='mt-4'>
{t('landingPage.error.contactSupport', 'If the problem persists, ')}
{error?.message && (
<>
<span
onClick={handleCopyError}
className='cursor-pointer text-text-action hover:underline'
>
{t('landingPage.error.copyError', 'copy error')}
</span>
{' and '}
</>
)}
{t('landingPage.error.contact', 'contact ')}
<span
onClick={() => window.open('mailto:[email protected]', '_blank')}
className='cursor-pointer text-text-action hover:underline'
>
[email protected]
</span>
.
</div>
</>
}
primaryAction={
onRetry
Expand Down
7 changes: 4 additions & 3 deletions src/components/app/components/AppContextConsumer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Awareness } from 'y-protocols/awareness';
import { AIChatProvider } from '@/components/ai-chat/AIChatProvider';
import { AppOverlayProvider } from '@/components/app/app-overlay/AppOverlayProvider';
import { AppContext, useAppViewId, useCurrentWorkspaceId } from '@/components/app/app.hooks';
import { RequestAccessError } from '@/components/app/hooks/useWorkspaceData';
import RequestAccess from '@/components/app/landing-pages/RequestAccess';
import { useCurrentUser } from '@/components/main/app.hooks';

Expand All @@ -14,7 +15,7 @@ const ViewModal = React.lazy(() => import('@/components/app/ViewModal'));

interface AppContextConsumerProps {
children: React.ReactNode;
requestAccessOpened: boolean;
requestAccessError: RequestAccessError | null;
openModalViewId?: string;
setOpenModalViewId: (id: string | undefined) => void;
awarenessMap: Record<string, Awareness>;
Expand All @@ -23,15 +24,15 @@ interface AppContextConsumerProps {
// Component that consumes all internal contexts and provides the unified AppContext
// This maintains the original AppContext API while using the new layered architecture internally
export const AppContextConsumer: React.FC<AppContextConsumerProps> = memo(
({ children, requestAccessOpened, openModalViewId, setOpenModalViewId, awarenessMap }) => {
({ children, requestAccessError, openModalViewId, setOpenModalViewId, awarenessMap }) => {
// Merge all layer data into the complete AppContextType
const allContextData = useAllContextData(awarenessMap);

return (
<AppContext.Provider value={allContextData}>
<AIChatProvider>
<AppOverlayProvider>
{requestAccessOpened ? <RequestAccess /> : children}
{requestAccessError ? <RequestAccess error={requestAccessError} /> : children}
{
<Suspense>
<ViewModal
Expand Down
26 changes: 21 additions & 5 deletions src/components/app/hooks/useWorkspaceData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,21 @@ import { useNavigate } from 'react-router-dom';
import { validate as uuidValidate } from 'uuid';

import { APP_EVENTS } from '@/application/constants';
import { invalidToken } from '@/application/session/token';
import { DatabaseRelations, MentionablePerson, UIVariant, View, ViewLayout } from '@/application/types';
import { findView, findViewByLayout } from '@/components/_shared/outline/utils';
import { createDeduplicatedNoArgsRequest } from '@/utils/deduplicateRequest';

import { useAuthInternal } from '../contexts/AuthInternalContext';
import { useSyncInternal } from '../contexts/SyncInternalContext';

const USER_NO_ACCESS_CODE = [1024, 1012];
const USER_NO_ACCESS_CODE = 1012;
const USER_UNAUTHORIZED_CODE = 1024;

export interface RequestAccessError {
code: number;
message: string;
}

// Hook for managing workspace data (outline, favorites, recent, trash)
export function useWorkspaceData() {
Expand All @@ -25,7 +32,7 @@ export function useWorkspaceData() {
const [recentViews, setRecentViews] = useState<View[]>();
const [trashList, setTrashList] = useState<View[]>();
const [workspaceDatabases, setWorkspaceDatabases] = useState<DatabaseRelations | undefined>(undefined);
const [requestAccessOpened, setRequestAccessOpened] = useState(false);
const [requestAccessError, setRequestAccessError] = useState<RequestAccessError | null>(null);

const mentionableUsersRef = useRef<MentionablePerson[]>([]);

Expand Down Expand Up @@ -110,8 +117,17 @@ export function useWorkspaceData() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (e: any) {
console.error('App outline not found');
if (USER_NO_ACCESS_CODE.includes(e.code)) {
setRequestAccessOpened(true);
if (e.code === USER_UNAUTHORIZED_CODE) {
invalidToken();
navigate('/login');
return;
}

if (e.code === USER_NO_ACCESS_CODE) {
setRequestAccessError({
code: e.code,
message: e.message,
});
return;
}
}
Expand Down Expand Up @@ -320,7 +336,7 @@ export function useWorkspaceData() {
recentViews,
trashList,
workspaceDatabases,
requestAccessOpened,
requestAccessError,
loadOutline,
loadFavoriteViews,
loadRecentViews,
Expand Down
9 changes: 7 additions & 2 deletions src/components/app/landing-pages/RequestAccess.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { ReactComponent as AppFlowyLogo } from '@/assets/icons/appflowy.svg';
import { RequestAccessError } from '@/components/app/hooks/useWorkspaceData';
import { RequestAccessContent } from '@/components/app/share/RequestAccessContent';

function RequestAccess() {
interface RequestAccessProps {
error?: RequestAccessError;
}

function RequestAccess({ error }: RequestAccessProps) {
return (
<div className='flex h-screen w-screen flex-col bg-background-primary'>
<div className='absolute left-0 top-0 flex h-[60px] w-full items-center justify-between gap-[10px] p-4'>
Expand All @@ -15,7 +20,7 @@ function RequestAccess() {
</span>
</div>
<div className='flex w-full flex-1 items-center justify-center'>
<RequestAccessContent />
<RequestAccessContent error={error} />
</div>
</div>
);
Expand Down
4 changes: 2 additions & 2 deletions src/components/app/layers/AppBusinessLayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export const AppBusinessLayer: React.FC<AppBusinessLayerProps> = ({ children })
recentViews,
trashList,
workspaceDatabases,
requestAccessOpened,
requestAccessError,
loadOutline,
loadFavoriteViews,
loadRecentViews,
Expand Down Expand Up @@ -295,7 +295,7 @@ export const AppBusinessLayer: React.FC<AppBusinessLayerProps> = ({ children })
return (
<BusinessInternalContext.Provider value={businessContextValue}>
<AppContextConsumer
requestAccessOpened={requestAccessOpened}
requestAccessError={requestAccessError}
openModalViewId={openModalViewId}
setOpenModalViewId={setOpenModalViewId}
awarenessMap={awarenessMap}
Expand Down
4 changes: 3 additions & 1 deletion src/components/app/share/RequestAccessContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { toast } from 'sonner';
import { ReactComponent as NoAccessLogo } from '@/assets/icons/no_access.svg';
import { ReactComponent as SuccessLogo } from '@/assets/icons/success_logo.svg';
import { useAppViewId, useCurrentWorkspaceId } from '@/components/app/app.hooks';
import { RequestAccessError } from '@/components/app/hooks/useWorkspaceData';
import { useCurrentUser, useService } from '@/components/main/app.hooks';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
Expand All @@ -15,9 +16,10 @@ const REPEAT_REQUEST_CODE = 1043;
interface RequestAccessContentProps {
viewId?: string;
workspaceId?: string;
error?: RequestAccessError;
}

export function RequestAccessContent({ viewId: propViewId, workspaceId: propWorkspaceId }: RequestAccessContentProps) {
export function RequestAccessContent({ viewId: propViewId, workspaceId: propWorkspaceId, error: _error }: RequestAccessContentProps) {
const { t } = useTranslation();
const service = useService();
const currentWorkspaceId = useCurrentWorkspaceId();
Expand Down
Loading