feat(core): new "is journal" page property (#8525)

close AF-1450, AF-1451, AF-14552
This commit is contained in:
CatsJuice
2024-10-18 10:03:08 +00:00
parent 714a87c2c0
commit 8f92be926b
19 changed files with 494 additions and 49 deletions

View File

@@ -16,4 +16,9 @@ export const BUILT_IN_CUSTOM_PROPERTY_TYPE = [
type: 'docPrimaryMode', type: 'docPrimaryMode',
show: 'always-hide', show: 'always-hide',
}, },
{
id: 'journal',
type: 'journal',
show: 'always-hide',
},
] as DocCustomPropertyInfo[]; ] as DocCustomPropertyInfo[];

View File

@@ -1,5 +1,5 @@
import { useJournalInfoHelper } from '@affine/core/components/hooks/use-journal';
import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta'; 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 { PeekViewService } from '@affine/core/modules/peek-view/services/peek-view';
import { useInsidePeekView } from '@affine/core/modules/peek-view/view/modal-container'; import { useInsidePeekView } from '@affine/core/modules/peek-view/view/modal-container';
import { WorkbenchLink } from '@affine/core/modules/workbench'; import { WorkbenchLink } from '@affine/core/modules/workbench';
@@ -30,7 +30,8 @@ export function AffinePageReference({
params?: URLSearchParams; params?: URLSearchParams;
}) { }) {
const docDisplayMetaService = useService(DocDisplayMetaService); const docDisplayMetaService = useService(DocDisplayMetaService);
const journalHelper = useJournalInfoHelper(); const journalService = useService(JournalService);
const isJournal = !!useLiveData(journalService.journalDate$(pageId));
const i18n = useI18n(); const i18n = useI18n();
let linkWithMode: DocMode | null = null; let linkWithMode: DocMode | null = null;
@@ -67,7 +68,6 @@ export function AffinePageReference({
const peekView = useService(PeekViewService).peekView; const peekView = useService(PeekViewService).peekView;
const isInPeekView = useInsidePeekView(); const isInPeekView = useInsidePeekView();
const isJournal = journalHelper.isPageJournal(pageId);
const onClick = useCallback( const onClick = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
@@ -127,7 +127,8 @@ export function AffineSharedPageReference({
params?: URLSearchParams; params?: URLSearchParams;
}) { }) {
const docDisplayMetaService = useService(DocDisplayMetaService); const docDisplayMetaService = useService(DocDisplayMetaService);
const journalHelper = useJournalInfoHelper(); const journalService = useService(JournalService);
const isJournal = !!useLiveData(journalService.journalDate$(pageId));
const i18n = useI18n(); const i18n = useI18n();
let linkWithMode: DocMode | null = null; let linkWithMode: DocMode | null = null;
@@ -159,8 +160,6 @@ export function AffineSharedPageReference({
const [refreshKey, setRefreshKey] = useState<string>(() => nanoid()); const [refreshKey, setRefreshKey] = useState<string>(() => nanoid());
const isJournal = journalHelper.isPageJournal(pageId);
const onClick = useCallback( const onClick = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
if (isJournal) { if (isJournal) {

View File

@@ -1,25 +1,32 @@
import { useJournalInfoHelper } from '@affine/core/components/hooks/use-journal'; import { JournalService } from '@affine/core/modules/journal';
import { useI18n } from '@affine/i18n'; import { i18nTime, useI18n } from '@affine/i18n';
import type { Doc } from '@blocksuite/affine/store'; import type { Doc } from '@blocksuite/affine/store';
import { useLiveData, useService } from '@toeverything/infra';
import dayjs from 'dayjs';
import * as styles from './styles.css'; import * as styles from './styles.css';
export const BlocksuiteEditorJournalDocTitle = ({ page }: { page: Doc }) => { export const BlocksuiteEditorJournalDocTitle = ({ page }: { page: Doc }) => {
const { localizedJournalDate, isTodayJournal, journalDate } = const journalService = useService(JournalService);
useJournalInfoHelper(page.id); 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(); const t = useI18n();
// TODO(catsjuice): i18n // TODO(catsjuice): i18n
const day = journalDate?.format('dddd') ?? null; const day = journalDate?.format('dddd') ?? null;
return ( return (
<span className="doc-title-container"> <div className="doc-title-container" data-testid="journal-title">
<span>{localizedJournalDate}</span> <span data-testid="date">{localizedJournalDate}</span>
{isTodayJournal ? ( {isTodayJournal ? (
<span className={styles.titleTodayTag}>{t['com.affine.today']()}</span> <span className={styles.titleTodayTag}>{t['com.affine.today']()}</span>
) : ( ) : (
<span className={styles.titleDayTag}>{day}</span> <span className={styles.titleDayTag}>{day}</span>
)} )}
</span> </div>
); );
}; };

View File

@@ -3,10 +3,10 @@ import {
useConfirmModal, useConfirmModal,
useLitPortalFactory, useLitPortalFactory,
} from '@affine/component'; } from '@affine/component';
import { useJournalInfoHelper } from '@affine/core/components/hooks/use-journal';
import { ServerConfigService } from '@affine/core/modules/cloud'; import { ServerConfigService } from '@affine/core/modules/cloud';
import { EditorService } from '@affine/core/modules/editor'; import { EditorService } from '@affine/core/modules/editor';
import { EditorSettingService } from '@affine/core/modules/editor-settting'; import { EditorSettingService } from '@affine/core/modules/editor-settting';
import { JournalService } from '@affine/core/modules/journal';
import { toURLSearchParams } from '@affine/core/modules/navigation'; import { toURLSearchParams } from '@affine/core/modules/navigation';
import { PeekViewService } from '@affine/core/modules/peek-view/services/peek-view'; import { PeekViewService } from '@affine/core/modules/peek-view/services/peek-view';
import type { DocMode } from '@blocksuite/affine/blocks'; import type { DocMode } from '@blocksuite/affine/blocks';
@@ -196,7 +196,8 @@ export const BlocksuiteDocEditor = forwardRef<
) { ) {
const titleRef = useRef<DocTitle | null>(null); const titleRef = useRef<DocTitle | null>(null);
const docRef = useRef<PageEditor | null>(null); const docRef = useRef<PageEditor | null>(null);
const { isJournal } = useJournalInfoHelper(page.id); const journalService = useService(JournalService);
const isJournal = !!useLiveData(journalService.journalDate$(page.id));
const editorSettingService = useService(EditorSettingService); const editorSettingService = useService(EditorSettingService);

View File

@@ -41,6 +41,7 @@ const titleTagBasic = style({
padding: '0 4px', padding: '0 4px',
borderRadius: '4px', borderRadius: '4px',
marginLeft: '4px', marginLeft: '4px',
lineHeight: '0px',
}); });
export const titleDayTag = style([ export const titleDayTag = style([
titleTagBasic, titleTagBasic,

View File

@@ -1,10 +1,9 @@
import type { WeekDatePickerHandle } from '@affine/component'; import type { WeekDatePickerHandle } from '@affine/component';
import { WeekDatePicker } from '@affine/component'; import { WeekDatePicker } from '@affine/component';
import { import { useJournalRouteHelper } from '@affine/core/components/hooks/use-journal';
useJournalInfoHelper, import { JournalService } from '@affine/core/modules/journal';
useJournalRouteHelper,
} from '@affine/core/components/hooks/use-journal';
import type { Doc, DocCollection } from '@blocksuite/affine/store'; import type { Doc, DocCollection } from '@blocksuite/affine/store';
import { useLiveData, useService } from '@toeverything/infra';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
@@ -19,7 +18,9 @@ export const JournalWeekDatePicker = ({
page, page,
}: JournalWeekDatePickerProps) => { }: JournalWeekDatePickerProps) => {
const handleRef = useRef<WeekDatePickerHandle>(null); const handleRef = useRef<WeekDatePickerHandle>(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 { openJournal } = useJournalRouteHelper(docCollection);
const [date, setDate] = useState( const [date, setDate] = useState(
(journalDate ?? dayjs()).format('YYYY-MM-DD') (journalDate ?? dayjs()).format('YYYY-MM-DD')
@@ -33,6 +34,7 @@ export const JournalWeekDatePicker = ({
return ( return (
<WeekDatePicker <WeekDatePicker
data-testid="journal-week-picker"
handleRef={handleRef} handleRef={handleRef}
style={weekStyle} style={weekStyle}
value={date} value={date}

View File

@@ -318,6 +318,7 @@ export const DocPropertyRow = ({
hideEmpty={hideEmpty} hideEmpty={hideEmpty}
hide={hide} hide={hide}
data-testid="doc-property-row" data-testid="doc-property-row"
data-info-id={propertyInfo.id}
> >
<PropertyName <PropertyName
defaultOpenMenu={defaultOpenEditMenu} defaultOpenMenu={defaultOpenEditMenu}
@@ -414,6 +415,7 @@ export const DocPropertiesTableBody = forwardRef<
variant="plain" variant="plain"
prefix={<PlusIcon />} prefix={<PlusIcon />}
className={styles.propertyActionButton} className={styles.propertyActionButton}
data-testid="add-property-button"
> >
{t['com.affine.page-properties.add-property']()} {t['com.affine.page-properties.add-property']()}
</Button> </Button>

View File

@@ -7,12 +7,14 @@ import {
NumberIcon, NumberIcon,
TagIcon, TagIcon,
TextIcon, TextIcon,
TodayIcon,
} from '@blocksuite/icons/rc'; } from '@blocksuite/icons/rc';
import { CheckboxValue } from './checkbox'; import { CheckboxValue } from './checkbox';
import { CreatedByValue, UpdatedByValue } from './created-updated-by'; import { CreatedByValue, UpdatedByValue } from './created-updated-by';
import { DateValue } from './date'; import { DateValue } from './date';
import { DocPrimaryModeValue } from './doc-primary-mode'; import { DocPrimaryModeValue } from './doc-primary-mode';
import { JournalValue } from './journal';
import { NumberValue } from './number'; import { NumberValue } from './number';
import { TagsValue } from './tags'; import { TagsValue } from './tags';
import { TextValue } from './text'; import { TextValue } from './text';
@@ -61,6 +63,13 @@ export const DocPropertyTypes = {
value: DocPrimaryModeValue, value: DocPrimaryModeValue,
name: 'com.affine.page-properties.property.docPrimaryMode', 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< } as Record<
string, string,
{ {

View File

@@ -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,
});

View File

@@ -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 (
<PropertyValue className={styles.property} onClick={toggle}>
<div className={styles.root}>
<Checkbox
className={styles.checkbox}
checked={checked}
onChange={handleCheck}
/>
{checked ? (
<Menu
contentOptions={{
onClick: e => e.stopPropagation(),
sideOffset: 10,
alignOffset: -30,
style: { padding: 20 },
}}
rootOptions={{
modal: true,
open: showDatePicker,
onOpenChange: setShowDatePicker,
}}
items={
<DatePicker
weekDays={t['com.affine.calendar-date-picker.week-days']()}
monthNames={t['com.affine.calendar-date-picker.month-names']()}
todayLabel={t['com.affine.calendar-date-picker.today']()}
value={selectedDate}
onChange={handleDateSelect}
/>
}
>
<div data-testid="date-selector" className={styles.date}>
{displayDate}
</div>
</Menu>
) : null}
{checked && conflict ? (
<div
data-testid="conflict-tag"
className={styles.duplicateTag}
onClick={handleOpenDuplicate}
>
{t['com.affine.page-properties.property.journal-duplicated']()}
</div>
) : null}
</div>
</PropertyValue>
);
};

View File

@@ -14,9 +14,9 @@ import { BlocksuiteHeaderTitle } from '@affine/core/components/blocksuite/block-
import { EditorModeSwitch } from '@affine/core/components/blocksuite/block-suite-mode-switch'; 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 { 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 { 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 { HeaderDivider } from '@affine/core/components/pure/header';
import { EditorService } from '@affine/core/modules/editor'; import { EditorService } from '@affine/core/modules/editor';
import { JournalService } from '@affine/core/modules/journal';
import { ViewIcon, ViewTitle } from '@affine/core/modules/workbench'; import { ViewIcon, ViewTitle } from '@affine/core/modules/workbench';
import type { Doc } from '@blocksuite/affine/store'; import type { Doc } from '@blocksuite/affine/store';
import { useLiveData, useService, type Workspace } from '@toeverything/infra'; import { useLiveData, useService, type Workspace } from '@toeverything/infra';
@@ -155,7 +155,8 @@ export function NormalPageHeader({ page, workspace }: PageHeaderProps) {
export function DetailPageHeader(props: PageHeaderProps) { export function DetailPageHeader(props: PageHeaderProps) {
const { page, workspace } = props; 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; const isInTrash = page.meta?.trash;
useRegisterCopyLinkCommands({ useRegisterCopyLinkCommands({

View File

@@ -1,4 +1,5 @@
import { cssVar } from '@toeverything/theme'; import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css'; import { style } from '@vanilla-extract/css';
const interactive = style({ const interactive = style({
@@ -161,6 +162,9 @@ export const pageItemLabel = style({
color: cssVar('primaryColor'), color: cssVar('primaryColor'),
}, },
}, },
display: 'flex',
gap: 6,
alignItems: 'center',
}); });
// conflict // conflict
@@ -185,6 +189,16 @@ export const journalConflictMoreTrigger = style([
alignItems: 'center', 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 // customize date-picker cell
export const journalDateCell = style([ export const journalDateCell = style([

View File

@@ -1,16 +1,20 @@
import type { DateCell } from '@affine/component'; 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 { import {
useJournalHelper, DatePicker,
useJournalInfoHelper, IconButton,
useJournalRouteHelper, Menu,
} from '@affine/core/components/hooks/use-journal'; 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 { MoveToTrash } from '@affine/core/components/page-list';
import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta'; 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 } from '@affine/core/modules/workbench';
import { useI18n } from '@affine/i18n'; 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 type { DocRecord } from '@toeverything/infra';
import { import {
DocService, DocService,
@@ -41,8 +45,15 @@ interface PageItemProps
extends Omit<HTMLAttributes<HTMLAnchorElement>, 'onClick'> { extends Omit<HTMLAttributes<HTMLAnchorElement>, 'onClick'> {
docId: string; docId: string;
right?: ReactNode; right?: ReactNode;
duplicate?: boolean;
} }
const PageItem = ({ docId, right, className, ...attrs }: PageItemProps) => { const PageItem = ({
docId,
right,
duplicate,
className,
...attrs
}: PageItemProps) => {
const i18n = useI18n(); const i18n = useI18n();
const docDisplayMetaService = useService(DocDisplayMetaService); const docDisplayMetaService = useService(DocDisplayMetaService);
const Icon = useLiveData( const Icon = useLiveData(
@@ -53,6 +64,7 @@ const PageItem = ({ docId, right, className, ...attrs }: PageItemProps) => {
return ( return (
<WorkbenchLink <WorkbenchLink
data-testid="journal-conflict-item"
aria-label={title} aria-label={title}
to={`/${docId}`} to={`/${docId}`}
className={clsx(className, styles.pageItem)} className={clsx(className, styles.pageItem)}
@@ -61,7 +73,14 @@ const PageItem = ({ docId, right, className, ...attrs }: PageItemProps) => {
<div className={styles.pageItemIcon}> <div className={styles.pageItemIcon}>
<Icon width={20} height={20} /> <Icon width={20} height={20} />
</div> </div>
<span className={styles.pageItemLabel}>{title}</span> <div className={styles.pageItemLabel}>
{title}
{duplicate ? (
<div className={styles.duplicateTag}>
{i18n['com.affine.page-properties.property.journal-duplicated']()}
</div>
) : null}
</div>
{right} {right}
</WorkbenchLink> </WorkbenchLink>
); );
@@ -82,7 +101,10 @@ export const EditorJournalPanel = () => {
const t = useI18n(); const t = useI18n();
const doc = useService(DocService).doc; const doc = useService(DocService).doc;
const workspace = useService(WorkspaceService).workspace; 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 { openJournal } = useJournalRouteHelper(workspace.docCollection);
const onDateSelect = useCallback( const onDateSelect = useCallback(
@@ -93,12 +115,11 @@ export const EditorJournalPanel = () => {
[journalDate, openJournal] [journalDate, openJournal]
); );
const allJournalDates = useLiveData(journalService.allJournalDates$);
const customDayRenderer = useCallback( const customDayRenderer = useCallback(
(cell: DateCell) => { (cell: DateCell) => {
// TODO(@catsjuice): add a dot to indicate journal const hasJournal = allJournalDates.has(cell.date.format('YYYY-MM-DD'));
// has performance issue for now, better to calculate it in advance
// const hasJournal = !!getJournalsByDate(cell.date.format('YYYY-MM-DD'))?.length;
const hasJournal = false;
return ( return (
<button <button
className={styles.journalDateCell} className={styles.journalDateCell}
@@ -118,11 +139,15 @@ export const EditorJournalPanel = () => {
</button> </button>
); );
}, },
[isJournal] [allJournalDates, isJournal]
); );
return ( return (
<div className={styles.journalPanel} data-is-journal={isJournal}> <div
className={styles.journalPanel}
data-is-journal={isJournal}
data-testid="sidebar-journal-panel"
>
<div data-mobile={mobile} className={styles.calendar}> <div data-mobile={mobile} className={styles.calendar}>
<DatePicker <DatePicker
weekDays={t['com.affine.calendar-date-picker.week-days']()} weekDays={t['com.affine.calendar-date-picker.week-days']()}
@@ -284,7 +309,9 @@ const ConflictList = ({
className, className,
...attrs ...attrs
}: ConflictListProps) => { }: ConflictListProps) => {
const t = useI18n();
const currentDoc = useService(DocService).doc; const currentDoc = useService(DocService).doc;
const journalService = useService(JournalService);
const { setTrashModal } = useTrashModalHelper(); const { setTrashModal } = useTrashModalHelper();
const handleOpenTrashModal = useCallback( const handleOpenTrashModal = useCallback(
@@ -297,9 +324,19 @@ const ConflictList = ({
}, },
[setTrashModal] [setTrashModal]
); );
const handleRemoveJournalMark = useCallback(
(docId: string) => {
journalService.removeJournalDate(docId);
},
[journalService]
);
return ( return (
<div className={clsx(styles.journalConflictWrapper, className)} {...attrs}> <div
data-testid="journal-conflict-list"
className={clsx(styles.journalConflictWrapper, className)}
{...attrs}
>
{docRecords.map(docRecord => { {docRecords.map(docRecord => {
const isCurrent = docRecord.id === currentDoc.id; const isCurrent = docRecord.id === currentDoc.id;
return ( return (
@@ -307,17 +344,40 @@ const ConflictList = ({
aria-selected={isCurrent} aria-selected={isCurrent}
docId={docRecord.id} docId={docRecord.id}
key={docRecord.id} key={docRecord.id}
duplicate
right={ right={
<Menu <Menu
contentOptions={{
style: { width: 237, maxWidth: '100%' },
align: 'end',
alignOffset: -4,
sideOffset: 8,
}}
items={ items={
<>
<MenuItem
prefixIcon={<CalendarXmarkIcon />}
onClick={e => {
e.stopPropagation();
handleRemoveJournalMark(docRecord.id);
}}
data-testid="journal-conflict-remove-mark"
>
{t[
'com.affine.page-properties.property.journal-remove'
]()}
</MenuItem>
<MenuSeparator />
<MoveToTrash <MoveToTrash
onSelect={() => handleOpenTrashModal(docRecord)} onSelect={() => handleOpenTrashModal(docRecord)}
/> />
</>
} }
> >
<IconButton> <IconButton
<MoreHorizontalIcon /> data-testid="journal-conflict-edit"
</IconButton> icon={<EditIcon />}
/>
</Menu> </Menu>
} }
/> />
@@ -329,10 +389,15 @@ const ConflictList = ({
}; };
const JournalConflictBlock = ({ date }: JournalBlockProps) => { const JournalConflictBlock = ({ date }: JournalBlockProps) => {
const t = useI18n(); const t = useI18n();
const workspace = useService(WorkspaceService).workspace;
const docRecordList = useService(DocsService).list; const docRecordList = useService(DocsService).list;
const journalHelper = useJournalHelper(workspace.docCollection); const journalService = useService(JournalService);
const docs = journalHelper.getJournalsByDate(date.format('YYYY-MM-DD')); const dateString = date.format('YYYY-MM-DD');
const docs = useLiveData(
useMemo(
() => journalService.journalsByDate$(dateString),
[dateString, journalService]
)
);
const docRecords = useLiveData( const docRecords = useLiveData(
docRecordList.docs$.map(records => docRecordList.docs$.map(records =>
records.filter(v => { records.filter(v => {

View File

@@ -1,4 +1,5 @@
import { LiveData, Service } from '@toeverything/infra'; import { LiveData, Service } from '@toeverything/infra';
import dayjs from 'dayjs';
import type { JournalStore } from '../store/journal'; import type { JournalStore } from '../store/journal';
@@ -7,15 +8,31 @@ export class JournalService extends Service {
super(); super();
} }
allJournalDates$ = this.store.allJournalDates$;
journalDate$(docId: string) { journalDate$(docId: string) {
return LiveData.from(this.store.watchDocJournalDate(docId), undefined); 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) { setJournalDate(docId: string, date: string) {
this.store.setDocJournalDate(docId, date); this.store.setDocJournalDate(docId, date);
} }
removeJournalDate(docId: string) {
this.store.removeDocJournalDate(docId);
}
getJournalsByDate(date: string) { getJournalsByDate(date: string) {
return this.store.getDocsByJournalDate(date); return this.store.getDocsByJournalDate(date);
} }
journalsByDate$(date: string) {
return this.store.docsByJournalDate$(date);
}
} }

View File

@@ -11,6 +11,17 @@ export class JournalStore extends Store {
super(); 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<string | undefined> { watchDocJournalDate(docId: string): Observable<string | undefined> {
return LiveData.computed(get => { return LiveData.computed(get => {
const doc = get(this.docsService.list.doc$(docId)); const doc = get(this.docsService.list.doc$(docId));
@@ -35,9 +46,21 @@ export class JournalStore extends Store {
doc.setProperty('journal', date); doc.setProperty('journal', date);
} }
removeDocJournalDate(docId: string) {
this.setDocJournalDate(docId, '');
}
getDocsByJournalDate(date: string) { getDocsByJournalDate(date: string) {
return this.docsService.list.docs$.value.filter( return this.docsService.list.docs$.value.filter(
doc => doc.properties$.value.journal === date 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;
});
});
}
} }

View File

@@ -17,6 +17,6 @@
"ru": 84, "ru": 84,
"sv-SE": 5, "sv-SE": 5,
"ur": 3, "ur": 3,
"zh-Hans": 100, "zh-Hans": 99,
"zh-Hant": 99 "zh-Hant": 99
} }

View File

@@ -652,6 +652,9 @@
"com.affine.page-properties.property.tags": "Tags", "com.affine.page-properties.property.tags": "Tags",
"com.affine.page-properties.property.docPrimaryMode": "Doc mode", "com.affine.page-properties.property.docPrimaryMode": "Doc mode",
"com.affine.page-properties.property.text": "Text", "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.page-properties.property.updatedBy": "Last edited by",
"com.affine.propertySidebar.property-list.section": "Properties", "com.affine.propertySidebar.property-list.section": "Properties",
"com.affine.propertySidebar.add-more.section": "Add more properties", "com.affine.propertySidebar.add-more.section": "Add more properties",

View File

@@ -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();
});

View File

@@ -120,6 +120,7 @@ test('property table reordering', async ({ page }) => {
// new order should be Doc mode, (Tags), Number, Date, Checkbox, Text // new order should be Doc mode, (Tags), Number, Date, Checkbox, Text
for (const [index, property] of [ for (const [index, property] of [
'Journal',
'Doc mode', 'Doc mode',
'Tags', 'Tags',
'Number', 'Number',
@@ -159,6 +160,7 @@ test('page info show more will show all properties', async ({ page }) => {
await page.click('[data-testid="property-collapsible-button"]'); await page.click('[data-testid="property-collapsible-button"]');
for (const [index, property] of [ for (const [index, property] of [
'Journal',
'Doc mode', 'Doc mode',
'Tags', 'Tags',
'Text', 'Text',