mirror of
https://github.com/toeverything/AFFiNE.git
synced 2024-12-25 11:02:46 +03:00
feat: add onboarding for client (#2144)
Co-authored-by: Himself65 <himself65@outlook.com>
This commit is contained in:
parent
238f69b4e7
commit
6d7f06c1c3
@ -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);
|
||||||
|
});
|
||||||
|
BIN
apps/web/public/editingVideo.mp4
Normal file
BIN
apps/web/public/editingVideo.mp4
Normal file
Binary file not shown.
BIN
apps/web/public/switchVideo.mp4
Normal file
BIN
apps/web/public/switchVideo.mp4
Normal file
Binary file not shown.
@ -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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
@ -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);
|
||||||
|
|
||||||
|
@ -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"
|
||||||
>
|
>
|
||||||
|
@ -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',
|
||||||
|
45
apps/web/src/components/pure/OnboardingModal.tsx
Normal file
45
apps/web/src/components/pure/OnboardingModal.tsx
Normal 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;
|
@ -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">
|
||||||
|
@ -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}
|
||||||
|
103
packages/component/src/components/tour-modal/TourModal.tsx
Normal file
103
packages/component/src/components/tour-modal/TourModal.tsx
Normal 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;
|
44
packages/component/src/components/tour-modal/index.css.ts
Normal file
44
packages/component/src/components/tour-modal/index.css.ts
Normal 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)',
|
||||||
|
},
|
||||||
|
});
|
1
packages/component/src/components/tour-modal/index.ts
Normal file
1
packages/component/src/components/tour-modal/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './TourModal';
|
16
packages/component/src/stories/OnboardingModal.stories.tsx
Normal file
16
packages/component/src/stories/OnboardingModal.stories.tsx
Normal 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',
|
||||||
|
};
|
Loading…
Reference in New Issue
Block a user