feat: add change log (#1734)

Co-authored-by: Himself65 <himself65@outlook.com>
This commit is contained in:
JimmFly
2023-03-31 04:17:36 +08:00
committed by GitHub
parent 91c32b6715
commit c9bd4e34b3
12 changed files with 292 additions and 49 deletions

View File

@@ -1,5 +1,28 @@
import { atom } from 'jotai';
import { atomWithStorage } from 'jotai/utils'; import { atomWithStorage } from 'jotai/utils';
export const isFirstLoadAtom = atomWithStorage<boolean>('isFirstLoad', true); export type Visibility = Record<string, boolean>;
export const openTipsAtom = atom<boolean>(false);
const DEFAULT_VALUE = '0.0.0';
//atomWithStorage always uses initial value when first render
//https://github.com/pmndrs/jotai/discussions/1737
function getInitialValue() {
if (typeof window !== 'undefined') {
const storedValue = window.localStorage.getItem('lastVersion');
if (storedValue) {
return JSON.parse(storedValue);
}
}
return DEFAULT_VALUE;
}
export const lastVersionAtom = atomWithStorage(
'lastVersion',
getInitialValue()
);
export const guideHiddenAtom = atomWithStorage<Visibility>('guideHidden', {});
export const guideHiddenUntilNextUpdateAtom = atomWithStorage<Visibility>(
'guideHiddenUntilNextUpdate',
{}
);

View File

@@ -3,8 +3,9 @@ import { useTranslation } from '@affine/i18n';
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { import {
useIsFirstLoad, useGuideHidden,
useOpenTips, useGuideHiddenUntilNextUpdate,
useUpdateTipsOnVersionChange,
} from '../../../hooks/affine/use-is-first-load'; } from '../../../hooks/affine/use-is-first-load';
import { useSidebarStatus } from '../../../hooks/affine/use-sidebar-status'; import { useSidebarStatus } from '../../../hooks/affine/use-sidebar-status';
import { SidebarSwitchIcon } from './icons'; import { SidebarSwitchIcon } from './icons';
@@ -19,10 +20,12 @@ export const SidebarSwitch = ({
tooltipContent, tooltipContent,
testid = '', testid = '',
}: SidebarSwitchProps) => { }: SidebarSwitchProps) => {
useUpdateTipsOnVersionChange();
const [open, setOpen] = useSidebarStatus(); const [open, setOpen] = useSidebarStatus();
const [tooltipVisible, setTooltipVisible] = useState(false); const [tooltipVisible, setTooltipVisible] = useState(false);
const [isFirstLoad, setIsFirstLoad] = useIsFirstLoad(); const [guideHidden, setGuideHidden] = useGuideHidden();
const [, setOpenTips] = useOpenTips(); const [guideHiddenUntilNextUpdate, setGuideHiddenUntilNextUpdate] =
useGuideHiddenUntilNextUpdate();
const { t } = useTranslation(); const { t } = useTranslation();
tooltipContent = tooltipContent =
tooltipContent || (open ? t('Collapse sidebar') : t('Expand sidebar')); tooltipContent || (open ? t('Collapse sidebar') : t('Expand sidebar'));
@@ -41,13 +44,23 @@ export const SidebarSwitch = ({
onClick={useCallback(() => { onClick={useCallback(() => {
setOpen(!open); setOpen(!open);
setTooltipVisible(false); setTooltipVisible(false);
if (isFirstLoad) { if (guideHiddenUntilNextUpdate['quickSearchTips'] === false) {
setIsFirstLoad(false); setGuideHiddenUntilNextUpdate({
...guideHiddenUntilNextUpdate,
quickSearchTips: true,
});
setTimeout(() => { setTimeout(() => {
setOpenTips(true); setGuideHidden({ ...guideHidden, quickSearchTips: false });
}, 200); }, 200);
} }
}, [isFirstLoad, open, setIsFirstLoad, setOpen, setOpenTips])} }, [
guideHidden,
guideHiddenUntilNextUpdate,
open,
setGuideHidden,
setGuideHiddenUntilNextUpdate,
setOpen,
])}
onMouseEnter={useCallback(() => { onMouseEnter={useCallback(() => {
setTooltipVisible(true); setTooltipVisible(true);
}, [])} }, [])}

View File

@@ -8,7 +8,7 @@ import type { HTMLAttributes } from 'react';
import { forwardRef, useCallback, useRef } from 'react'; import { forwardRef, useCallback, useRef } from 'react';
import { currentEditorAtom, openQuickSearchModalAtom } from '../../../atoms'; import { currentEditorAtom, openQuickSearchModalAtom } from '../../../atoms';
import { useOpenTips } from '../../../hooks/affine/use-is-first-load'; import { useGuideHidden } from '../../../hooks/affine/use-is-first-load';
import { usePageMeta } from '../../../hooks/use-page-meta'; import { usePageMeta } from '../../../hooks/use-page-meta';
import { useElementResizeEffect } from '../../../hooks/use-workspaces'; import { useElementResizeEffect } from '../../../hooks/use-workspaces';
import type { BlockSuiteWorkspace } from '../../../shared'; import type { BlockSuiteWorkspace } from '../../../shared';
@@ -53,7 +53,7 @@ export const BlockSuiteEditorHeader = forwardRef<
assertExists(pageMeta); assertExists(pageMeta);
const title = pageMeta.title; const title = pageMeta.title;
const { trash: isTrash } = pageMeta; const { trash: isTrash } = pageMeta;
const [openTips, setOpenTips] = useOpenTips(); const [isTipsHidden, setTipsHidden] = useGuideHidden();
const isMac = () => { const isMac = () => {
const env = getEnvironment(); const env = getEnvironment();
return env.isBrowser && env.isMacOs; return env.isBrowser && env.isMacOs;
@@ -64,11 +64,11 @@ export const BlockSuiteEditorHeader = forwardRef<
useElementResizeEffect( useElementResizeEffect(
useAtomValue(currentEditorAtom), useAtomValue(currentEditorAtom),
useCallback(() => { useCallback(() => {
if (!openTips || !popperRef.current) { if (isTipsHidden.quickSearchTips || !popperRef.current) {
return; return;
} }
popperRef.current.update(); popperRef.current.update();
}, [openTips]) }, [isTipsHidden.quickSearchTips])
); );
const TipsContent = ( const TipsContent = (
@@ -91,7 +91,9 @@ export const BlockSuiteEditorHeader = forwardRef<
</div> </div>
<StyledQuickSearchTipButton <StyledQuickSearchTipButton
data-testid="quick-search-got-it" data-testid="quick-search-got-it"
onClick={() => setOpenTips(false)} onClick={() =>
setTipsHidden({ ...isTipsHidden, quickSearchTips: true })
}
> >
Got it Got it
</StyledQuickSearchTipButton> </StyledQuickSearchTipButton>
@@ -130,7 +132,7 @@ export const BlockSuiteEditorHeader = forwardRef<
content={TipsContent} content={TipsContent}
placement="bottom" placement="bottom"
popperRef={popperRef} popperRef={popperRef}
open={openTips} open={!isTipsHidden.quickSearchTips}
offset={[0, -5]} offset={[0, -5]}
> >
<StyledSearchArrowWrapper> <StyledSearchArrowWrapper>

View File

@@ -1,6 +1,6 @@
import { MuiFade, Tooltip } from '@affine/component'; import { MuiFade, Tooltip } from '@affine/component';
import { useTranslation } from '@affine/i18n'; import { useTranslation } from '@affine/i18n';
import { CloseIcon } from '@blocksuite/icons'; import { CloseIcon, DoneIcon } from '@blocksuite/icons';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import { useState } from 'react'; import { useState } from 'react';
@@ -23,9 +23,9 @@ const ContactModal = dynamic(
} }
); );
export type IslandItemNames = 'contact' | 'shortcuts'; export type IslandItemNames = 'whatNew' | 'contact' | 'shortcuts';
export const HelpIsland = ({ export const HelpIsland = ({
showList = ['contact', 'shortcuts'], showList = ['whatNew', 'contact', 'shortcuts'],
}: { }: {
showList?: IslandItemNames[]; showList?: IslandItemNames[];
}) => { }) => {
@@ -62,6 +62,18 @@ export const HelpIsland = ({
<StyledAnimateWrapper <StyledAnimateWrapper
style={{ height: spread ? `${showList.length * 44}px` : 0 }} style={{ height: spread ? `${showList.length * 44}px` : 0 }}
> >
{showList.includes('whatNew') && (
<Tooltip content={t("Discover what's new")} placement="left-end">
<StyledIconWrapper
data-testid="right-bottom-change-log-icon"
onClick={() => {
window.open('https://affine.pro', '_blank');
}}
>
<DoneIcon />
</StyledIconWrapper>
</Tooltip>
)}
{showList.includes('contact') && ( {showList.includes('contact') && (
<Tooltip content={t('Contact Us')} placement="left-end"> <Tooltip content={t('Contact Us')} placement="left-end">
<StyledIconWrapper <StyledIconWrapper

View File

@@ -0,0 +1,52 @@
import { IconButton } from '@affine/component';
import { useTranslation } from '@affine/i18n';
import { CloseIcon, DoneIcon } from '@blocksuite/icons';
import { useCallback } from 'react';
import {
useGuideHidden,
useGuideHiddenUntilNextUpdate,
} from '../../../../hooks/affine/use-is-first-load';
import { StyledListItem } from '../shared-styles';
import { StyledLink } from '../style';
export const ChangeLog = () => {
const [guideHidden, setGuideHidden] = useGuideHidden();
const [guideHiddenUntilNextUpdate, setGuideHiddenUntilNextUpdate] =
useGuideHiddenUntilNextUpdate();
const { t } = useTranslation();
const onCloseWhatsNew = useCallback(() => {
setGuideHiddenUntilNextUpdate({
...guideHiddenUntilNextUpdate,
changeLog: true,
});
setGuideHidden({ ...guideHidden, changeLog: true });
}, [
guideHidden,
guideHiddenUntilNextUpdate,
setGuideHidden,
setGuideHiddenUntilNextUpdate,
]);
if (guideHiddenUntilNextUpdate.changeLog) {
return <></>;
}
return (
<>
<StyledListItem data-testid="change-log">
<StyledLink href={'https://affine.pro'} target="_blank">
<DoneIcon />
{t("Discover what's new!")}
</StyledLink>
<IconButton
onClick={() => {
onCloseWhatsNew();
}}
data-testid="change-log-close-button"
>
<CloseIcon />
</IconButton>
</StyledListItem>
</>
);
};
export default ChangeLog;

View File

@@ -15,6 +15,7 @@ import { useSidebarStatus } from '../../../hooks/affine/use-sidebar-status';
import { usePageMeta } from '../../../hooks/use-page-meta'; import { usePageMeta } from '../../../hooks/use-page-meta';
import type { RemWorkspace } from '../../../shared'; import type { RemWorkspace } from '../../../shared';
import { SidebarSwitch } from '../../affine/sidebar-switch'; import { SidebarSwitch } from '../../affine/sidebar-switch';
import { ChangeLog } from './changeLog';
import Favorite from './favorite'; import Favorite from './favorite';
import { Pivots } from './Pivots'; import { Pivots } from './Pivots';
import { StyledListItem } from './shared-styles'; import { StyledListItem } from './shared-styles';
@@ -86,7 +87,7 @@ export const WorkSpaceSliderBar: React.FC<WorkSpaceSliderBarProps> = ({
currentWorkspace={currentWorkspace} currentWorkspace={currentWorkspace}
onClick={onOpenWorkspaceListModal} onClick={onOpenWorkspaceListModal}
/> />
<ChangeLog />
<StyledListItem <StyledListItem
data-testid="slider-bar-quick-search-button" data-testid="slider-bar-quick-search-button"
onClick={useCallback(() => { onClick={useCallback(() => {
@@ -117,7 +118,6 @@ export const WorkSpaceSliderBar: React.FC<WorkSpaceSliderBarProps> = ({
{t('Workspace Settings')} {t('Workspace Settings')}
</StyledLink> </StyledLink>
</StyledListItem> </StyledListItem>
<StyledListItem <StyledListItem
active={ active={
currentPath === currentPath ===
@@ -133,7 +133,6 @@ export const WorkSpaceSliderBar: React.FC<WorkSpaceSliderBarProps> = ({
<span data-testid="all-pages">{t('All pages')}</span> <span data-testid="all-pages">{t('All pages')}</span>
</StyledLink> </StyledLink>
</StyledListItem> </StyledListItem>
<Favorite <Favorite
currentPath={currentPath} currentPath={currentPath}
paths={paths} paths={paths}
@@ -148,7 +147,6 @@ export const WorkSpaceSliderBar: React.FC<WorkSpaceSliderBarProps> = ({
allMetas={pageMeta} allMetas={pageMeta}
/> />
)} )}
<StyledListItem <StyledListItem
active={ active={
currentPath === currentPath ===

View File

@@ -25,7 +25,12 @@ import {
import { LocalPlugin } from '../../plugins/local'; import { LocalPlugin } from '../../plugins/local';
import type { LocalWorkspace } from '../../shared'; import type { LocalWorkspace } from '../../shared';
import { BlockSuiteWorkspace, WorkspaceSubPath } from '../../shared'; import { BlockSuiteWorkspace, WorkspaceSubPath } from '../../shared';
import { useIsFirstLoad, useOpenTips } from '../affine/use-is-first-load'; import {
useGuideHidden,
useGuideHiddenUntilNextUpdate,
useLastVersion,
useTipsDisplayStatus,
} from '../affine/use-is-first-load';
import { import {
useRecentlyViewed, useRecentlyViewed,
useSyncRecentViewsWithRouter, useSyncRecentViewsWithRouter,
@@ -348,20 +353,47 @@ describe('useRecentlyViewed', () => {
}); });
}); });
describe('useIsFirstLoad', () => { describe('useIsFirstLoad', () => {
test('basic', async () => { test('useLastVersion', async () => {
const firstLoad = renderHook(() => useIsFirstLoad()); const lastVersion = renderHook(() => useLastVersion());
const setFirstLoad = firstLoad.result.current[1]; const setLastVersion = lastVersion.result.current[1];
expect(firstLoad.result.current[0]).toEqual(true); expect(lastVersion.result.current[0]).toEqual('0.0.0');
setFirstLoad(false); setLastVersion('testVersion');
firstLoad.rerender(); lastVersion.rerender();
expect(firstLoad.result.current[0]).toEqual(false); expect(lastVersion.result.current[0]).toEqual('testVersion');
}); });
test('useOpenTips', async () => { test('useGuideHidden', async () => {
const openTips = renderHook(() => useOpenTips()); const guideHidden = renderHook(() => useGuideHidden());
const setOpenTips = openTips.result.current[1]; const setGuideHidden = guideHidden.result.current[1];
expect(openTips.result.current[0]).toEqual(false); expect(guideHidden.result.current[0]).toEqual({});
setOpenTips(true); setGuideHidden({ test: true });
openTips.rerender(); guideHidden.rerender();
expect(openTips.result.current[0]).toEqual(true); expect(guideHidden.result.current[0]).toEqual({ test: true });
});
test('useGuideHiddenUntilNextUpdate', async () => {
const guideHiddenUntilNextUpdate = renderHook(() =>
useGuideHiddenUntilNextUpdate()
);
const setGuideHiddenUntilNextUpdate =
guideHiddenUntilNextUpdate.result.current[1];
expect(guideHiddenUntilNextUpdate.result.current[0]).toEqual({});
setGuideHiddenUntilNextUpdate({ test: true });
guideHiddenUntilNextUpdate.rerender();
expect(guideHiddenUntilNextUpdate.result.current[0]).toEqual({
test: true,
});
});
test('useTipsDisplayStatus', async () => {
const tipsDisplayStatus = renderHook(() => useTipsDisplayStatus());
const setTipsDisplayStatus = tipsDisplayStatus.result.current;
expect(tipsDisplayStatus.result.current).toEqual({
quickSearchTips: {
permanentlyHidden: true,
hiddenUntilNextUpdate: true,
},
changeLog: {
permanentlyHidden: true,
hiddenUntilNextUpdate: true,
},
});
}); });
}); });

View File

@@ -1,12 +1,74 @@
import { useAtom } from 'jotai'; import { config } from '@affine/env';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { useEffect } from 'react';
import { isFirstLoadAtom, openTipsAtom } from '../../atoms/first-load'; import {
guideHiddenAtom,
guideHiddenUntilNextUpdateAtom,
lastVersionAtom,
} from '../../atoms/first-load';
export function useIsFirstLoad() { export function useLastVersion() {
const [isFirstLoad, setIsFirstLoad] = useAtom(isFirstLoadAtom); return useAtom(lastVersionAtom);
return [isFirstLoad, setIsFirstLoad] as const;
} }
export function useOpenTips() {
const [openTips, setOpenTips] = useAtom(openTipsAtom); export function useGuideHidden() {
return [openTips, setOpenTips] as const; return useAtom(guideHiddenAtom);
}
export function useGuideHiddenUntilNextUpdate() {
return useAtom(guideHiddenUntilNextUpdateAtom);
}
const TIPS = {
quickSearchTips: true,
changeLog: true,
};
export function useTipsDisplayStatus() {
const permanentlyHiddenTips = useAtomValue(guideHiddenAtom);
const hiddenUntilNextUpdateTips = useAtomValue(
guideHiddenUntilNextUpdateAtom
);
return {
quickSearchTips: {
permanentlyHidden: permanentlyHiddenTips.quickSearchTips || true,
hiddenUntilNextUpdate: hiddenUntilNextUpdateTips.quickSearchTips || true,
},
changeLog: {
permanentlyHidden: permanentlyHiddenTips.changeLog || true,
hiddenUntilNextUpdate: hiddenUntilNextUpdateTips.changeLog || true,
},
};
}
export function useUpdateTipsOnVersionChange() {
const [lastVersion, setLastVersion] = useLastVersion();
const currentVersion = config.gitVersion;
const tipsDisplayStatus = useTipsDisplayStatus();
const setPermanentlyHiddenTips = useSetAtom(guideHiddenAtom);
const setHiddenUntilNextUpdateTips = useSetAtom(
guideHiddenUntilNextUpdateAtom
);
useEffect(() => {
if (lastVersion !== currentVersion) {
setLastVersion(currentVersion);
const newHiddenUntilNextUpdateTips = { ...TIPS };
const newPermanentlyHiddenTips = { ...TIPS, changeLog: false };
Object.keys(tipsDisplayStatus).forEach(tipKey => {
newHiddenUntilNextUpdateTips[tipKey as keyof typeof TIPS] = false;
});
setHiddenUntilNextUpdateTips(newHiddenUntilNextUpdateTips);
setPermanentlyHiddenTips(newPermanentlyHiddenTips);
}
}, [
currentVersion,
lastVersion,
setLastVersion,
setPermanentlyHiddenTips,
setHiddenUntilNextUpdateTips,
tipsDisplayStatus,
]);
} }

View File

@@ -257,7 +257,9 @@ export const WorkspaceLayoutInner: React.FC<React.PropsWithChildren> = ({
</div> </div>
{!isPublicWorkspace && ( {!isPublicWorkspace && (
<HelpIsland <HelpIsland
showList={router.query.pageId ? undefined : ['contact']} showList={
router.query.pageId ? undefined : ['whatNew', 'contact']
}
/> />
)} )}
</StyledToolWrapper> </StyledToolWrapper>

View File

@@ -200,5 +200,6 @@
"Move to": "Move to", "Move to": "Move to",
"Move page to...": "Move page to...", "Move page to...": "Move page to...",
"Remove from Pivots": "Remove from Pivots", "Remove from Pivots": "Remove from Pivots",
"RFP": "Pages can be freely added/removed from pivots, remaining accessible from \"All Pages\"." "RFP": "Pages can be freely added/removed from pivots, remaining accessible from \"All Pages\".",
"Discover what's new!": "Discover what's new!"
} }

View File

@@ -29,3 +29,31 @@ test.describe('Open AFFiNE', () => {
expect(currentWorkspaceName).toEqual('New Workspace 2'); expect(currentWorkspaceName).toEqual('New Workspace 2');
}); });
}); });
test.describe('AFFiNE change log', () => {
test('Open affine in first time after updated', async ({ page }) => {
await openHomePage(page);
const changeLogItem = page.locator('[data-testid=change-log]');
await expect(changeLogItem).toBeVisible();
const closeButton = page.locator('[data-testid=change-log-close-button]');
await closeButton.click();
await expect(changeLogItem).not.toBeVisible();
await page.goto('http://localhost:8080');
const currentChangeLogItem = page.locator('[data-testid=change-log]');
await expect(currentChangeLogItem).not.toBeVisible();
});
test('Click right-bottom corner change log icon', async ({ page }) => {
await openHomePage(page);
await waitMarkdownImported(page);
await page.locator('[data-testid=help-island]').click();
const editorRightBottomChangeLog = page.locator(
'[data-testid=right-bottom-change-log-icon]'
);
expect(await editorRightBottomChangeLog.isVisible()).toEqual(true);
await page.getByRole('link', { name: 'All pages' }).click();
const normalRightBottomChangeLog = page.locator(
'[data-testid=right-bottom-change-log-icon]'
);
expect(await normalRightBottomChangeLog.isVisible()).toEqual(true);
});
});

View File

@@ -186,4 +186,22 @@ test.describe('Novice guidance for quick search', () => {
await page.locator('[data-testid=quick-search-got-it]').click(); await page.locator('[data-testid=quick-search-got-it]').click();
await expect(quickSearchTips).not.toBeVisible(); await expect(quickSearchTips).not.toBeVisible();
}); });
test('After appearing once, it will not appear a second time', async ({
page,
}) => {
await openHomePage(page);
await waitMarkdownImported(page);
const quickSearchTips = page.locator('[data-testid=quick-search-tips]');
await expect(quickSearchTips).not.toBeVisible();
await page.getByTestId('sliderBar-arrowButton-collapse').click();
const sliderBarArea = page.getByTestId('sliderBar');
await expect(sliderBarArea).not.toBeVisible();
await expect(quickSearchTips).toBeVisible();
await page.locator('[data-testid=quick-search-got-it]').click();
await expect(quickSearchTips).not.toBeVisible();
await page.reload();
await page.locator('[data-testid=sliderBar-arrowButton-expand]').click();
await page.getByTestId('sliderBar-arrowButton-collapse').click();
await expect(quickSearchTips).not.toBeVisible();
});
}); });