mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-01 17:50:50 +08:00
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:
@@ -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;
|
||||
};
|
||||
+17
-3
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user