From 8f92be926bc0d5453129e164b4c4f99c91b23f93 Mon Sep 17 00:00:00 2001 From: CatsJuice Date: Fri, 18 Oct 2024 10:03:08 +0000 Subject: [PATCH] feat(core): new "is journal" page property (#8525) close AF-1450, AF-1451, AF-14552 --- .../common/infra/src/modules/doc/constants.ts | 5 + .../affine/reference-link/index.tsx | 11 +- .../block-suite-editor/journal-doc-title.tsx | 21 ++- .../block-suite-editor/lit-adaper.tsx | 5 +- .../block-suite-editor/styles.css.ts | 1 + .../journal/date-picker.tsx | 12 +- .../src/components/doc-properties/table.tsx | 2 + .../doc-properties/types/constant.tsx | 9 ++ .../doc-properties/types/journal.css.ts | 38 +++++ .../doc-properties/types/journal.tsx | 134 ++++++++++++++++++ .../detail-page/detail-page-header.tsx | 5 +- .../workspace/detail-page/tabs/journal.css.ts | 14 ++ .../workspace/detail-page/tabs/journal.tsx | 117 +++++++++++---- .../src/modules/journal/services/journal.ts | 17 +++ .../core/src/modules/journal/store/journal.ts | 23 +++ .../i18n/src/i18n-completenesses.json | 2 +- packages/frontend/i18n/src/resources/en.json | 3 + tests/affine-local/e2e/journal.spec.ts | 122 ++++++++++++++++ .../affine-local/e2e/page-properties.spec.ts | 2 + 19 files changed, 494 insertions(+), 49 deletions(-) create mode 100644 packages/frontend/core/src/components/doc-properties/types/journal.css.ts create mode 100644 packages/frontend/core/src/components/doc-properties/types/journal.tsx create mode 100644 tests/affine-local/e2e/journal.spec.ts diff --git a/packages/common/infra/src/modules/doc/constants.ts b/packages/common/infra/src/modules/doc/constants.ts index cf10819081..89d802060f 100644 --- a/packages/common/infra/src/modules/doc/constants.ts +++ b/packages/common/infra/src/modules/doc/constants.ts @@ -16,4 +16,9 @@ export const BUILT_IN_CUSTOM_PROPERTY_TYPE = [ type: 'docPrimaryMode', show: 'always-hide', }, + { + id: 'journal', + type: 'journal', + show: 'always-hide', + }, ] as DocCustomPropertyInfo[]; diff --git a/packages/frontend/core/src/components/affine/reference-link/index.tsx b/packages/frontend/core/src/components/affine/reference-link/index.tsx index cdeef62f04..f44a876a7c 100644 --- a/packages/frontend/core/src/components/affine/reference-link/index.tsx +++ b/packages/frontend/core/src/components/affine/reference-link/index.tsx @@ -1,5 +1,5 @@ -import { useJournalInfoHelper } from '@affine/core/components/hooks/use-journal'; import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta'; +import { JournalService } from '@affine/core/modules/journal'; import { PeekViewService } from '@affine/core/modules/peek-view/services/peek-view'; import { useInsidePeekView } from '@affine/core/modules/peek-view/view/modal-container'; import { WorkbenchLink } from '@affine/core/modules/workbench'; @@ -30,7 +30,8 @@ export function AffinePageReference({ params?: URLSearchParams; }) { const docDisplayMetaService = useService(DocDisplayMetaService); - const journalHelper = useJournalInfoHelper(); + const journalService = useService(JournalService); + const isJournal = !!useLiveData(journalService.journalDate$(pageId)); const i18n = useI18n(); let linkWithMode: DocMode | null = null; @@ -67,7 +68,6 @@ export function AffinePageReference({ const peekView = useService(PeekViewService).peekView; const isInPeekView = useInsidePeekView(); - const isJournal = journalHelper.isPageJournal(pageId); const onClick = useCallback( (e: React.MouseEvent) => { @@ -127,7 +127,8 @@ export function AffineSharedPageReference({ params?: URLSearchParams; }) { const docDisplayMetaService = useService(DocDisplayMetaService); - const journalHelper = useJournalInfoHelper(); + const journalService = useService(JournalService); + const isJournal = !!useLiveData(journalService.journalDate$(pageId)); const i18n = useI18n(); let linkWithMode: DocMode | null = null; @@ -159,8 +160,6 @@ export function AffineSharedPageReference({ const [refreshKey, setRefreshKey] = useState(() => nanoid()); - const isJournal = journalHelper.isPageJournal(pageId); - const onClick = useCallback( (e: React.MouseEvent) => { if (isJournal) { diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-editor/journal-doc-title.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-editor/journal-doc-title.tsx index 6cde60aac8..eb6d3a69f9 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-editor/journal-doc-title.tsx +++ b/packages/frontend/core/src/components/blocksuite/block-suite-editor/journal-doc-title.tsx @@ -1,25 +1,32 @@ -import { useJournalInfoHelper } from '@affine/core/components/hooks/use-journal'; -import { useI18n } from '@affine/i18n'; +import { JournalService } from '@affine/core/modules/journal'; +import { i18nTime, useI18n } from '@affine/i18n'; import type { Doc } from '@blocksuite/affine/store'; +import { useLiveData, useService } from '@toeverything/infra'; +import dayjs from 'dayjs'; import * as styles from './styles.css'; export const BlocksuiteEditorJournalDocTitle = ({ page }: { page: Doc }) => { - const { localizedJournalDate, isTodayJournal, journalDate } = - useJournalInfoHelper(page.id); + 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, { + absolute: { accuracy: 'day' }, + }); const t = useI18n(); // TODO(catsjuice): i18n const day = journalDate?.format('dddd') ?? null; return ( - - {localizedJournalDate} +
+ {localizedJournalDate} {isTodayJournal ? ( {t['com.affine.today']()} ) : ( {day} )} - +
); }; diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-editor/lit-adaper.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-editor/lit-adaper.tsx index 4017f2d99b..0a131f6235 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-editor/lit-adaper.tsx +++ b/packages/frontend/core/src/components/blocksuite/block-suite-editor/lit-adaper.tsx @@ -3,10 +3,10 @@ import { useConfirmModal, useLitPortalFactory, } from '@affine/component'; -import { useJournalInfoHelper } from '@affine/core/components/hooks/use-journal'; import { ServerConfigService } from '@affine/core/modules/cloud'; import { EditorService } from '@affine/core/modules/editor'; import { EditorSettingService } from '@affine/core/modules/editor-settting'; +import { JournalService } from '@affine/core/modules/journal'; import { toURLSearchParams } from '@affine/core/modules/navigation'; import { PeekViewService } from '@affine/core/modules/peek-view/services/peek-view'; import type { DocMode } from '@blocksuite/affine/blocks'; @@ -196,7 +196,8 @@ export const BlocksuiteDocEditor = forwardRef< ) { const titleRef = useRef(null); const docRef = useRef(null); - const { isJournal } = useJournalInfoHelper(page.id); + const journalService = useService(JournalService); + const isJournal = !!useLiveData(journalService.journalDate$(page.id)); const editorSettingService = useService(EditorSettingService); diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-editor/styles.css.ts b/packages/frontend/core/src/components/blocksuite/block-suite-editor/styles.css.ts index 93321c9c58..32558b5aae 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-editor/styles.css.ts +++ b/packages/frontend/core/src/components/blocksuite/block-suite-editor/styles.css.ts @@ -41,6 +41,7 @@ const titleTagBasic = style({ padding: '0 4px', borderRadius: '4px', marginLeft: '4px', + lineHeight: '0px', }); export const titleDayTag = style([ titleTagBasic, diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-header/journal/date-picker.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-header/journal/date-picker.tsx index 13587d5c67..f07b861692 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-header/journal/date-picker.tsx +++ b/packages/frontend/core/src/components/blocksuite/block-suite-header/journal/date-picker.tsx @@ -1,10 +1,9 @@ import type { WeekDatePickerHandle } from '@affine/component'; import { WeekDatePicker } from '@affine/component'; -import { - useJournalInfoHelper, - useJournalRouteHelper, -} from '@affine/core/components/hooks/use-journal'; +import { useJournalRouteHelper } from '@affine/core/components/hooks/use-journal'; +import { JournalService } from '@affine/core/modules/journal'; import type { Doc, DocCollection } from '@blocksuite/affine/store'; +import { useLiveData, useService } from '@toeverything/infra'; import dayjs from 'dayjs'; import { useEffect, useRef, useState } from 'react'; @@ -19,7 +18,9 @@ export const JournalWeekDatePicker = ({ page, }: JournalWeekDatePickerProps) => { const handleRef = useRef(null); - const { journalDate } = useJournalInfoHelper(page.id); + const journalService = useService(JournalService); + const journalDateStr = useLiveData(journalService.journalDate$(page.id)); + const journalDate = journalDateStr ? dayjs(journalDateStr) : null; const { openJournal } = useJournalRouteHelper(docCollection); const [date, setDate] = useState( (journalDate ?? dayjs()).format('YYYY-MM-DD') @@ -33,6 +34,7 @@ export const JournalWeekDatePicker = ({ return ( } className={styles.propertyActionButton} + data-testid="add-property-button" > {t['com.affine.page-properties.add-property']()} diff --git a/packages/frontend/core/src/components/doc-properties/types/constant.tsx b/packages/frontend/core/src/components/doc-properties/types/constant.tsx index c442aca8b0..c8da5974e4 100644 --- a/packages/frontend/core/src/components/doc-properties/types/constant.tsx +++ b/packages/frontend/core/src/components/doc-properties/types/constant.tsx @@ -7,12 +7,14 @@ import { NumberIcon, TagIcon, TextIcon, + TodayIcon, } from '@blocksuite/icons/rc'; import { CheckboxValue } from './checkbox'; import { CreatedByValue, UpdatedByValue } from './created-updated-by'; import { DateValue } from './date'; import { DocPrimaryModeValue } from './doc-primary-mode'; +import { JournalValue } from './journal'; import { NumberValue } from './number'; import { TagsValue } from './tags'; import { TextValue } from './text'; @@ -61,6 +63,13 @@ export const DocPropertyTypes = { value: DocPrimaryModeValue, name: 'com.affine.page-properties.property.docPrimaryMode', }, + journal: { + icon: TodayIcon, + value: JournalValue, + name: 'com.affine.page-properties.property.journal', + uniqueId: 'journal', + renameable: false, + }, } as Record< string, { diff --git a/packages/frontend/core/src/components/doc-properties/types/journal.css.ts b/packages/frontend/core/src/components/doc-properties/types/journal.css.ts new file mode 100644 index 0000000000..787a65700a --- /dev/null +++ b/packages/frontend/core/src/components/doc-properties/types/journal.css.ts @@ -0,0 +1,38 @@ +import { cssVar } from '@toeverything/theme'; +import { cssVarV2 } from '@toeverything/theme/v2'; +import { style } from '@vanilla-extract/css'; + +export const property = style({ + padding: 4, +}); + +export const root = style({ + height: '100%', + display: 'flex', + gap: 2, + alignItems: 'center', +}); + +export const checkbox = style({ + fontSize: 24, + color: cssVarV2('icon/primary'), +}); + +export const date = style({ + fontSize: cssVar('fontSm'), + color: cssVarV2('text/primary'), + lineHeight: '22px', + padding: '0 4px', + borderRadius: 4, + ':hover': { + background: cssVarV2('layer/background/hoverOverlay'), + }, +}); + +export const duplicateTag = style({ + padding: '0 8px', + border: `1px solid ${cssVarV2('database/border')}`, + background: cssVarV2('layer/background/error'), + color: cssVarV2('toast/iconState/error'), + borderRadius: 4, +}); diff --git a/packages/frontend/core/src/components/doc-properties/types/journal.tsx b/packages/frontend/core/src/components/doc-properties/types/journal.tsx new file mode 100644 index 0000000000..79bdb5f8a0 --- /dev/null +++ b/packages/frontend/core/src/components/doc-properties/types/journal.tsx @@ -0,0 +1,134 @@ +import { Checkbox, DatePicker, Menu, PropertyValue } from '@affine/component'; +import { JournalService } from '@affine/core/modules/journal'; +import { WorkbenchService } from '@affine/core/modules/workbench'; +import { ViewService } from '@affine/core/modules/workbench/services/view'; +import { i18nTime, useI18n } from '@affine/i18n'; +import { + DocService, + useLiveData, + useService, + useServiceOptional, +} from '@toeverything/infra'; +import dayjs from 'dayjs'; +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import * as styles from './journal.css'; + +export const JournalValue = () => { + const t = useI18n(); + + const journalService = useService(JournalService); + const doc = useService(DocService).doc; + const journalDate = useLiveData(journalService.journalDate$(doc.id)); + const checked = !!journalDate; + + const [selectedDate, setSelectedDate] = useState( + dayjs().format('YYYY-MM-DD') + ); + const [showDatePicker, setShowDatePicker] = useState(false); + const displayDate = useMemo( + () => + i18nTime(selectedDate, { + absolute: { accuracy: 'day' }, + }), + [selectedDate] + ); + const docs = useLiveData( + useMemo( + () => journalService.journalsByDate$(selectedDate), + [journalService, selectedDate] + ) + ); + const conflict = docs.length > 1; + + useEffect(() => { + if (journalDate) setSelectedDate(journalDate); + }, [journalDate]); + + const handleDateSelect = useCallback( + (day: string) => { + const date = dayjs(day).format('YYYY-MM-DD'); + setSelectedDate(date); + journalService.setJournalDate(doc.id, date); + }, + [journalService, doc.id] + ); + + const handleCheck = useCallback( + (_: unknown, v: boolean) => { + if (!v) { + journalService.removeJournalDate(doc.id); + } else { + handleDateSelect(selectedDate); + } + }, + [handleDateSelect, journalService, doc.id, selectedDate] + ); + + const workbench = useService(WorkbenchService).workbench; + const activeView = useLiveData(workbench.activeView$); + const view = useServiceOptional(ViewService)?.view ?? activeView; + + const handleOpenDuplicate = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + workbench.openSidebar(); + view.activeSidebarTab('journal'); + }, + [view, workbench] + ); + + const toggle = useCallback(() => { + handleCheck(null, !checked); + }, [checked, handleCheck]); + + return ( + +
+ + {checked ? ( + e.stopPropagation(), + sideOffset: 10, + alignOffset: -30, + style: { padding: 20 }, + }} + rootOptions={{ + modal: true, + open: showDatePicker, + onOpenChange: setShowDatePicker, + }} + items={ + + } + > +
+ {displayDate} +
+
+ ) : null} + + {checked && conflict ? ( +
+ {t['com.affine.page-properties.property.journal-duplicated']()} +
+ ) : null} +
+
+ ); +}; diff --git a/packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page-header.tsx b/packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page-header.tsx index dff76da2a2..2ee4772cc9 100644 --- a/packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page-header.tsx +++ b/packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page-header.tsx @@ -14,9 +14,9 @@ import { BlocksuiteHeaderTitle } from '@affine/core/components/blocksuite/block- import { EditorModeSwitch } from '@affine/core/components/blocksuite/block-suite-mode-switch'; import { useRegisterCopyLinkCommands } from '@affine/core/components/hooks/affine/use-register-copy-link-commands'; import { useDocCollectionPageTitle } from '@affine/core/components/hooks/use-block-suite-workspace-page-title'; -import { useJournalInfoHelper } from '@affine/core/components/hooks/use-journal'; import { HeaderDivider } from '@affine/core/components/pure/header'; import { EditorService } from '@affine/core/modules/editor'; +import { JournalService } from '@affine/core/modules/journal'; import { ViewIcon, ViewTitle } from '@affine/core/modules/workbench'; import type { Doc } from '@blocksuite/affine/store'; import { useLiveData, useService, type Workspace } from '@toeverything/infra'; @@ -155,7 +155,8 @@ export function NormalPageHeader({ page, workspace }: PageHeaderProps) { export function DetailPageHeader(props: PageHeaderProps) { const { page, workspace } = props; - const { isJournal } = useJournalInfoHelper(page.id); + const journalService = useService(JournalService); + const isJournal = !!useLiveData(journalService.journalDate$(page.id)); const isInTrash = page.meta?.trash; useRegisterCopyLinkCommands({ diff --git a/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/journal.css.ts b/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/journal.css.ts index 4bdce37636..25dff600af 100644 --- a/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/journal.css.ts +++ b/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/journal.css.ts @@ -1,4 +1,5 @@ import { cssVar } from '@toeverything/theme'; +import { cssVarV2 } from '@toeverything/theme/v2'; import { style } from '@vanilla-extract/css'; const interactive = style({ @@ -161,6 +162,9 @@ export const pageItemLabel = style({ color: cssVar('primaryColor'), }, }, + display: 'flex', + gap: 6, + alignItems: 'center', }); // conflict @@ -185,6 +189,16 @@ export const journalConflictMoreTrigger = style([ alignItems: 'center', }, ]); +export const duplicateTag = style({ + padding: '0 8px', + border: `1px solid ${cssVarV2('database/border')}`, + background: cssVarV2('layer/background/error'), + color: cssVarV2('toast/iconState/error'), + borderRadius: 4, + fontSize: cssVar('fontXs'), + lineHeight: '20px', + fontWeight: 400, +}); // customize date-picker cell export const journalDateCell = style([ diff --git a/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/journal.tsx b/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/journal.tsx index df892b0e6a..d1064788c2 100644 --- a/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/journal.tsx +++ b/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/journal.tsx @@ -1,16 +1,20 @@ import type { DateCell } from '@affine/component'; -import { DatePicker, IconButton, Menu, Scrollable } from '@affine/component'; -import { useTrashModalHelper } from '@affine/core/components/hooks/affine/use-trash-modal-helper'; import { - useJournalHelper, - useJournalInfoHelper, - useJournalRouteHelper, -} from '@affine/core/components/hooks/use-journal'; + DatePicker, + IconButton, + Menu, + MenuItem, + MenuSeparator, + Scrollable, +} from '@affine/component'; +import { useTrashModalHelper } from '@affine/core/components/hooks/affine/use-trash-modal-helper'; +import { useJournalRouteHelper } from '@affine/core/components/hooks/use-journal'; import { MoveToTrash } from '@affine/core/components/page-list'; import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta'; +import { JournalService } from '@affine/core/modules/journal'; import { WorkbenchLink } from '@affine/core/modules/workbench'; import { useI18n } from '@affine/i18n'; -import { MoreHorizontalIcon } from '@blocksuite/icons/rc'; +import { CalendarXmarkIcon, EditIcon } from '@blocksuite/icons/rc'; import type { DocRecord } from '@toeverything/infra'; import { DocService, @@ -41,8 +45,15 @@ interface PageItemProps extends Omit, 'onClick'> { docId: string; right?: ReactNode; + duplicate?: boolean; } -const PageItem = ({ docId, right, className, ...attrs }: PageItemProps) => { +const PageItem = ({ + docId, + right, + duplicate, + className, + ...attrs +}: PageItemProps) => { const i18n = useI18n(); const docDisplayMetaService = useService(DocDisplayMetaService); const Icon = useLiveData( @@ -53,6 +64,7 @@ const PageItem = ({ docId, right, className, ...attrs }: PageItemProps) => { return ( {
- {title} +
+ {title} + {duplicate ? ( +
+ {i18n['com.affine.page-properties.property.journal-duplicated']()} +
+ ) : null} +
{right}
); @@ -82,7 +101,10 @@ export const EditorJournalPanel = () => { const t = useI18n(); const doc = useService(DocService).doc; const workspace = useService(WorkspaceService).workspace; - const { journalDate, isJournal } = useJournalInfoHelper(doc.id); + const journalService = useService(JournalService); + const journalDateStr = useLiveData(journalService.journalDate$(doc.id)); + const journalDate = journalDateStr ? dayjs(journalDateStr) : null; + const isJournal = !!journalDate; const { openJournal } = useJournalRouteHelper(workspace.docCollection); const onDateSelect = useCallback( @@ -93,12 +115,11 @@ export const EditorJournalPanel = () => { [journalDate, openJournal] ); + const allJournalDates = useLiveData(journalService.allJournalDates$); + const customDayRenderer = useCallback( (cell: DateCell) => { - // TODO(@catsjuice): add a dot to indicate journal - // has performance issue for now, better to calculate it in advance - // const hasJournal = !!getJournalsByDate(cell.date.format('YYYY-MM-DD'))?.length; - const hasJournal = false; + const hasJournal = allJournalDates.has(cell.date.format('YYYY-MM-DD')); return ( ); }, - [isJournal] + [allJournalDates, isJournal] ); return ( -
+
{ + const t = useI18n(); const currentDoc = useService(DocService).doc; + const journalService = useService(JournalService); const { setTrashModal } = useTrashModalHelper(); const handleOpenTrashModal = useCallback( @@ -297,9 +324,19 @@ const ConflictList = ({ }, [setTrashModal] ); + const handleRemoveJournalMark = useCallback( + (docId: string) => { + journalService.removeJournalDate(docId); + }, + [journalService] + ); return ( -
+
{docRecords.map(docRecord => { const isCurrent = docRecord.id === currentDoc.id; return ( @@ -307,17 +344,40 @@ const ConflictList = ({ aria-selected={isCurrent} docId={docRecord.id} key={docRecord.id} + duplicate right={ handleOpenTrashModal(docRecord)} - /> + <> + } + onClick={e => { + e.stopPropagation(); + handleRemoveJournalMark(docRecord.id); + }} + data-testid="journal-conflict-remove-mark" + > + {t[ + 'com.affine.page-properties.property.journal-remove' + ]()} + + + handleOpenTrashModal(docRecord)} + /> + } > - - - + } + /> } /> @@ -329,10 +389,15 @@ const ConflictList = ({ }; const JournalConflictBlock = ({ date }: JournalBlockProps) => { const t = useI18n(); - const workspace = useService(WorkspaceService).workspace; const docRecordList = useService(DocsService).list; - const journalHelper = useJournalHelper(workspace.docCollection); - const docs = journalHelper.getJournalsByDate(date.format('YYYY-MM-DD')); + const journalService = useService(JournalService); + const dateString = date.format('YYYY-MM-DD'); + const docs = useLiveData( + useMemo( + () => journalService.journalsByDate$(dateString), + [dateString, journalService] + ) + ); const docRecords = useLiveData( docRecordList.docs$.map(records => records.filter(v => { diff --git a/packages/frontend/core/src/modules/journal/services/journal.ts b/packages/frontend/core/src/modules/journal/services/journal.ts index 39ec9ed482..d78cf61235 100644 --- a/packages/frontend/core/src/modules/journal/services/journal.ts +++ b/packages/frontend/core/src/modules/journal/services/journal.ts @@ -1,4 +1,5 @@ import { LiveData, Service } from '@toeverything/infra'; +import dayjs from 'dayjs'; import type { JournalStore } from '../store/journal'; @@ -7,15 +8,31 @@ export class JournalService extends Service { super(); } + allJournalDates$ = this.store.allJournalDates$; + journalDate$(docId: string) { return LiveData.from(this.store.watchDocJournalDate(docId), undefined); } + journalToday$(docId: string) { + return LiveData.computed(get => { + const date = get(this.journalDate$(docId)); + if (!date) return false; + return dayjs(date).isSame(dayjs(), 'day'); + }); + } setJournalDate(docId: string, date: string) { this.store.setDocJournalDate(docId, date); } + removeJournalDate(docId: string) { + this.store.removeDocJournalDate(docId); + } + getJournalsByDate(date: string) { return this.store.getDocsByJournalDate(date); } + journalsByDate$(date: string) { + return this.store.docsByJournalDate$(date); + } } diff --git a/packages/frontend/core/src/modules/journal/store/journal.ts b/packages/frontend/core/src/modules/journal/store/journal.ts index 658b50adb9..9b935c997b 100644 --- a/packages/frontend/core/src/modules/journal/store/journal.ts +++ b/packages/frontend/core/src/modules/journal/store/journal.ts @@ -11,6 +11,17 @@ export class JournalStore extends Store { super(); } + allJournalDates$ = LiveData.computed(get => { + return new Set( + get(this.docsService.list.docs$) + .filter(doc => { + const journal = get(doc.properties$.selector(p => p.journal)); + return !!journal && isJournalString(journal); + }) + .map(doc => get(doc.properties$.selector(p => p.journal))) + ); + }); + watchDocJournalDate(docId: string): Observable { return LiveData.computed(get => { const doc = get(this.docsService.list.doc$(docId)); @@ -35,9 +46,21 @@ export class JournalStore extends Store { doc.setProperty('journal', date); } + removeDocJournalDate(docId: string) { + this.setDocJournalDate(docId, ''); + } + getDocsByJournalDate(date: string) { return this.docsService.list.docs$.value.filter( doc => doc.properties$.value.journal === date ); } + docsByJournalDate$(date: string) { + return LiveData.computed(get => { + return get(this.docsService.list.docs$).filter(doc => { + const journal = get(doc.properties$.selector(p => p.journal)); + return journal === date; + }); + }); + } } diff --git a/packages/frontend/i18n/src/i18n-completenesses.json b/packages/frontend/i18n/src/i18n-completenesses.json index 927e300847..faee20552e 100644 --- a/packages/frontend/i18n/src/i18n-completenesses.json +++ b/packages/frontend/i18n/src/i18n-completenesses.json @@ -17,6 +17,6 @@ "ru": 84, "sv-SE": 5, "ur": 3, - "zh-Hans": 100, + "zh-Hans": 99, "zh-Hant": 99 } \ No newline at end of file diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index ebee594a33..43f00535ac 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -652,6 +652,9 @@ "com.affine.page-properties.property.tags": "Tags", "com.affine.page-properties.property.docPrimaryMode": "Doc mode", "com.affine.page-properties.property.text": "Text", + "com.affine.page-properties.property.journal": "Journal", + "com.affine.page-properties.property.journal-duplicated": "Duplicated", + "com.affine.page-properties.property.journal-remove": "Remove journal mark", "com.affine.page-properties.property.updatedBy": "Last edited by", "com.affine.propertySidebar.property-list.section": "Properties", "com.affine.propertySidebar.add-more.section": "Add more properties", diff --git a/tests/affine-local/e2e/journal.spec.ts b/tests/affine-local/e2e/journal.spec.ts new file mode 100644 index 0000000000..c5310d7055 --- /dev/null +++ b/tests/affine-local/e2e/journal.spec.ts @@ -0,0 +1,122 @@ +import { test } from '@affine-test/kit/playwright'; +import { 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'; + +type MaybeDate = string | number | Date; +function isSameDay(d1: MaybeDate, d2: MaybeDate) { + const date1 = new Date(d1); + const date2 = new Date(d2); + return ( + date1.getFullYear() === date2.getFullYear() && + date1.getMonth() === date2.getMonth() && + date1.getDate() === date2.getDate() + ); +} + +function getJournalRow(page: Page) { + return page.locator( + '[data-testid="doc-property-row"][data-info-id="journal"]' + ); +} +async function isJournalEditor(page: Page, maybeDate?: string | number | Date) { + // journal header + const header = page.getByTestId('header'); + const weekPicker = header.getByTestId('journal-week-picker'); + await expect(weekPicker).toBeVisible(); + + // journal title + const journalTitle = page.getByTestId('journal-title'); + await expect(journalTitle).toBeVisible(); + + if (maybeDate) { + const date = (await journalTitle.getByTestId('date').textContent()) ?? ''; + expect(isSameDay(date, maybeDate)).toBeTruthy(); + } +} +async function openPagePropertiesAndAddJournal(page: Page) { + const collapse = page.getByTestId('page-info-collapse'); + const open = await collapse.getAttribute('aria-expanded'); + if (open?.toLowerCase() !== 'true') { + await collapse.click(); + } + + // add if not exists + if ((await getJournalRow(page).count()) === 0) { + const addPropertyButton = page.getByTestId('add-property-button'); + if (!(await addPropertyButton.isVisible())) { + await page.getByTestId('property-collapsible-button').click(); + } + await addPropertyButton.click(); + await page + .locator('[role="menuitem"][data-property-type="journal"]') + .click(); + await page.keyboard.press('Escape'); + } + // expand if collapsed + else if (!(await getJournalRow(page).isVisible())) { + await page.getByTestId('property-collapsible-button').click(); + } + + const journalRow = getJournalRow(page); + await expect(journalRow).toBeVisible(); + return journalRow; +} +async function toggleJournal(row: Locator, value: boolean) { + const checkbox = row.locator('input[type="checkbox"]'); + const state = await checkbox.inputValue(); + const checked = state === 'on'; + if (checked !== value) { + await checkbox.click(); + const newState = await checkbox.inputValue(); + const newChecked = newState === 'on'; + expect(newChecked).toBe(value); + } +} +async function createPageAndTurnIntoJournal(page: Page) { + await page.getByTestId('sidebar-new-page-button').click(); + await waitForEditorLoad(page); + const journalRow = await openPagePropertiesAndAddJournal(page); + await toggleJournal(journalRow, true); + return journalRow; +} + +test('Create a journal from sidebar', async ({ page }) => { + await openHomePage(page); + await page.getByTestId('slider-bar-journals-button').click(); + await waitForEditorLoad(page); + await isJournalEditor(page); +}); + +test('Create a page and turn it into a journal', async ({ page }) => { + await openHomePage(page); + await createPageAndTurnIntoJournal(page); + await isJournalEditor(page, new Date()); +}); + +test('Should show duplicated tag when create journal on same day', async ({ + page, +}) => { + await openHomePage(page); + await createPageAndTurnIntoJournal(page); + const journalRow2 = await createPageAndTurnIntoJournal(page); + await expect(journalRow2.getByTestId('conflict-tag')).toBeVisible(); +}); + +test('Resolve duplicated journal', async ({ page }) => { + await openHomePage(page); + await createPageAndTurnIntoJournal(page); + const journalRow2 = await createPageAndTurnIntoJournal(page); + await journalRow2.getByTestId('conflict-tag').click(); + const journalPanel = page.getByTestId('sidebar-journal-panel'); + await expect(journalPanel).toBeVisible(); + const conflictList = journalPanel.getByTestId('journal-conflict-list'); + await expect(conflictList).toBeVisible(); + const conflictItems = conflictList.getByTestId('journal-conflict-item'); + const first = conflictItems.first(); + await first.getByTestId('journal-conflict-edit').click(); + await page.getByTestId('journal-conflict-remove-mark').click(); + + await expect(journalRow2.getByTestId('conflict-tag')).not.toBeVisible(); + await expect(conflictList).not.toBeVisible(); +}); diff --git a/tests/affine-local/e2e/page-properties.spec.ts b/tests/affine-local/e2e/page-properties.spec.ts index c1b58b6cdb..afed24fba1 100644 --- a/tests/affine-local/e2e/page-properties.spec.ts +++ b/tests/affine-local/e2e/page-properties.spec.ts @@ -120,6 +120,7 @@ test('property table reordering', async ({ page }) => { // new order should be Doc mode, (Tags), Number, Date, Checkbox, Text for (const [index, property] of [ + 'Journal', 'Doc mode', 'Tags', 'Number', @@ -159,6 +160,7 @@ test('page info show more will show all properties', async ({ page }) => { await page.click('[data-testid="property-collapsible-button"]'); for (const [index, property] of [ + 'Journal', 'Doc mode', 'Tags', 'Text',