feat: add recentlyViewed (#1357)
Co-authored-by: himself65 <himself65@outlook.com>
This commit is contained in:
@@ -5,7 +5,6 @@ import { unstable_batchedUpdates } from 'react-dom';
|
|||||||
|
|
||||||
import { WorkspacePlugins } from '../plugins';
|
import { WorkspacePlugins } from '../plugins';
|
||||||
import { RemWorkspace, RemWorkspaceFlavour } from '../shared';
|
import { RemWorkspace, RemWorkspaceFlavour } from '../shared';
|
||||||
|
|
||||||
// workspace necessary atoms
|
// workspace necessary atoms
|
||||||
export const currentWorkspaceIdAtom = atom<string | null>(null);
|
export const currentWorkspaceIdAtom = atom<string | null>(null);
|
||||||
export const currentPageIdAtom = atom<string | null>(null);
|
export const currentPageIdAtom = atom<string | null>(null);
|
||||||
@@ -60,3 +59,32 @@ export const workspacesAtom = atom<Promise<RemWorkspace[]>>(async get => {
|
|||||||
);
|
);
|
||||||
return workspaces.filter(workspace => workspace !== null) as RemWorkspace[];
|
return workspaces.filter(workspace => workspace !== null) as RemWorkspace[];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
type View = { title: string; id: string; mode: 'page' | 'edgeless' };
|
||||||
|
|
||||||
|
export type WorkspaceRecentViews = Record<string, View[]>;
|
||||||
|
|
||||||
|
export const workspaceRecentViewsAtom = atomWithStorage<WorkspaceRecentViews>(
|
||||||
|
'recentViews',
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const workspaceRecentViresWriteAtom = atom<null, [string, View], View[]>(
|
||||||
|
null,
|
||||||
|
(get, set, id, value) => {
|
||||||
|
const record = get(workspaceRecentViewsAtom);
|
||||||
|
if (Array.isArray(record[id])) {
|
||||||
|
const idx = record[id].findIndex(view => view.id === value.id);
|
||||||
|
if (idx !== -1) {
|
||||||
|
record[id].splice(idx, 1);
|
||||||
|
}
|
||||||
|
record[id] = [value, ...record[id]];
|
||||||
|
} else {
|
||||||
|
record[id] = [value];
|
||||||
|
}
|
||||||
|
|
||||||
|
record[id] = record[id].slice(0, 3);
|
||||||
|
set(workspaceRecentViewsAtom, { ...record });
|
||||||
|
return record[id];
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Command } from 'cmdk';
|
|||||||
import { NextRouter } from 'next/router';
|
import { NextRouter } from 'next/router';
|
||||||
import React, { Dispatch, SetStateAction, useEffect, useState } from 'react';
|
import React, { Dispatch, SetStateAction, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { useRecentlyViewed } from '../../../hooks/affine/use-recent-views';
|
||||||
import { useBlockSuiteWorkspaceHelper } from '../../../hooks/use-blocksuite-workspace-helper';
|
import { useBlockSuiteWorkspaceHelper } from '../../../hooks/use-blocksuite-workspace-helper';
|
||||||
import { usePageMeta } from '../../../hooks/use-page-meta';
|
import { usePageMeta } from '../../../hooks/use-page-meta';
|
||||||
import { BlockSuiteWorkspace } from '../../../shared';
|
import { BlockSuiteWorkspace } from '../../../shared';
|
||||||
@@ -33,6 +34,7 @@ export const Results: React.FC<ResultsProps> = ({
|
|||||||
assertExists(blockSuiteWorkspace.room);
|
assertExists(blockSuiteWorkspace.room);
|
||||||
const List = useSwitchToConfig(blockSuiteWorkspace.room);
|
const List = useSwitchToConfig(blockSuiteWorkspace.room);
|
||||||
const [results, setResults] = useState(new Map<string, string | undefined>());
|
const [results, setResults] = useState(new Map<string, string | undefined>());
|
||||||
|
const recentlyViewed = useRecentlyViewed();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setResults(blockSuiteWorkspace.search(query));
|
setResults(blockSuiteWorkspace.search(query));
|
||||||
@@ -93,25 +95,56 @@ export const Results: React.FC<ResultsProps> = ({
|
|||||||
</StyledNotFound>
|
</StyledNotFound>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<Command.Group heading={t('Jump to')}>
|
<div>
|
||||||
{List.map(link => {
|
<Command.Group heading={t('Recently viewed')}>
|
||||||
return (
|
{recentlyViewed.map(recent => {
|
||||||
<Command.Item
|
return (
|
||||||
key={link.title}
|
<Command.Item
|
||||||
value={link.title}
|
key={recent.id}
|
||||||
onSelect={() => {
|
value={recent.id}
|
||||||
onClose();
|
onSelect={() => {
|
||||||
router.push(link.href);
|
onClose();
|
||||||
}}
|
router.push({
|
||||||
>
|
pathname: '/workspace/[workspaceId]/[pageId]',
|
||||||
<StyledListItem>
|
query: {
|
||||||
<link.icon />
|
workspaceId: blockSuiteWorkspace.room,
|
||||||
<span>{link.title}</span>
|
pageId: recent.id,
|
||||||
</StyledListItem>
|
},
|
||||||
</Command.Item>
|
});
|
||||||
);
|
}}
|
||||||
})}
|
>
|
||||||
</Command.Group>
|
<StyledListItem>
|
||||||
|
{recent.mode === 'edgeless' ? (
|
||||||
|
<EdgelessIcon />
|
||||||
|
) : (
|
||||||
|
<PaperIcon />
|
||||||
|
)}
|
||||||
|
<span>{recent.title}</span>
|
||||||
|
</StyledListItem>
|
||||||
|
</Command.Item>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Command.Group>
|
||||||
|
<Command.Group heading={t('Jump to')}>
|
||||||
|
{List.map(link => {
|
||||||
|
return (
|
||||||
|
<Command.Item
|
||||||
|
key={link.title}
|
||||||
|
value={link.title}
|
||||||
|
onSelect={() => {
|
||||||
|
onClose();
|
||||||
|
router.push(link.href);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StyledListItem>
|
||||||
|
<link.icon />
|
||||||
|
<span>{link.title}</span>
|
||||||
|
</StyledListItem>
|
||||||
|
</Command.Item>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Command.Group>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,10 +13,23 @@ import { useRouter } from 'next/router';
|
|||||||
import routerMock from 'next-router-mock';
|
import routerMock from 'next-router-mock';
|
||||||
import { createDynamicRouteParser } from 'next-router-mock/dynamic-routes';
|
import { createDynamicRouteParser } from 'next-router-mock/dynamic-routes';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { beforeAll, beforeEach, describe, expect, test } from 'vitest';
|
import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
import { workspacesAtom } from '../../atoms';
|
import {
|
||||||
import { BlockSuiteWorkspace, RemWorkspaceFlavour } from '../../shared';
|
currentWorkspaceIdAtom,
|
||||||
|
jotaiWorkspacesAtom,
|
||||||
|
workspacesAtom,
|
||||||
|
} from '../../atoms';
|
||||||
|
import { LocalPlugin } from '../../plugins/local';
|
||||||
|
import {
|
||||||
|
BlockSuiteWorkspace,
|
||||||
|
LocalWorkspace,
|
||||||
|
RemWorkspaceFlavour,
|
||||||
|
} from '../../shared';
|
||||||
|
import {
|
||||||
|
useRecentlyViewed,
|
||||||
|
useSyncRecentViewsWithRouter,
|
||||||
|
} from '../affine/use-recent-views';
|
||||||
import {
|
import {
|
||||||
currentWorkspaceAtom,
|
currentWorkspaceAtom,
|
||||||
useCurrentWorkspace,
|
useCurrentWorkspace,
|
||||||
@@ -248,3 +261,59 @@ describe('useBlockSuiteWorkspaceName', () => {
|
|||||||
expect(blockSuiteWorkspace.meta.name).toBe('test 3');
|
expect(blockSuiteWorkspace.meta.name).toBe('test 3');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('useRecentlyViewed', () => {
|
||||||
|
test('basic', async () => {
|
||||||
|
const { ProviderWrapper, store } = await getJotaiContext();
|
||||||
|
const workspaceId = blockSuiteWorkspace.room as string;
|
||||||
|
const pageId = 'page0';
|
||||||
|
store.set(jotaiWorkspacesAtom, [
|
||||||
|
{
|
||||||
|
id: workspaceId,
|
||||||
|
flavour: RemWorkspaceFlavour.LOCAL,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
LocalPlugin.CRUD.get = vi.fn().mockResolvedValue({
|
||||||
|
id: workspaceId,
|
||||||
|
flavour: RemWorkspaceFlavour.LOCAL,
|
||||||
|
blockSuiteWorkspace,
|
||||||
|
providers: [],
|
||||||
|
} satisfies LocalWorkspace);
|
||||||
|
store.set(currentWorkspaceIdAtom, blockSuiteWorkspace.room as string);
|
||||||
|
const workspace = await store.get(currentWorkspaceAtom);
|
||||||
|
expect(workspace?.id).toBe(blockSuiteWorkspace.room as string);
|
||||||
|
const currentHook = renderHook(() => useCurrentWorkspace(), {
|
||||||
|
wrapper: ProviderWrapper,
|
||||||
|
});
|
||||||
|
expect(currentHook.result.current[0]?.id).toEqual(workspaceId);
|
||||||
|
await store.get(currentWorkspaceAtom);
|
||||||
|
const recentlyViewedHook = renderHook(() => useRecentlyViewed(), {
|
||||||
|
wrapper: ProviderWrapper,
|
||||||
|
});
|
||||||
|
expect(recentlyViewedHook.result.current).toEqual([]);
|
||||||
|
const routerHook = renderHook(() => useRouter());
|
||||||
|
await routerHook.result.current.push({
|
||||||
|
pathname: '/workspace/[workspaceId]/[pageId]',
|
||||||
|
query: {
|
||||||
|
workspaceId,
|
||||||
|
pageId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
routerHook.rerender();
|
||||||
|
const syncHook = renderHook(
|
||||||
|
router => useSyncRecentViewsWithRouter(router),
|
||||||
|
{
|
||||||
|
wrapper: ProviderWrapper,
|
||||||
|
initialProps: routerHook.result.current,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
syncHook.rerender(routerHook.result.current);
|
||||||
|
expect(recentlyViewedHook.result.current).toEqual([
|
||||||
|
{
|
||||||
|
id: 'page0',
|
||||||
|
mode: 'page',
|
||||||
|
title: 'Untitled',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
40
apps/web/src/hooks/affine/use-recent-views.ts
Normal file
40
apps/web/src/hooks/affine/use-recent-views.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { useAtomValue, useSetAtom } from 'jotai';
|
||||||
|
import { NextRouter } from 'next/router';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
workspaceRecentViewsAtom,
|
||||||
|
workspaceRecentViresWriteAtom,
|
||||||
|
} from '../../atoms';
|
||||||
|
import { useCurrentWorkspace } from '../current/use-current-workspace';
|
||||||
|
import { usePageMeta } from '../use-page-meta';
|
||||||
|
|
||||||
|
export function useRecentlyViewed() {
|
||||||
|
const [workspace] = useCurrentWorkspace();
|
||||||
|
const workspaceId = workspace?.id || null;
|
||||||
|
const recentlyViewed = useAtomValue(workspaceRecentViewsAtom);
|
||||||
|
|
||||||
|
if (!workspaceId) return [];
|
||||||
|
return recentlyViewed[workspaceId] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSyncRecentViewsWithRouter(router: NextRouter) {
|
||||||
|
const [workspace] = useCurrentWorkspace();
|
||||||
|
const workspaceId = workspace?.id || null;
|
||||||
|
const blockSuiteWorkspace = workspace?.blockSuiteWorkspace || null;
|
||||||
|
const pageId = router.query.pageId as string;
|
||||||
|
const set = useSetAtom(workspaceRecentViresWriteAtom);
|
||||||
|
const meta = usePageMeta(blockSuiteWorkspace).find(
|
||||||
|
meta => meta.id === pageId
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!workspaceId) return;
|
||||||
|
if (pageId && meta) {
|
||||||
|
set(workspaceId, {
|
||||||
|
title: meta.title || 'Untitled',
|
||||||
|
id: pageId as string,
|
||||||
|
mode: meta.mode || 'page',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [pageId, meta, workspaceId, set]);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import React, { useEffect } from 'react';
|
|||||||
|
|
||||||
import { Unreachable } from '../../../components/affine/affine-error-eoundary';
|
import { Unreachable } from '../../../components/affine/affine-error-eoundary';
|
||||||
import { PageLoading } from '../../../components/pure/loading';
|
import { PageLoading } from '../../../components/pure/loading';
|
||||||
|
import { useSyncRecentViewsWithRouter } from '../../../hooks/affine/use-recent-views';
|
||||||
import { useCurrentPageId } from '../../../hooks/current/use-current-page-id';
|
import { useCurrentPageId } from '../../../hooks/current/use-current-page-id';
|
||||||
import { useCurrentWorkspace } from '../../../hooks/current/use-current-workspace';
|
import { useCurrentWorkspace } from '../../../hooks/current/use-current-workspace';
|
||||||
import { useSyncRouterWithCurrentWorkspaceAndPage } from '../../../hooks/use-sync-router-with-current-workspace-and-page';
|
import { useSyncRouterWithCurrentWorkspaceAndPage } from '../../../hooks/use-sync-router-with-current-workspace-and-page';
|
||||||
@@ -27,6 +28,7 @@ function enableFullFlags(blockSuiteWorkspace: BlockSuiteWorkspace) {
|
|||||||
const WorkspaceDetail: React.FC = () => {
|
const WorkspaceDetail: React.FC = () => {
|
||||||
const [pageId] = useCurrentPageId();
|
const [pageId] = useCurrentPageId();
|
||||||
const [currentWorkspace] = useCurrentWorkspace();
|
const [currentWorkspace] = useCurrentWorkspace();
|
||||||
|
useSyncRecentViewsWithRouter(useRouter());
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentWorkspace) {
|
if (currentWorkspace) {
|
||||||
enableFullFlags(currentWorkspace.blockSuiteWorkspace);
|
enableFullFlags(currentWorkspace.blockSuiteWorkspace);
|
||||||
|
|||||||
Reference in New Issue
Block a user