From 46a2ad750f7d4132ba24f1af643e0e0e6f35f3d9 Mon Sep 17 00:00:00 2001 From: Cats Juice Date: Thu, 17 Jul 2025 10:32:01 +0800 Subject: [PATCH] feat(core): add a two-step confirm page to create new journal (#13240) close AF-2750; ## Summary by CodeRabbit * **New Features** * Introduced a new workspace journals page with date-based navigation, placeholder UI, and the ability to create daily journals directly from the page. * Added a "Today" button for quick navigation to the current day's journal when viewing other dates. * **Improvements** * Enhanced the journal document title display with improved date formatting and flexible styling. * Expanded the active state for the journal sidebar button to cover all journal-related routes. * Updated journal navigation to open existing entries directly or navigate to filtered journal listings. * **Bug Fixes** * Improved date handling and navigation logic for journal entries to ensure accurate redirection and creation flows. * **Style** * Added new styles for the workspace journals page, including headers, placeholders, and buttons. * **Localization** * Added English translations for journal placeholder text and create journal prompts. * **Tests** * Added confirmation steps in journal creation flows to improve test reliability. --- .../block-suite-editor/journal-doc-title.tsx | 32 +++- .../journal/date-picker.tsx | 27 +++- .../root-app-sidebar/journal-button.tsx | 2 +- .../core/src/desktop/pages/journals/index.tsx | 11 -- .../detail-page/tabs/journal/index.tsx | 20 ++- .../pages/workspace/journals/index.css.ts | 68 +++++++++ .../pages/workspace/journals/index.tsx | 141 ++++++++++++++++++ .../core/src/desktop/workbench-router.ts | 2 +- packages/frontend/i18n/src/i18n.gen.ts | 8 + packages/frontend/i18n/src/resources/en.json | 2 + tests/affine-desktop/e2e/tabs.spec.ts | 1 + tests/affine-local/e2e/journal.spec.ts | 6 +- tests/affine-local/e2e/template.spec.ts | 8 +- tests/kit/src/utils/load-page.ts | 6 + 14 files changed, 304 insertions(+), 30 deletions(-) delete mode 100644 packages/frontend/core/src/desktop/pages/journals/index.tsx create mode 100644 packages/frontend/core/src/desktop/pages/workspace/journals/index.css.ts create mode 100644 packages/frontend/core/src/desktop/pages/workspace/journals/index.tsx diff --git a/packages/frontend/core/src/blocksuite/block-suite-editor/journal-doc-title.tsx b/packages/frontend/core/src/blocksuite/block-suite-editor/journal-doc-title.tsx index bbeb343cea..f5748d9bd4 100644 --- a/packages/frontend/core/src/blocksuite/block-suite-editor/journal-doc-title.tsx +++ b/packages/frontend/core/src/blocksuite/block-suite-editor/journal-doc-title.tsx @@ -9,20 +9,40 @@ import * as styles from './styles.css'; export const BlocksuiteEditorJournalDocTitle = ({ page }: { page: Store }) => { const journalService = useService(JournalService); const journalDateStr = useLiveData(journalService.journalDate$(page.id)); - const journalDate = journalDateStr ? dayjs(journalDateStr) : null; - const isTodayJournal = useLiveData(journalService.journalToday$(page.id)); - const localizedJournalDate = i18nTime(journalDateStr, { + + return ; +}; + +export const BlocksuiteEditorJournalDocTitleUI = ({ + date: dateStr, + overrideClassName, +}: { + date?: string; + /** + * The `doc-title-container` class style is defined in editor, + * which means if we use this component outside editor, the style will not work, + * so we provide a className to override + */ + overrideClassName?: string; +}) => { + const localizedJournalDate = i18nTime(dateStr, { absolute: { accuracy: 'day' }, }); const t = useI18n(); // TODO(catsjuice): i18n - const day = journalDate?.format('dddd') ?? null; + const today = dayjs(); + const date = dayjs(dateStr); + const day = dayjs(date).format('dddd') ?? null; + const isToday = date.isSame(today, 'day'); return ( -
+
{localizedJournalDate} - {isTodayJournal ? ( + {isToday ? ( {t['com.affine.today']()} diff --git a/packages/frontend/core/src/blocksuite/block-suite-header/journal/date-picker.tsx b/packages/frontend/core/src/blocksuite/block-suite-header/journal/date-picker.tsx index f8a273f140..99d5b91aee 100644 --- a/packages/frontend/core/src/blocksuite/block-suite-header/journal/date-picker.tsx +++ b/packages/frontend/core/src/blocksuite/block-suite-header/journal/date-picker.tsx @@ -1,11 +1,14 @@ import type { WeekDatePickerHandle } from '@affine/component'; import { WeekDatePicker } from '@affine/component'; -import { useJournalRouteHelper } from '@affine/core/components/hooks/use-journal'; -import { JournalService } from '@affine/core/modules/journal'; +import { + JOURNAL_DATE_FORMAT, + JournalService, +} from '@affine/core/modules/journal'; +import { WorkbenchService } from '@affine/core/modules/workbench'; import type { Store } from '@blocksuite/affine/store'; import { useLiveData, useService } from '@toeverything/infra'; import dayjs from 'dayjs'; -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; export interface JournalWeekDatePickerProps { page: Store; @@ -17,17 +20,29 @@ export const JournalWeekDatePicker = ({ page }: JournalWeekDatePickerProps) => { const journalService = useService(JournalService); const journalDateStr = useLiveData(journalService.journalDate$(page.id)); const journalDate = journalDateStr ? dayjs(journalDateStr) : null; - const { openJournal } = useJournalRouteHelper(); const [date, setDate] = useState( - (journalDate ?? dayjs()).format('YYYY-MM-DD') + (journalDate ?? dayjs()).format(JOURNAL_DATE_FORMAT) ); + const workbench = useService(WorkbenchService).workbench; useEffect(() => { if (!journalDate) return; - setDate(journalDate.format('YYYY-MM-DD')); + setDate(journalDate.format(JOURNAL_DATE_FORMAT)); handleRef.current?.setCursor?.(journalDate); }, [journalDate]); + const openJournal = useCallback( + (date: string) => { + const docs = journalService.journalsByDate$(date).value; + if (docs.length > 0) { + workbench.openDoc(docs[0].id, { at: 'active' }); + } else { + workbench.open(`/journals?date=${date}`, { at: 'active' }); + } + }, + [journalService, workbench] + ); + return ( { return ( } > diff --git a/packages/frontend/core/src/desktop/pages/journals/index.tsx b/packages/frontend/core/src/desktop/pages/journals/index.tsx deleted file mode 100644 index 4860ac287d..0000000000 --- a/packages/frontend/core/src/desktop/pages/journals/index.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { useJournalRouteHelper } from '@affine/core/components/hooks/use-journal'; -import { useEffect } from 'react'; - -// this route page acts as a redirector to today's journal -export const Component = () => { - const { openToday } = useJournalRouteHelper(); - useEffect(() => { - openToday({ replaceHistory: true }); - }, [openToday]); - return null; -}; diff --git a/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/journal/index.tsx b/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/journal/index.tsx index a2ced6da2f..4bfbc949a8 100644 --- a/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/journal/index.tsx +++ b/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/journal/index.tsx @@ -9,7 +9,6 @@ import { useConfirmModal, } from '@affine/component'; import { Guard } from '@affine/core/components/guard'; -import { useJournalRouteHelper } from '@affine/core/components/hooks/use-journal'; import { MoveToTrash } from '@affine/core/components/page-list'; import { type DocRecord, @@ -18,7 +17,10 @@ import { } from '@affine/core/modules/doc'; import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta'; import { JournalService } from '@affine/core/modules/journal'; -import { WorkbenchLink } from '@affine/core/modules/workbench'; +import { + WorkbenchLink, + WorkbenchService, +} from '@affine/core/modules/workbench'; import { useI18n } from '@affine/i18n'; import { CalendarXmarkIcon, EditIcon } from '@blocksuite/icons/rc'; import { @@ -103,13 +105,25 @@ const mobile = environment.isMobile; export const EditorJournalPanel = () => { const t = useI18n(); const doc = useServiceOptional(DocService)?.doc; + const workbench = useService(WorkbenchService).workbench; const journalService = useService(JournalService); const journalDateStr = useLiveData( doc ? journalService.journalDate$(doc.id) : null ); const journalDate = journalDateStr ? dayjs(journalDateStr) : null; const isJournal = !!journalDate; - const { openJournal } = useJournalRouteHelper(); + + const openJournal = useCallback( + (date: string) => { + const docs = journalService.journalsByDate$(date).value; + if (docs.length > 0) { + workbench.openDoc(docs[0].id, { at: 'active' }); + } else { + workbench.open(`/journals?date=${date}`, { at: 'active' }); + } + }, + [journalService, workbench] + ); const onDateSelect = useCallback( (date: string) => { diff --git a/packages/frontend/core/src/desktop/pages/workspace/journals/index.css.ts b/packages/frontend/core/src/desktop/pages/workspace/journals/index.css.ts new file mode 100644 index 0000000000..615c981ad5 --- /dev/null +++ b/packages/frontend/core/src/desktop/pages/workspace/journals/index.css.ts @@ -0,0 +1,68 @@ +import { cssVarV2 } from '@toeverything/theme/v2'; +import { style } from '@vanilla-extract/css'; + +export const header = style({ + display: 'flex', + width: '100%', + height: '100%', + alignItems: 'center', + justifyContent: 'center', + position: 'relative', + padding: '0 36px', +}); + +export const todayButton = style({ + position: 'absolute', + right: 0, +}); + +export const body = style({ + width: '100%', + height: '100%', + borderTop: `0.5px solid ${cssVarV2.layer.insideBorder.border}`, +}); + +export const content = style({ + maxWidth: 944, + padding: '0px 50px', + margin: '0 auto', +}); + +export const docTitleContainer = style({ + color: cssVarV2.text.primary, + fontSize: 40, + lineHeight: '50px', + fontWeight: 700, + padding: '38px 0', +}); + +export const placeholder = style({ + height: 200, + width: '100%', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + border: `1px dashed ${cssVarV2.layer.insideBorder.border}`, + borderRadius: 8, +}); + +export const placeholderIcon = style({ + width: 36, + height: 36, + borderRadius: 36, + backgroundColor: cssVarV2.button.emptyIconBackground, + color: cssVarV2.icon.primary, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: 20, + marginBottom: 4, +}); + +export const placeholderText = style({ + fontSize: 14, + lineHeight: '22px', + marginBottom: 16, + color: cssVarV2.text.tertiary, +}); diff --git a/packages/frontend/core/src/desktop/pages/workspace/journals/index.tsx b/packages/frontend/core/src/desktop/pages/workspace/journals/index.tsx new file mode 100644 index 0000000000..ffbbac0307 --- /dev/null +++ b/packages/frontend/core/src/desktop/pages/workspace/journals/index.tsx @@ -0,0 +1,141 @@ +import { + Button, + WeekDatePicker, + type WeekDatePickerHandle, +} from '@affine/component'; +import { BlocksuiteEditorJournalDocTitleUI } from '@affine/core/blocksuite/block-suite-editor/journal-doc-title'; +import { + JOURNAL_DATE_FORMAT, + JournalService, +} from '@affine/core/modules/journal'; +import { + ViewBody, + ViewHeader, + ViewIcon, + ViewService, + ViewTitle, + WorkbenchService, +} from '@affine/core/modules/workbench'; +import { useI18n } from '@affine/i18n'; +import { TodayIcon } from '@blocksuite/icons/rc'; +import { useLiveData, useService } from '@toeverything/infra'; +import dayjs from 'dayjs'; +import type { Location } from 'history'; +import { useCallback, useLayoutEffect, useRef, useState } from 'react'; + +import { AllDocSidebarTabs } from '../layouts/all-doc-sidebar-tabs'; +import * as styles from './index.css'; + +function getDateFromUrl(location: Location) { + const searchParams = new URLSearchParams(location.search); + const date = searchParams.get('date') + ? dayjs(searchParams.get('date')) + : dayjs(); + return date.format(JOURNAL_DATE_FORMAT); +} + +const weekStyle = { maxWidth: 800, width: '100%' }; +// this route page acts as a redirector to today's journal +export const Component = () => { + const handleRef = useRef(null); + + const t = useI18n(); + const journalService = useService(JournalService); + const workbench = useService(WorkbenchService).workbench; + const view = useService(ViewService).view; + const location = useLiveData(view.location$); + const dateString = getDateFromUrl(location); + const todayString = dayjs().format(JOURNAL_DATE_FORMAT); + const isToday = dateString === todayString; + + const [redirecting, setRedirecting] = useState(false); + const [ready, setReady] = useState(false); + + const createJournal = useCallback(() => { + if (redirecting) return; + setRedirecting(true); + const doc = journalService.ensureJournalByDate(dateString); + workbench.openDoc(doc.id, { + replaceHistory: true, + at: 'active', + }); + }, [dateString, journalService, redirecting, workbench]); + + const openJournal = useCallback( + (date: string) => { + workbench.open(`/journals?date=${date}`, { at: 'active' }); + }, + [workbench] + ); + + useLayoutEffect(() => { + // only handle current route + if (!location.pathname.startsWith('/journals')) return; + + // check if the journal is created + const docs = journalService.journalsByDate$(dateString).value; + if (docs.length === 0) { + setReady(true); + return; + } + + // if created, redirect to the journal + const journal = docs[0]; + workbench.openDoc(journal.id, { replaceHistory: true, at: 'active' }); + }, [dateString, journalService, location.pathname, view, workbench]); + + if (!ready) return null; + + return ( + <> + + + +
+ + + {!isToday ? ( + + ) : null} +
+
+ +
+
+ +
+
+ +
+
+ {t['com.affine.journal.placeholder.title']()} +
+ +
+
+
+
+ + + ); +}; diff --git a/packages/frontend/core/src/desktop/workbench-router.ts b/packages/frontend/core/src/desktop/workbench-router.ts index 04d6a6adf6..fa591c559a 100644 --- a/packages/frontend/core/src/desktop/workbench-router.ts +++ b/packages/frontend/core/src/desktop/workbench-router.ts @@ -39,7 +39,7 @@ export const workbenchRoutes = [ }, { path: '/journals', - lazy: () => import('./pages/journals'), + lazy: () => import('./pages/workspace/journals'), }, { path: '/settings', diff --git a/packages/frontend/i18n/src/i18n.gen.ts b/packages/frontend/i18n/src/i18n.gen.ts index c74932f441..863dc1652d 100644 --- a/packages/frontend/i18n/src/i18n.gen.ts +++ b/packages/frontend/i18n/src/i18n.gen.ts @@ -2493,6 +2493,14 @@ export function useAFFiNEI18N(): { * `Updated` */ ["com.affine.journal.updated-today"](): string; + /** + * `No Journal` + */ + ["com.affine.journal.placeholder.title"](): string; + /** + * `Create Daily Journal` + */ + ["com.affine.journal.placeholder.create"](): string; /** * `Just now` */ diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index c65c9b90a5..1d28ead880 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -622,6 +622,8 @@ "com.affine.journal.daily-count-created-empty-tips": "You haven't created anything yet", "com.affine.journal.daily-count-updated-empty-tips": "You haven't updated anything yet", "com.affine.journal.updated-today": "Updated", + "com.affine.journal.placeholder.title": "No Journal", + "com.affine.journal.placeholder.create": "Create Daily Journal", "com.affine.just-now": "Just now", "com.affine.keyboardShortcuts.appendDailyNote": "Append to daily note", "com.affine.keyboardShortcuts.bodyText": "Body text", diff --git a/tests/affine-desktop/e2e/tabs.spec.ts b/tests/affine-desktop/e2e/tabs.spec.ts index 817c35777b..59ebe21f38 100644 --- a/tests/affine-desktop/e2e/tabs.spec.ts +++ b/tests/affine-desktop/e2e/tabs.spec.ts @@ -78,6 +78,7 @@ test('tab title will change when navigating', async ({ page }) => { // go to today's journal await page.getByTestId('slider-bar-journals-button').click(); + await page.getByTestId('confirm-create-journal-button').click(); await expect(page.locator('.doc-title-container')).toContainText('Today'); const dateString = await page .locator('.doc-title-container > span:first-of-type') diff --git a/tests/affine-local/e2e/journal.spec.ts b/tests/affine-local/e2e/journal.spec.ts index c5310d7055..82c07ba06c 100644 --- a/tests/affine-local/e2e/journal.spec.ts +++ b/tests/affine-local/e2e/journal.spec.ts @@ -1,5 +1,8 @@ import { test } from '@affine-test/kit/playwright'; -import { openHomePage } from '@affine-test/kit/utils/load-page'; +import { + confirmCreateJournal, + openHomePage, +} from '@affine-test/kit/utils/load-page'; import { waitForEditorLoad } from '@affine-test/kit/utils/page-logic'; import { expect, type Locator, type Page } from '@playwright/test'; @@ -84,6 +87,7 @@ async function createPageAndTurnIntoJournal(page: Page) { test('Create a journal from sidebar', async ({ page }) => { await openHomePage(page); await page.getByTestId('slider-bar-journals-button').click(); + await confirmCreateJournal(page); await waitForEditorLoad(page); await isJournalEditor(page); }); diff --git a/tests/affine-local/e2e/template.spec.ts b/tests/affine-local/e2e/template.spec.ts index e0fd65e2d8..dbf928ebdd 100644 --- a/tests/affine-local/e2e/template.spec.ts +++ b/tests/affine-local/e2e/template.spec.ts @@ -1,5 +1,8 @@ import { test } from '@affine-test/kit/playwright'; -import { openHomePage } from '@affine-test/kit/utils/load-page'; +import { + confirmCreateJournal, + openHomePage, +} from '@affine-test/kit/utils/load-page'; import { waitForEditorLoad } from '@affine-test/kit/utils/page-logic'; import { expect, type Locator, type Page } from '@playwright/test'; @@ -183,6 +186,7 @@ test('set default template for journal', async ({ page }) => { // by default create a journal, should not use template await page.getByTestId('slider-bar-journals-button').click(); + await confirmCreateJournal(page); await waitForEditorLoad(page); await expect( page.getByText('This is a journal template doc') @@ -200,6 +204,7 @@ test('set default template for journal', async ({ page }) => { const prevWeekButton = page.getByTestId('week-picker-prev'); await prevWeekButton.click(); await page.getByTestId('week-picker-day').first().click(); + await confirmCreateJournal(page); await waitForEditorLoad(page); await expect(page.getByText('This is a page template doc')).toBeVisible(); @@ -212,6 +217,7 @@ test('set default template for journal', async ({ page }) => { // create a new journal await prevWeekButton.click(); await page.getByTestId('week-picker-day').first().click(); + await confirmCreateJournal(page); await waitForEditorLoad(page); await expect(page.getByText('This is a journal template doc')).toBeVisible(); }); diff --git a/tests/kit/src/utils/load-page.ts b/tests/kit/src/utils/load-page.ts index 5367119dec..3f51dc8233 100644 --- a/tests/kit/src/utils/load-page.ts +++ b/tests/kit/src/utils/load-page.ts @@ -15,8 +15,14 @@ export async function open404Page(page: Page) { await page.goto(`${coreUrl}/404`); } +export async function confirmCreateJournal(page: Page) { + const confirmButton = page.getByTestId('confirm-create-journal-button'); + await confirmButton.click(); +} + export async function openJournalsPage(page: Page) { await page.getByTestId('slider-bar-journals-button').click(); + await confirmCreateJournal(page); await expect( page.locator('.doc-title-container:has-text("Today")') ).toBeVisible();