feat(core): add a two-step confirm page to create new journal (#13240)

close AF-2750;

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Cats Juice
2025-07-17 10:32:01 +08:00
committed by GitHub
parent 3949714618
commit 46a2ad750f
14 changed files with 304 additions and 30 deletions
@@ -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 <BlocksuiteEditorJournalDocTitleUI date={journalDateStr} />;
};
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 (
<div className="doc-title-container" data-testid="journal-title">
<div
className={overrideClassName ?? 'doc-title-container'}
data-testid="journal-title"
>
<span data-testid="date">{localizedJournalDate}</span>
{isTodayJournal ? (
{isToday ? (
<span className={styles.titleTodayTag} data-testid="date-today-label">
{t['com.affine.today']()}
</span>
@@ -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 (
<WeekDatePicker
data-testid="journal-week-picker"
@@ -21,7 +21,7 @@ export const AppSidebarJournalButton = () => {
return (
<MenuLinkItem
data-testid="slider-bar-journals-button"
active={isJournal}
active={isJournal || location.pathname.startsWith('/journals')}
to={'/journals'}
icon={<Icon />}
>
@@ -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;
};
@@ -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) => {
@@ -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,
});
@@ -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<WeekDatePickerHandle>(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 (
<>
<ViewTitle title="" />
<ViewIcon icon="journal" />
<ViewHeader>
<div className={styles.header}>
<WeekDatePicker
data-testid="journal-week-picker"
handleRef={handleRef}
style={weekStyle}
value={dateString}
onChange={openJournal}
/>
{!isToday ? (
<Button
className={styles.todayButton}
onClick={() => openJournal(todayString)}
>
{t['com.affine.today']()}
</Button>
) : null}
</div>
</ViewHeader>
<ViewBody>
<div className={styles.body}>
<div className={styles.content}>
<BlocksuiteEditorJournalDocTitleUI
date={dateString}
overrideClassName={styles.docTitleContainer}
/>
<div className={styles.placeholder}>
<div className={styles.placeholderIcon}>
<TodayIcon />
</div>
<div className={styles.placeholderText}>
{t['com.affine.journal.placeholder.title']()}
</div>
<Button
variant="primary"
onClick={createJournal}
data-testid="confirm-create-journal-button"
>
{t['com.affine.journal.placeholder.create']()}
</Button>
</div>
</div>
</div>
</ViewBody>
<AllDocSidebarTabs />
</>
);
};
@@ -39,7 +39,7 @@ export const workbenchRoutes = [
},
{
path: '/journals',
lazy: () => import('./pages/journals'),
lazy: () => import('./pages/workspace/journals'),
},
{
path: '/settings',
+8
View File
@@ -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`
*/
@@ -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",
+1
View File
@@ -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')
+5 -1
View File
@@ -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);
});
+7 -1
View File
@@ -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();
});
+6
View File
@@ -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();