feat: add onboarding for client (#2144)

Co-authored-by: Himself65 <himself65@outlook.com>
This commit is contained in:
JimmFly 2023-05-04 15:29:16 +08:00 committed by GitHub
parent 238f69b4e7
commit 6d7f06c1c3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 297 additions and 7 deletions

View File

@ -16,6 +16,9 @@ test.beforeEach(async () => {
colorScheme: 'light', colorScheme: 'light',
}); });
page = await electronApp.firstWindow(); page = await electronApp.firstWindow();
await page.getByTestId('onboarding-modal-close-button').click({
delay: 100,
});
// cleanup page data // cleanup page data
await page.evaluate(() => localStorage.clear()); await page.evaluate(() => localStorage.clear());
}); });
@ -76,3 +79,21 @@ test('affine cloud disabled', async () => {
state: 'visible', state: 'visible',
}); });
}); });
test('affine onboarding button', async () => {
await page.getByTestId('help-island').click();
await page.getByTestId('easy-guide').click();
const onboardingModal = page.locator('[data-testid=onboarding-modal]');
expect(await onboardingModal.isVisible()).toEqual(true);
const switchVideo = page.locator(
'[data-testid=onboarding-modal-switch-video]'
);
expect(await switchVideo.isVisible()).toEqual(true);
await page.getByTestId('onboarding-modal-next-button').click();
const editingVideo = page.locator(
'[data-testid=onboarding-modal-editing-video]'
);
expect(await editingVideo.isVisible()).toEqual(true);
await page.getByTestId('onboarding-modal-ok-button').click();
expect(await onboardingModal.isVisible()).toEqual(false);
});

Binary file not shown.

Binary file not shown.

View File

@ -52,3 +52,18 @@ export const guideChangeLogAtom = atom<
})); }));
} }
); );
export const guideOnboardingAtom = atom<
Guide['onBoarding'],
[open: boolean],
void
>(
get => {
return get(guidePrimitiveAtom).onBoarding;
},
(_, set, open) => {
set(guidePrimitiveAtom, tips => ({
...tips,
onBoarding: open,
}));
}
);

View File

@ -77,6 +77,7 @@ export const currentEditorAtom = rootCurrentEditorAtom;
export const openWorkspacesModalAtom = atom(false); export const openWorkspacesModalAtom = atom(false);
export const openCreateWorkspaceModalAtom = atom(false); export const openCreateWorkspaceModalAtom = atom(false);
export const openQuickSearchModalAtom = atom(false); export const openQuickSearchModalAtom = atom(false);
export const openOnboardingModalAtom = atom(false);
export const openDisableCloudAlertModalAtom = atom(false); export const openDisableCloudAlertModalAtom = atom(false);

View File

@ -45,7 +45,7 @@ const CommonMenu = () => {
<Menu <Menu
width={276} width={276}
content={content} content={content}
// placement="bottom-end" placement="bottom"
disablePortal={true} disablePortal={true}
trigger="click" trigger="click"
> >

View File

@ -15,7 +15,7 @@ export const StyledHeaderContainer = styled('div')<{
top: 0, top: 0,
background: 'var(--affine-background-primary-color)', background: 'var(--affine-background-primary-color)',
WebkitAppRegion: 'drag', WebkitAppRegion: 'drag',
zIndex: 1, zIndex: 'var(--affine-z-index-popover)',
'@media (max-width: 768px)': { '@media (max-width: 768px)': {
'&[data-open="true"]': { '&[data-open="true"]': {
WebkitAppRegion: 'no-drag', WebkitAppRegion: 'no-drag',

View File

@ -0,0 +1,45 @@
import { TourModal } from '@affine/component/tour-modal';
import { useAtom } from 'jotai';
import { useCallback, useEffect, useMemo } from 'react';
import { openOnboardingModalAtom } from '../../atoms';
import { guideOnboardingAtom } from '../../atoms/guide';
type OnboardingModalProps = {
onClose: () => void;
open: boolean;
};
const getHelperGuide = (): { onBoarding: boolean } | null => {
const helperGuide = localStorage.getItem('helper-guide');
if (helperGuide) {
return JSON.parse(helperGuide);
}
return null;
};
export const OnboardingModal: React.FC<OnboardingModalProps> = ({
open,
onClose,
}) => {
const [, setShowOnboarding] = useAtom(guideOnboardingAtom);
const [, setOpenOnboarding] = useAtom(openOnboardingModalAtom);
const onCloseTourModal = useCallback(() => {
setShowOnboarding(false);
onClose();
}, [onClose, setShowOnboarding]);
const shouldShow = useMemo(() => {
const helperGuide = getHelperGuide();
return helperGuide?.onBoarding ?? true;
}, []);
useEffect(() => {
if (shouldShow) {
setOpenOnboarding(true);
}
}, [shouldShow, setOpenOnboarding]);
return <TourModal open={open} onClose={onCloseTourModal} />;
};
export default OnboardingModal;

View File

@ -1,8 +1,11 @@
import { MuiFade, Tooltip } from '@affine/component'; import { MuiFade, Tooltip } from '@affine/component';
import { getEnvironment } from '@affine/env';
import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { CloseIcon, NewIcon } from '@blocksuite/icons'; import { CloseIcon, NewIcon } from '@blocksuite/icons';
import { useAtom } from 'jotai';
import { lazy, Suspense, useState } from 'react'; import { lazy, Suspense, useState } from 'react';
import { openOnboardingModalAtom } from '../../../atoms';
import { ShortcutsModal } from '../shortcuts-modal'; import { ShortcutsModal } from '../shortcuts-modal';
import { ContactIcon, HelpIcon, KeyboardIcon } from './Icons'; import { ContactIcon, HelpIcon, KeyboardIcon } from './Icons';
import { import {
@ -11,19 +14,25 @@ import {
StyledIsland, StyledIsland,
StyledTriggerWrapper, StyledTriggerWrapper,
} from './style'; } from './style';
const env = getEnvironment();
const ContactModal = lazy(() => const ContactModal = lazy(() =>
import('@affine/component/contact-modal').then(({ ContactModal }) => ({ import('@affine/component/contact-modal').then(({ ContactModal }) => ({
default: ContactModal, default: ContactModal,
})) }))
); );
const DEFAULT_SHOW_LIST: IslandItemNames[] = [
export type IslandItemNames = 'whatNew' | 'contact' | 'shortcuts'; 'whatNew',
'contact',
'shortcuts',
];
const DESKTOP_SHOW_LIST: IslandItemNames[] = [...DEFAULT_SHOW_LIST, 'guide'];
export type IslandItemNames = 'whatNew' | 'contact' | 'shortcuts' | 'guide';
export const HelpIsland = ({ export const HelpIsland = ({
showList = ['whatNew', 'contact', 'shortcuts'], showList = env.isDesktop ? DESKTOP_SHOW_LIST : DEFAULT_SHOW_LIST,
}: { }: {
showList?: IslandItemNames[]; showList?: IslandItemNames[];
}) => { }) => {
const [, setOpenOnboarding] = useAtom(openOnboardingModalAtom);
const [spread, setShowSpread] = useState(false); const [spread, setShowSpread] = useState(false);
// const { triggerShortcutsModal, triggerContactModal } = useModal(); // const { triggerShortcutsModal, triggerContactModal } = useModal();
// const blockHub = useGlobalState(store => store.blockHub); // const blockHub = useGlobalState(store => store.blockHub);
@ -98,6 +107,19 @@ export const HelpIsland = ({
</StyledIconWrapper> </StyledIconWrapper>
</Tooltip> </Tooltip>
)} )}
{showList.includes('guide') && (
<Tooltip content={'Easy Guide'} placement="left-end">
<StyledIconWrapper
data-testid="easy-guide"
onClick={() => {
setShowSpread(false);
setOpenOnboarding(true);
}}
>
<HelpIcon />
</StyledIconWrapper>
</Tooltip>
)}
</StyledAnimateWrapper> </StyledAnimateWrapper>
<Tooltip content={t['Help and Feedback']()} placement="left-end"> <Tooltip content={t['Help and Feedback']()} placement="left-end">

View File

@ -1,3 +1,4 @@
import { getEnvironment } from '@affine/env';
import { rootWorkspacesMetadataAtom } from '@affine/workspace/atom'; import { rootWorkspacesMetadataAtom } from '@affine/workspace/atom';
import { arrayMove } from '@dnd-kit/sortable'; import { arrayMove } from '@dnd-kit/sortable';
import { useAtom, useAtomValue, useSetAtom } from 'jotai'; import { useAtom, useAtomValue, useSetAtom } from 'jotai';
@ -9,6 +10,7 @@ import {
currentWorkspaceIdAtom, currentWorkspaceIdAtom,
openCreateWorkspaceModalAtom, openCreateWorkspaceModalAtom,
openDisableCloudAlertModalAtom, openDisableCloudAlertModalAtom,
openOnboardingModalAtom,
openWorkspacesModalAtom, openWorkspacesModalAtom,
} from '../atoms'; } from '../atoms';
import { useAffineLogIn } from '../hooks/affine/use-affine-log-in'; import { useAffineLogIn } from '../hooks/affine/use-affine-log-in';
@ -37,6 +39,11 @@ const TmpDisableAffineCloudModal = lazy(() =>
}) })
) )
); );
const OnboardingModalAtom = lazy(() =>
import('../components/pure/OnboardingModal').then(module => ({
default: module.OnboardingModal,
}))
);
export function Modals() { export function Modals() {
const [openWorkspacesModal, setOpenWorkspacesModal] = useAtom( const [openWorkspacesModal, setOpenWorkspacesModal] = useAtom(
@ -49,6 +56,9 @@ export function Modals() {
const [openDisableCloudAlertModal, setOpenDisableCloudAlertModal] = useAtom( const [openDisableCloudAlertModal, setOpenDisableCloudAlertModal] = useAtom(
openDisableCloudAlertModalAtom openDisableCloudAlertModalAtom
); );
const [openOnboardingModal, setOpenOnboardingModal] = useAtom(
openOnboardingModalAtom
);
const router = useRouter(); const router = useRouter();
const { jumpToSubPath } = useRouterHelper(router); const { jumpToSubPath } = useRouterHelper(router);
@ -59,7 +69,10 @@ export function Modals() {
const [, setCurrentWorkspace] = useCurrentWorkspace(); const [, setCurrentWorkspace] = useCurrentWorkspace();
const { createLocalWorkspace } = useAppHelper(); const { createLocalWorkspace } = useAppHelper();
const [transitioning, transition] = useTransition(); const [transitioning, transition] = useTransition();
const env = getEnvironment();
const onCloseOnboardingModal = useCallback(() => {
setOpenOnboardingModal(false);
}, [setOpenOnboardingModal]);
return ( return (
<> <>
<Suspense> <Suspense>
@ -70,6 +83,15 @@ export function Modals() {
}, [setOpenDisableCloudAlertModal])} }, [setOpenDisableCloudAlertModal])}
/> />
</Suspense> </Suspense>
{env.isDesktop && (
<Suspense>
<OnboardingModalAtom
open={openOnboardingModal}
onClose={onCloseOnboardingModal}
/>
</Suspense>
)}
<Suspense> <Suspense>
<WorkspaceListModal <WorkspaceListModal
disabled={transitioning} disabled={transitioning}

View File

@ -0,0 +1,103 @@
import type { FC } from 'react';
import { useState } from 'react';
import { Button, Modal, ModalCloseButton, ModalWrapper } from '../..';
import {
buttonContainerStyle,
buttonStyle,
modalStyle,
titleStyle,
videoContainerStyle,
videoStyle,
} from './index.css';
type TourModalProps = {
open: boolean;
onClose: () => void;
};
export const TourModal: FC<TourModalProps> = ({ open, onClose }) => {
const [step, setStep] = useState(0);
const handleClose = () => {
setStep(0);
onClose();
};
return (
<Modal
open={open}
onClose={handleClose}
wrapperPosition={['center', 'center']}
hideBackdrop
>
<ModalWrapper width={545} height={442} data-testid="onboarding-modal">
<ModalCloseButton
top={10}
right={10}
onClick={handleClose}
data-testid="onboarding-modal-close-button"
/>
{step === 0 && (
<div className={modalStyle}>
<div className={titleStyle}>Hyper merged whiteboard and docs</div>
<div className={videoContainerStyle}>
<video
autoPlay
muted
loop
className={videoStyle}
data-testid="onboarding-modal-switch-video"
>
<source src="/switchVideo.mp4" type="video/mp4" />
<source src="/switchVideo.webm" type="video/webm" />
Easily switch between Page mode for structured document creation
and Whiteboard mode for the freeform visual expression of
creative ideas.
</video>
</div>
<div className={buttonContainerStyle}>
<Button
className={buttonStyle}
onClick={() => setStep(1)}
data-testid="onboarding-modal-next-button"
>
Next Tip Please !
</Button>
</div>
</div>
)}
{step === 1 && (
<div className={modalStyle}>
<div className={titleStyle}>
Intuitive & robust block-based editing
</div>
<div className={videoContainerStyle}>
<video
autoPlay
muted
loop
className={videoStyle}
data-testid="onboarding-modal-editing-video"
>
<source src="/editingVideo.mp4" type="video/mp4" />
<source src="/editingVideo.webm" type="video/webm" />
Create structured documents with ease, using a modular interface
to drag and drop blocks of text, images, and other content.
</video>
</div>
<div className={buttonContainerStyle}>
<Button
className={buttonStyle}
onClick={handleClose}
data-testid="onboarding-modal-ok-button"
>
Okay, I Like It !
</Button>
</div>
</div>
)}
</ModalWrapper>
</Modal>
);
};
export default TourModal;

View File

@ -0,0 +1,44 @@
import { style } from '@vanilla-extract/css';
export const modalStyle = style({
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
position: 'relative',
padding: '12px 36px',
backgroundColor: 'var(--affine-white)',
borderRadius: '12px',
boxShadow: 'var(--affine-popover-shadow)',
});
export const titleStyle = style({
fontSize: 'var(--affine-font-h6)',
fontWeight: '600',
});
export const videoContainerStyle = style({
paddingTop: '15px',
width: '100%',
});
export const videoStyle = style({
objectFit: 'fill',
height: '300px',
width: '100%',
});
export const buttonContainerStyle = style({
marginTop: '15px',
width: '100%',
display: 'flex',
justifyContent: 'flex-end',
});
export const buttonStyle = style({
borderRadius: '8px',
backgroundColor: 'var(--affine-primary-color)',
color: 'var(--affine-white)',
height: '32px',
padding: '4 20px',
':hover': {
backgroundColor: 'var(--affine-primary-color)',
color: 'var(--affine-text-primary-color)',
},
});

View File

@ -0,0 +1 @@
export * from './TourModal';

View File

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