feat(editor): calendar view for database block (#14984)
fix #13663 #### PR Dependency Tree * **PR #14984** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Calendar view for database blocks (month layout, entry cards, external-source support) * Workspace calendar integration and new slash-menu "Calendar View" * **Improvements** * Create/manage database rows from calendar UI; preserve durations when moving/resizing ranges * Drag-and-drop, drop-preview, and hit-testing support for calendar and docs * Redesigned in-menu View settings with multi-page navigation * Context-menu input autofocus toggle and conditional back-navigation * **Tests** * New unit and E2E suites covering calendar layout, interactions, sources, and slash-menu integration <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -254,6 +254,7 @@ export class DataViewBlockComponent extends CaptionedBlockComponent<DataViewBloc
|
|||||||
dataSource: this.dataSource,
|
dataSource: this.dataSource,
|
||||||
headerWidget: this.headerWidget,
|
headerWidget: this.headerWidget,
|
||||||
clipboard: this.std.clipboard,
|
clipboard: this.std.clipboard,
|
||||||
|
dnd: this.std.dnd,
|
||||||
notification: {
|
notification: {
|
||||||
toast: message => {
|
toast: message => {
|
||||||
const notification = this.std.getOptional(NotificationProvider);
|
const notification = this.std.getOptional(NotificationProvider);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { viewPresets } from '@blocksuite/data-view/view-presets';
|
|||||||
import {
|
import {
|
||||||
DatabaseKanbanViewIcon,
|
DatabaseKanbanViewIcon,
|
||||||
DatabaseTableViewIcon,
|
DatabaseTableViewIcon,
|
||||||
|
TodayIcon,
|
||||||
} from '@blocksuite/icons/lit';
|
} from '@blocksuite/icons/lit';
|
||||||
|
|
||||||
import { insertDatabaseBlockCommand } from '../commands';
|
import { insertDatabaseBlockCommand } from '../commands';
|
||||||
@@ -47,6 +48,35 @@ export const databaseSlashMenuConfig: SlashMenuConfig = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'Calendar View',
|
||||||
|
description: 'Display items by date in a calendar.',
|
||||||
|
searchAlias: ['database', 'calendar'],
|
||||||
|
icon: TodayIcon(),
|
||||||
|
group: '7_Database@1',
|
||||||
|
when: ({ model }) =>
|
||||||
|
!isInsideBlockByFlavour(model.store, model, 'affine:edgeless-text'),
|
||||||
|
action: ({ std }) => {
|
||||||
|
std.command
|
||||||
|
.chain()
|
||||||
|
.pipe(getSelectedModelsCommand)
|
||||||
|
.pipe(insertDatabaseBlockCommand, {
|
||||||
|
viewType: viewPresets.calendarViewMeta.type,
|
||||||
|
place: 'after',
|
||||||
|
removeEmptyLine: true,
|
||||||
|
})
|
||||||
|
.pipe(({ insertedDatabaseBlockId }) => {
|
||||||
|
if (insertedDatabaseBlockId) {
|
||||||
|
const telemetry = std.getOptional(TelemetryProvider);
|
||||||
|
telemetry?.track('BlockCreated', {
|
||||||
|
blockType: 'affine:database',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
name: 'Kanban View',
|
name: 'Kanban View',
|
||||||
description: 'Visualize data in a dashboard.',
|
description: 'Visualize data in a dashboard.',
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
type SingleView,
|
type SingleView,
|
||||||
uniMap,
|
uniMap,
|
||||||
} from '@blocksuite/data-view';
|
} from '@blocksuite/data-view';
|
||||||
|
import { CalendarExternalSourceProvider } from '@blocksuite/data-view/view-presets';
|
||||||
import { widgetPresets } from '@blocksuite/data-view/widget-presets';
|
import { widgetPresets } from '@blocksuite/data-view/widget-presets';
|
||||||
import { IS_MOBILE } from '@blocksuite/global/env';
|
import { IS_MOBILE } from '@blocksuite/global/env';
|
||||||
import { Rect } from '@blocksuite/global/gfx';
|
import { Rect } from '@blocksuite/global/gfx';
|
||||||
@@ -150,6 +151,14 @@ export class DatabaseBlockComponent extends CaptionedBlockComponent<DatabaseBloc
|
|||||||
config
|
config
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
this.std.provider
|
||||||
|
.getAll(CalendarExternalSourceProvider)
|
||||||
|
.forEach(source => {
|
||||||
|
dataSource.serviceSet(
|
||||||
|
CalendarExternalSourceProvider(source.id),
|
||||||
|
source
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
const id = currentViewStorage.getCurrentView(this.model.id);
|
const id = currentViewStorage.getCurrentView(this.model.id);
|
||||||
if (id && dataSource.viewManager.viewGet(id)) {
|
if (id && dataSource.viewManager.viewGet(id)) {
|
||||||
@@ -293,6 +302,12 @@ export class DatabaseBlockComponent extends CaptionedBlockComponent<DatabaseBloc
|
|||||||
widgetPresets.tools.viewOptions,
|
widgetPresets.tools.viewOptions,
|
||||||
widgetPresets.tools.tableAddRow,
|
widgetPresets.tools.tableAddRow,
|
||||||
],
|
],
|
||||||
|
calendar: [
|
||||||
|
widgetPresets.tools.filter,
|
||||||
|
widgetPresets.tools.search,
|
||||||
|
widgetPresets.tools.viewOptions,
|
||||||
|
widgetPresets.tools.tableAddRow,
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
private readonly viewSelection$ = computed(() => {
|
private readonly viewSelection$ = computed(() => {
|
||||||
@@ -427,6 +442,7 @@ export class DatabaseBlockComponent extends CaptionedBlockComponent<DatabaseBloc
|
|||||||
headerWidget: this.headerWidget,
|
headerWidget: this.headerWidget,
|
||||||
onDrag: this.onDrag,
|
onDrag: this.onDrag,
|
||||||
clipboard: this.std.clipboard,
|
clipboard: this.std.clipboard,
|
||||||
|
dnd: this.std.dnd,
|
||||||
notification: {
|
notification: {
|
||||||
toast: message => {
|
toast: message => {
|
||||||
const notification = this.std.getOptional(NotificationProvider);
|
const notification = this.std.getOptional(NotificationProvider);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { viewConverts, viewPresets } from '@blocksuite/data-view/view-presets';
|
|||||||
export const databaseBlockViews: ViewMeta[] = [
|
export const databaseBlockViews: ViewMeta[] = [
|
||||||
viewPresets.tableViewMeta,
|
viewPresets.tableViewMeta,
|
||||||
viewPresets.kanbanViewMeta,
|
viewPresets.kanbanViewMeta,
|
||||||
|
viewPresets.calendarViewMeta,
|
||||||
];
|
];
|
||||||
|
|
||||||
export const databaseBlockViewMap = Object.fromEntries(
|
export const databaseBlockViewMap = Object.fromEntries(
|
||||||
|
|||||||
@@ -95,7 +95,9 @@ export class MenuInput extends MenuFocusable {
|
|||||||
});
|
});
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
|
if (!this.data.disableAutoFocus) {
|
||||||
this.inputRef.select();
|
this.inputRef.select();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -223,6 +225,7 @@ export const menuInputItems = {
|
|||||||
onComplete?: (value: string) => void;
|
onComplete?: (value: string) => void;
|
||||||
onChange?: (value: string) => void;
|
onChange?: (value: string) => void;
|
||||||
onBlur?: (value: string) => void;
|
onBlur?: (value: string) => void;
|
||||||
|
disableAutoFocus?: boolean;
|
||||||
class?: string;
|
class?: string;
|
||||||
style?: Readonly<StyleInfo>;
|
style?: Readonly<StyleInfo>;
|
||||||
}) =>
|
}) =>
|
||||||
@@ -237,6 +240,7 @@ export const menuInputItems = {
|
|||||||
onComplete: config.onComplete,
|
onComplete: config.onComplete,
|
||||||
onChange: config.onChange,
|
onChange: config.onChange,
|
||||||
onBlur: config.onBlur,
|
onBlur: config.onBlur,
|
||||||
|
disableAutoFocus: config.disableAutoFocus,
|
||||||
};
|
};
|
||||||
const style = styleMap({
|
const style = styleMap({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|||||||
@@ -111,8 +111,10 @@ export class MenuComponent
|
|||||||
}
|
}
|
||||||
const onBack = this.menu.options.title?.onBack;
|
const onBack = this.menu.options.title?.onBack;
|
||||||
if (e.key === 'Backspace' && onBack && !this.menu.showSearch$.value) {
|
if (e.key === 'Backspace' && onBack && !this.menu.showSearch$.value) {
|
||||||
|
const result = onBack(this.menu);
|
||||||
|
if (result !== false) {
|
||||||
this.menu.close();
|
this.menu.close();
|
||||||
onBack(this.menu);
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.key === 'Enter' && !e.isComposing) {
|
if (e.key === 'Enter' && !e.isComposing) {
|
||||||
@@ -214,8 +216,10 @@ export class MenuComponent
|
|||||||
${title.onBack
|
${title.onBack
|
||||||
? html` <div
|
? html` <div
|
||||||
@click="${() => {
|
@click="${() => {
|
||||||
title.onBack?.(this.menu);
|
const result = title.onBack?.(this.menu);
|
||||||
|
if (result !== false) {
|
||||||
this.menu.close();
|
this.menu.close();
|
||||||
|
}
|
||||||
}}"
|
}}"
|
||||||
class="dv-icon-20 dv-hover dv-pd-2 dv-round-4"
|
class="dv-icon-20 dv-hover dv-pd-2 dv-round-4"
|
||||||
style="display:flex;"
|
style="display:flex;"
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export type MenuOptions = {
|
|||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
title?: {
|
title?: {
|
||||||
text: string;
|
text: string;
|
||||||
onBack?: (menu: Menu) => void;
|
onBack?: (menu: Menu) => boolean | void;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
postfix?: () => TemplateResult;
|
postfix?: () => TemplateResult;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,371 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
type CalendarEntry,
|
||||||
|
createCalendarMonthLayout,
|
||||||
|
getCalendarDayContentSlots,
|
||||||
|
getCalendarVisibleMonthRange,
|
||||||
|
} from '../view-presets/calendar/index.js';
|
||||||
|
|
||||||
|
const day = (value: string) => new Date(`${value}T00:00:00`).getTime();
|
||||||
|
|
||||||
|
describe('calendar month layout', () => {
|
||||||
|
it('buckets single day entries', () => {
|
||||||
|
const entry = {
|
||||||
|
kind: 'row',
|
||||||
|
id: 'database:row-1',
|
||||||
|
sourceId: 'database',
|
||||||
|
rowId: 'row-1',
|
||||||
|
title: 'Task',
|
||||||
|
startAt: day('2026-05-15'),
|
||||||
|
cardProperties: [],
|
||||||
|
canResizeRange: false,
|
||||||
|
} satisfies CalendarEntry;
|
||||||
|
|
||||||
|
const layout = createCalendarMonthLayout({
|
||||||
|
month: day('2026-05-01'),
|
||||||
|
entries: [entry],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
layout.days.find(item => item.date === day('2026-05-15'))?.entries
|
||||||
|
).toEqual([entry]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('splits range external entries across weeks', () => {
|
||||||
|
const entry = {
|
||||||
|
kind: 'external',
|
||||||
|
id: 'external:1',
|
||||||
|
sourceId: 'workspace-calendar',
|
||||||
|
externalId: '1',
|
||||||
|
title: 'Trip',
|
||||||
|
startAt: day('2026-05-09'),
|
||||||
|
endAt: new Date('2026-05-12T12:00:00').getTime(),
|
||||||
|
canResizeRange: false,
|
||||||
|
} satisfies CalendarEntry;
|
||||||
|
|
||||||
|
const layout = createCalendarMonthLayout({
|
||||||
|
month: day('2026-05-01'),
|
||||||
|
entries: [entry],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(layout.segments).toMatchObject([
|
||||||
|
{ weekIndex: 1, startIndex: 6, span: 1 },
|
||||||
|
{ weekIndex: 2, startIndex: 0, span: 3 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats all-day external midnight end as exclusive', () => {
|
||||||
|
const entry = {
|
||||||
|
kind: 'external',
|
||||||
|
id: 'external:1',
|
||||||
|
sourceId: 'workspace-calendar',
|
||||||
|
externalId: '1',
|
||||||
|
title: 'All day',
|
||||||
|
startAt: day('2026-05-15'),
|
||||||
|
endAt: day('2026-05-16'),
|
||||||
|
allDay: true,
|
||||||
|
canResizeRange: false,
|
||||||
|
} satisfies CalendarEntry;
|
||||||
|
|
||||||
|
const layout = createCalendarMonthLayout({
|
||||||
|
month: day('2026-05-01'),
|
||||||
|
entries: [entry],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
layout.days.find(item => item.date === day('2026-05-15'))?.entries
|
||||||
|
).toEqual([entry]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats row midnight end date as inclusive', () => {
|
||||||
|
const entry = {
|
||||||
|
kind: 'row',
|
||||||
|
id: 'database:row-1',
|
||||||
|
sourceId: 'database',
|
||||||
|
rowId: 'row-1',
|
||||||
|
title: 'Task',
|
||||||
|
startAt: day('2026-05-15'),
|
||||||
|
endAt: day('2026-05-16'),
|
||||||
|
cardProperties: [],
|
||||||
|
canResizeRange: true,
|
||||||
|
} satisfies CalendarEntry;
|
||||||
|
|
||||||
|
const layout = createCalendarMonthLayout({
|
||||||
|
month: day('2026-05-01'),
|
||||||
|
entries: [entry],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(layout.segments).toMatchObject([
|
||||||
|
{ weekIndex: 2, startIndex: 5, span: 2 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clips range entries to visible month range', () => {
|
||||||
|
const entry = {
|
||||||
|
kind: 'external',
|
||||||
|
id: 'external:1',
|
||||||
|
sourceId: 'workspace-calendar',
|
||||||
|
externalId: '1',
|
||||||
|
title: 'Long trip',
|
||||||
|
startAt: day('2026-04-01'),
|
||||||
|
endAt: day('2026-06-30'),
|
||||||
|
canResizeRange: false,
|
||||||
|
} satisfies CalendarEntry;
|
||||||
|
|
||||||
|
const layout = createCalendarMonthLayout({
|
||||||
|
month: day('2026-05-01'),
|
||||||
|
entries: [entry],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(layout.segments[0]).toMatchObject({
|
||||||
|
weekIndex: 0,
|
||||||
|
startIndex: 0,
|
||||||
|
span: 7,
|
||||||
|
});
|
||||||
|
expect(layout.segments.at(-1)).toMatchObject({
|
||||||
|
weekIndex: layout.weeks.length - 1,
|
||||||
|
startIndex: 0,
|
||||||
|
span: 7,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pads month view to full weeks', () => {
|
||||||
|
const range = getCalendarVisibleMonthRange(day('2026-05-01'));
|
||||||
|
const layout = createCalendarMonthLayout({
|
||||||
|
month: day('2026-05-01'),
|
||||||
|
entries: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(new Date(range.from).getDay()).toBe(0);
|
||||||
|
expect(new Date(range.to).getDay()).toBe(6);
|
||||||
|
expect(layout.days).toHaveLength(layout.weeks.length * 7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps day buckets on local midnight across DST boundaries', () => {
|
||||||
|
const entry = {
|
||||||
|
kind: 'row',
|
||||||
|
id: 'database:row-1',
|
||||||
|
sourceId: 'database',
|
||||||
|
rowId: 'row-1',
|
||||||
|
title: 'DST task',
|
||||||
|
startAt: day('2026-03-09'),
|
||||||
|
cardProperties: [],
|
||||||
|
canResizeRange: false,
|
||||||
|
} satisfies CalendarEntry;
|
||||||
|
|
||||||
|
const layout = createCalendarMonthLayout({
|
||||||
|
month: day('2026-03-01'),
|
||||||
|
entries: [entry],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
layout.days.every(item => {
|
||||||
|
const date = new Date(item.date);
|
||||||
|
return (
|
||||||
|
date.getHours() === 0 &&
|
||||||
|
date.getMinutes() === 0 &&
|
||||||
|
date.getSeconds() === 0 &&
|
||||||
|
date.getMilliseconds() === 0
|
||||||
|
);
|
||||||
|
})
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
layout.days.find(item => item.date === day('2026-03-09'))?.entries
|
||||||
|
).toEqual([entry]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps range segment offsets across DST boundaries', () => {
|
||||||
|
const entry = {
|
||||||
|
kind: 'external',
|
||||||
|
id: 'external:1',
|
||||||
|
sourceId: 'workspace-calendar',
|
||||||
|
externalId: '1',
|
||||||
|
title: 'DST range',
|
||||||
|
startAt: day('2026-03-09'),
|
||||||
|
endAt: new Date('2026-03-10T12:00:00').getTime(),
|
||||||
|
canResizeRange: false,
|
||||||
|
} satisfies CalendarEntry;
|
||||||
|
|
||||||
|
const layout = createCalendarMonthLayout({
|
||||||
|
month: day('2026-03-01'),
|
||||||
|
entries: [entry],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(layout.segments).toMatchObject([
|
||||||
|
{ weekIndex: 1, startIndex: 1, span: 2 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps all same-day entries in the day bucket', () => {
|
||||||
|
const entries = Array.from(
|
||||||
|
{ length: 4 },
|
||||||
|
(_, index) =>
|
||||||
|
({
|
||||||
|
kind: 'row',
|
||||||
|
id: `database:row-${index}`,
|
||||||
|
sourceId: 'database',
|
||||||
|
rowId: `row-${index}`,
|
||||||
|
title: `Task ${index}`,
|
||||||
|
startAt: day('2026-05-15'),
|
||||||
|
cardProperties: [],
|
||||||
|
canResizeRange: false,
|
||||||
|
}) satisfies CalendarEntry
|
||||||
|
);
|
||||||
|
|
||||||
|
const layout = createCalendarMonthLayout({
|
||||||
|
month: day('2026-05-01'),
|
||||||
|
entries,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
layout.days.find(item => item.date === day('2026-05-15'))?.entries
|
||||||
|
).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('assigns each overlapping range segment to its own slot', () => {
|
||||||
|
const entries: CalendarEntry[] = [
|
||||||
|
...Array.from(
|
||||||
|
{ length: 3 },
|
||||||
|
(_, index) =>
|
||||||
|
({
|
||||||
|
kind: 'external',
|
||||||
|
id: `external:full-${index}`,
|
||||||
|
sourceId: 'workspace-calendar',
|
||||||
|
externalId: `full-${index}`,
|
||||||
|
title: `Full ${index}`,
|
||||||
|
startAt: day('2026-05-15'),
|
||||||
|
endAt: new Date('2026-05-17T12:00:00').getTime(),
|
||||||
|
canResizeRange: false,
|
||||||
|
}) as const
|
||||||
|
),
|
||||||
|
{
|
||||||
|
kind: 'external',
|
||||||
|
id: 'external:short',
|
||||||
|
sourceId: 'workspace-calendar',
|
||||||
|
externalId: 'short',
|
||||||
|
title: 'Short',
|
||||||
|
startAt: day('2026-05-18'),
|
||||||
|
endAt: new Date('2026-05-19T12:00:00').getTime(),
|
||||||
|
canResizeRange: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const layout = createCalendarMonthLayout({
|
||||||
|
month: day('2026-05-01'),
|
||||||
|
entries,
|
||||||
|
});
|
||||||
|
const may15 = layout.days.find(item => item.date === day('2026-05-15'))!;
|
||||||
|
const may18 = layout.days.find(item => item.date === day('2026-05-18'))!;
|
||||||
|
|
||||||
|
expect(getCalendarDayContentSlots(may15)).toBe(3);
|
||||||
|
expect(may15.segments.map(segment => segment.slot)).toEqual([0, 1, 2]);
|
||||||
|
expect(getCalendarDayContentSlots(may18)).toBe(1);
|
||||||
|
expect(may18.segments.map(segment => segment.slot)).toEqual([0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts segment and same-day slots for drag preview placement', () => {
|
||||||
|
const entries: CalendarEntry[] = [
|
||||||
|
...Array.from(
|
||||||
|
{ length: 3 },
|
||||||
|
(_, index) =>
|
||||||
|
({
|
||||||
|
kind: 'external',
|
||||||
|
id: `external:range-${index}`,
|
||||||
|
sourceId: 'workspace-calendar',
|
||||||
|
externalId: `range-${index}`,
|
||||||
|
title: `Range ${index}`,
|
||||||
|
startAt: day('2026-05-08'),
|
||||||
|
endAt: new Date('2026-05-09T12:00:00').getTime(),
|
||||||
|
canResizeRange: false,
|
||||||
|
}) as const
|
||||||
|
),
|
||||||
|
{
|
||||||
|
kind: 'row',
|
||||||
|
id: 'database:moving',
|
||||||
|
sourceId: 'database',
|
||||||
|
rowId: 'moving',
|
||||||
|
title: 'Moving',
|
||||||
|
startAt: day('2026-05-06'),
|
||||||
|
endAt: new Date('2026-05-08T12:00:00').getTime(),
|
||||||
|
cardProperties: [],
|
||||||
|
canResizeRange: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'row',
|
||||||
|
id: 'database:single',
|
||||||
|
sourceId: 'database',
|
||||||
|
rowId: 'single',
|
||||||
|
title: 'Single',
|
||||||
|
startAt: day('2026-05-08'),
|
||||||
|
cardProperties: [],
|
||||||
|
canResizeRange: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const layout = createCalendarMonthLayout({
|
||||||
|
month: day('2026-05-01'),
|
||||||
|
entries,
|
||||||
|
});
|
||||||
|
const may8 = layout.days.find(item => item.date === day('2026-05-08'))!;
|
||||||
|
|
||||||
|
expect(getCalendarDayContentSlots(may8, 'database:moving')).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('splits row range entries across weeks with continuation metadata', () => {
|
||||||
|
const entry = {
|
||||||
|
kind: 'row',
|
||||||
|
id: 'database:row-1',
|
||||||
|
sourceId: 'database',
|
||||||
|
rowId: 'row-1',
|
||||||
|
title: 'Project',
|
||||||
|
startAt: day('2026-05-09'),
|
||||||
|
endAt: new Date('2026-05-12T12:00:00').getTime(),
|
||||||
|
cardProperties: [],
|
||||||
|
canResizeRange: true,
|
||||||
|
} satisfies CalendarEntry;
|
||||||
|
|
||||||
|
const layout = createCalendarMonthLayout({
|
||||||
|
month: day('2026-05-01'),
|
||||||
|
entries: [entry],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(layout.segments).toMatchObject([
|
||||||
|
{
|
||||||
|
weekIndex: 1,
|
||||||
|
startIndex: 6,
|
||||||
|
span: 1,
|
||||||
|
startsBeforeWeek: false,
|
||||||
|
endsAfterWeek: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
weekIndex: 2,
|
||||||
|
startIndex: 0,
|
||||||
|
span: 3,
|
||||||
|
startsBeforeWeek: true,
|
||||||
|
endsAfterWeek: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips range entries completely outside the visible month range', () => {
|
||||||
|
const entry = {
|
||||||
|
kind: 'external',
|
||||||
|
id: 'external:outside',
|
||||||
|
sourceId: 'workspace-calendar',
|
||||||
|
externalId: 'outside',
|
||||||
|
title: 'Outside',
|
||||||
|
startAt: day('2026-06-10'),
|
||||||
|
endAt: day('2026-06-12'),
|
||||||
|
canResizeRange: false,
|
||||||
|
} satisfies CalendarEntry;
|
||||||
|
|
||||||
|
const layout = createCalendarMonthLayout({
|
||||||
|
month: day('2026-05-01'),
|
||||||
|
entries: [entry],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(layout.segments).toEqual([]);
|
||||||
|
expect(layout.days.every(day => day.segments.length === 0)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
812
blocksuite/affine/data-view/src/__tests__/calendar.unit.spec.ts
Normal file
812
blocksuite/affine/data-view/src/__tests__/calendar.unit.spec.ts
Normal file
@@ -0,0 +1,812 @@
|
|||||||
|
import { DocDisplayMetaProvider } from '@blocksuite/affine-shared/services';
|
||||||
|
import { signal } from '@preact/signals-core';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { DataSource } from '../core/data-source/base.js';
|
||||||
|
import {
|
||||||
|
CalendarSingleView,
|
||||||
|
type CalendarStoredViewData,
|
||||||
|
calendarViewModel,
|
||||||
|
} from '../view-presets/calendar/index.js';
|
||||||
|
import {
|
||||||
|
formatEntryTime,
|
||||||
|
openCalendarEntry,
|
||||||
|
} from '../view-presets/calendar/pc/actions.js';
|
||||||
|
import { getCalendarDndEntity } from '../view-presets/calendar/pc/dnd.js';
|
||||||
|
import { viewConverts } from '../view-presets/convert.js';
|
||||||
|
|
||||||
|
const day = (value: string) => new Date(`${value}T00:00:00`).getTime();
|
||||||
|
|
||||||
|
const createCalendarView = (options?: {
|
||||||
|
startColumnId?: string;
|
||||||
|
endColumnId?: string;
|
||||||
|
datePropertyType?: string;
|
||||||
|
rows?: string[];
|
||||||
|
filterValue?: string;
|
||||||
|
titleValue?: unknown;
|
||||||
|
linkedDocTitles?: Record<string, string>;
|
||||||
|
visiblePropertyIds?: string[];
|
||||||
|
externalFactories?: Map<unknown, unknown>;
|
||||||
|
}) => {
|
||||||
|
const rows = signal(options?.rows ?? ['row-1']);
|
||||||
|
const columns = signal(['title', 'date', 'end-date', 'status']);
|
||||||
|
const viewData = signal<CalendarStoredViewData>({
|
||||||
|
id: 'view-1',
|
||||||
|
name: 'Calendar',
|
||||||
|
mode: 'calendar',
|
||||||
|
filter: options?.filterValue
|
||||||
|
? {
|
||||||
|
type: 'group',
|
||||||
|
op: 'and',
|
||||||
|
conditions: [
|
||||||
|
{
|
||||||
|
type: 'filter',
|
||||||
|
left: { type: 'ref', name: 'status' },
|
||||||
|
function: 'is',
|
||||||
|
args: [{ type: 'literal', value: options.filterValue }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
type: 'group',
|
||||||
|
op: 'and',
|
||||||
|
conditions: [],
|
||||||
|
},
|
||||||
|
date: {
|
||||||
|
startColumnId: options?.startColumnId,
|
||||||
|
endColumnId: options?.endColumnId,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
titleColumnId: 'title',
|
||||||
|
visiblePropertyIds: options?.visiblePropertyIds ?? [],
|
||||||
|
},
|
||||||
|
sources: {
|
||||||
|
workspaceCalendar: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const values = new Map<string, unknown>([
|
||||||
|
['row-1:date', day('2026-05-15')],
|
||||||
|
['row-1:end-date', day('2026-05-17')],
|
||||||
|
['row-1:status', 'Done'],
|
||||||
|
['row-1:title', options?.titleValue ?? 'Task'],
|
||||||
|
['row-2:date', day('2026-05-16')],
|
||||||
|
['row-2:end-date', day('2026-05-14')],
|
||||||
|
['row-2:status', 'Todo'],
|
||||||
|
['row-2:title', 'Hidden'],
|
||||||
|
]);
|
||||||
|
const types = new Map<string, string>([
|
||||||
|
['title', 'title'],
|
||||||
|
['date', options?.datePropertyType ?? 'date'],
|
||||||
|
['end-date', 'date'],
|
||||||
|
['status', 'text'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const dataSource = {
|
||||||
|
rows$: rows,
|
||||||
|
properties$: columns,
|
||||||
|
readonly$: signal(false),
|
||||||
|
featureFlags$: signal({ enable_table_virtual_scroll: false }),
|
||||||
|
provider: {
|
||||||
|
getAll: () => options?.externalFactories ?? new Map(),
|
||||||
|
},
|
||||||
|
viewDataGet: () => viewData.value,
|
||||||
|
viewDataUpdate: (
|
||||||
|
_id: string,
|
||||||
|
updater: (data: CalendarStoredViewData) => Partial<CalendarStoredViewData>
|
||||||
|
) => {
|
||||||
|
viewData.value = { ...viewData.value, ...updater(viewData.value) };
|
||||||
|
},
|
||||||
|
cellValueGet: (rowId: string, propertyId: string) =>
|
||||||
|
values.get(`${rowId}:${propertyId}`),
|
||||||
|
cellValueChange: (rowId: string, propertyId: string, value: unknown) => {
|
||||||
|
values.set(`${rowId}:${propertyId}`, value);
|
||||||
|
},
|
||||||
|
rowAdd: () => {
|
||||||
|
const rowId = `row-${rows.value.length + 1}`;
|
||||||
|
rows.value = [...rows.value, rowId];
|
||||||
|
return rowId;
|
||||||
|
},
|
||||||
|
propertyTypeGet: (propertyId: string) => types.get(propertyId),
|
||||||
|
propertyNameGet: (propertyId: string) => propertyId,
|
||||||
|
propertyDataGet: () => ({}),
|
||||||
|
propertyReadonlyGet: () => false,
|
||||||
|
serviceGet: (key: unknown) => {
|
||||||
|
if (key !== DocDisplayMetaProvider) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title: (pageId: string, referenceInfo?: { title?: string }) =>
|
||||||
|
signal(referenceInfo?.title ?? options?.linkedDocTitles?.[pageId]),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
propertyMetaGet: (type: string) => ({
|
||||||
|
type,
|
||||||
|
config: {
|
||||||
|
rawValue: {
|
||||||
|
toJson: ({ value }: { value: unknown }) => {
|
||||||
|
const deltas =
|
||||||
|
typeof value === 'object' && value != null && 'deltas$' in value
|
||||||
|
? (value as { deltas$?: { value?: unknown } }).deltas$?.value
|
||||||
|
: undefined;
|
||||||
|
if (!Array.isArray(deltas)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return deltas
|
||||||
|
.map(delta => {
|
||||||
|
const item = delta as {
|
||||||
|
insert?: unknown;
|
||||||
|
attributes?: {
|
||||||
|
reference?: {
|
||||||
|
type?: string;
|
||||||
|
pageId?: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const pageId = item.attributes?.reference?.pageId;
|
||||||
|
if (
|
||||||
|
item.attributes?.reference?.type === 'LinkedPage' &&
|
||||||
|
typeof pageId === 'string'
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
options?.linkedDocTitles?.[pageId] ?? item.insert ?? ''
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return item.insert ?? '';
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
},
|
||||||
|
fromJson: ({ value }: { value: unknown }) => value,
|
||||||
|
toString: ({ value }: { value: unknown }) =>
|
||||||
|
typeof value === 'string' ? value : '',
|
||||||
|
},
|
||||||
|
jsonValue: {
|
||||||
|
schema: {
|
||||||
|
safeParse: (value: unknown) => ({ success: true, data: value }),
|
||||||
|
},
|
||||||
|
isEmpty: () => false,
|
||||||
|
type: () => undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
renderer: {},
|
||||||
|
}),
|
||||||
|
propertyAdd: () => {
|
||||||
|
columns.value = [...columns.value, 'created-date'];
|
||||||
|
types.set('created-date', 'date');
|
||||||
|
return 'created-date';
|
||||||
|
},
|
||||||
|
propertyCanDelete: () => true,
|
||||||
|
propertyCanDuplicate: () => true,
|
||||||
|
propertyTypeCanSet: () => true,
|
||||||
|
} as unknown as DataSource;
|
||||||
|
const manager = {
|
||||||
|
dataSource,
|
||||||
|
readonly$: signal(false),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
view: new CalendarSingleView(manager as any, 'view-1'),
|
||||||
|
viewData,
|
||||||
|
values,
|
||||||
|
types,
|
||||||
|
columns,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('CalendarSingleView', () => {
|
||||||
|
it('creates default view data without selecting a start date', () => {
|
||||||
|
const data = calendarViewModel.model.defaultData({
|
||||||
|
dataSource: {
|
||||||
|
properties$: signal(['title', 'date']),
|
||||||
|
propertyTypeGet: (id: string) => (id === 'title' ? 'title' : 'date'),
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(data.date).toEqual({});
|
||||||
|
expect(data.card).toEqual({
|
||||||
|
titleColumnId: 'title',
|
||||||
|
visiblePropertyIds: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enters setup state without a start date property', () => {
|
||||||
|
const { view } = createCalendarView();
|
||||||
|
|
||||||
|
expect(view.dateMapping$.value.status).toBe('setup');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enters setup state when start date column is not date', () => {
|
||||||
|
const { view } = createCalendarView({
|
||||||
|
startColumnId: 'date',
|
||||||
|
datePropertyType: 'text',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(view.dateMapping$.value.status).toBe('setup');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enters setup state after date property deletion', () => {
|
||||||
|
const { view, columns } = createCalendarView({ startColumnId: 'date' });
|
||||||
|
|
||||||
|
columns.value = ['title', 'status'];
|
||||||
|
|
||||||
|
expect(view.dateMapping$.value.status).toBe('setup');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates row entries after filtering rows', () => {
|
||||||
|
const { view } = createCalendarView({
|
||||||
|
startColumnId: 'date',
|
||||||
|
rows: ['row-1', 'row-2'],
|
||||||
|
filterValue: 'Done',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(view.rowEntries$.value.map(entry => entry.rowId)).toEqual(['row-1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates entry date after row date value changes', () => {
|
||||||
|
const { view, values } = createCalendarView({ startColumnId: 'date' });
|
||||||
|
|
||||||
|
values.set('row-1:date', day('2026-05-20'));
|
||||||
|
|
||||||
|
expect(view.rowEntries$.value[0]?.startAt).toBe(day('2026-05-20'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates row range entries and falls back when end date is invalid', () => {
|
||||||
|
const { view } = createCalendarView({
|
||||||
|
startColumnId: 'date',
|
||||||
|
endColumnId: 'end-date',
|
||||||
|
rows: ['row-1', 'row-2'],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
view.rowEntries$.value.map(entry => [
|
||||||
|
entry.rowId,
|
||||||
|
entry.startAt,
|
||||||
|
entry.endAt,
|
||||||
|
])
|
||||||
|
).toEqual([
|
||||||
|
['row-1', day('2026-05-15'), day('2026-05-17')],
|
||||||
|
['row-2', day('2026-05-16'), undefined],
|
||||||
|
]);
|
||||||
|
expect(view.rowEntries$.value[0]?.canResizeRange).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves row range while preserving duration', () => {
|
||||||
|
const { view, values } = createCalendarView({
|
||||||
|
startColumnId: 'date',
|
||||||
|
endColumnId: 'end-date',
|
||||||
|
});
|
||||||
|
|
||||||
|
view.moveRowToDate('row-1', day('2026-05-20'));
|
||||||
|
|
||||||
|
expect(values.get('row-1:date')).toBe(day('2026-05-20'));
|
||||||
|
expect(values.get('row-1:end-date')).toBe(day('2026-05-22'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resizes row range without crossing start and end', () => {
|
||||||
|
const { view, values } = createCalendarView({
|
||||||
|
startColumnId: 'date',
|
||||||
|
endColumnId: 'end-date',
|
||||||
|
});
|
||||||
|
|
||||||
|
view.resizeRowRange('row-1', 'start', day('2026-05-18'));
|
||||||
|
expect(values.get('row-1:date')).toBe(day('2026-05-17'));
|
||||||
|
|
||||||
|
view.resizeRowRange('row-1', 'end', day('2026-05-14'));
|
||||||
|
expect(values.get('row-1:end-date')).toBe(day('2026-05-17'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a row with default filter values and target date', () => {
|
||||||
|
const { view, values } = createCalendarView({
|
||||||
|
startColumnId: 'date',
|
||||||
|
filterValue: 'Done',
|
||||||
|
});
|
||||||
|
|
||||||
|
const rowId = view.createRowOnDate(day('2026-05-25'));
|
||||||
|
|
||||||
|
expect(rowId).toBe('row-2');
|
||||||
|
expect(values.get('row-2:date')).toBe(day('2026-05-25'));
|
||||||
|
expect(values.get('row-2:status')).toBe('Done');
|
||||||
|
expect(view.emptyMonthHintDismissed$.value).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a dated linked-doc row', () => {
|
||||||
|
const { view, values } = createCalendarView({
|
||||||
|
startColumnId: 'date',
|
||||||
|
filterValue: 'Done',
|
||||||
|
});
|
||||||
|
|
||||||
|
const rowId = view.createLinkedDocRowOnDate(day('2026-05-25'), 'doc-1');
|
||||||
|
const title = values.get('row-2:title') as
|
||||||
|
| { toDelta?: () => unknown[] }
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
expect(rowId).toBe('row-2');
|
||||||
|
expect(values.get('row-2:date')).toBe(day('2026-05-25'));
|
||||||
|
expect(values.get('row-2:status')).toBe('Done');
|
||||||
|
expect(title?.toDelta?.()).toEqual([
|
||||||
|
{
|
||||||
|
insert: ' ',
|
||||||
|
attributes: {
|
||||||
|
reference: {
|
||||||
|
type: 'LinkedPage',
|
||||||
|
pageId: 'doc-1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dismisses the empty month hint on the current calendar view', () => {
|
||||||
|
const { view, viewData } = createCalendarView({
|
||||||
|
startColumnId: 'date',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(view.emptyMonthHintDismissed$.value).toBe(false);
|
||||||
|
|
||||||
|
view.dismissEmptyMonthHint();
|
||||||
|
|
||||||
|
expect(view.emptyMonthHintDismissed$.value).toBe(true);
|
||||||
|
expect('ui' in viewData.value && viewData.value.ui).toEqual({
|
||||||
|
emptyMonthHintDismissed: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates workspace calendar settings when legacy view data has no sources', () => {
|
||||||
|
const { view, viewData } = createCalendarView({
|
||||||
|
startColumnId: 'date',
|
||||||
|
});
|
||||||
|
viewData.value = {
|
||||||
|
...viewData.value,
|
||||||
|
sources: undefined as unknown as CalendarStoredViewData['sources'],
|
||||||
|
};
|
||||||
|
|
||||||
|
view.setWorkspaceCalendarEnabled(false);
|
||||||
|
|
||||||
|
expect(viewData.value.sources.workspaceCalendar).toEqual({
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enters setup state when legacy view data has no date config', () => {
|
||||||
|
const { view, viewData } = createCalendarView({
|
||||||
|
startColumnId: 'date',
|
||||||
|
endColumnId: 'end-date',
|
||||||
|
});
|
||||||
|
viewData.value = {
|
||||||
|
...viewData.value,
|
||||||
|
date: undefined as unknown as CalendarStoredViewData['date'],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(view.dateMapping$.value).toEqual({
|
||||||
|
status: 'setup',
|
||||||
|
propertyId: undefined,
|
||||||
|
});
|
||||||
|
expect(view.endDateMapping$.value).toEqual({
|
||||||
|
status: 'setup',
|
||||||
|
propertyId: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates card properties from visible property ids', () => {
|
||||||
|
const { view } = createCalendarView({
|
||||||
|
startColumnId: 'date',
|
||||||
|
visiblePropertyIds: ['status'],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(view.rowEntries$.value[0]?.cardProperties).toEqual([
|
||||||
|
{
|
||||||
|
propertyId: 'status',
|
||||||
|
value: 'Done',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses single linked doc id from title cell', () => {
|
||||||
|
const { view } = createCalendarView({
|
||||||
|
startColumnId: 'date',
|
||||||
|
linkedDocTitles: {
|
||||||
|
'doc-1': 'Linked doc title',
|
||||||
|
},
|
||||||
|
titleValue: {
|
||||||
|
deltas$: {
|
||||||
|
value: [
|
||||||
|
{
|
||||||
|
insert: 'Doc',
|
||||||
|
attributes: {
|
||||||
|
reference: {
|
||||||
|
type: 'LinkedPage',
|
||||||
|
pageId: 'doc-1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(view.rowEntries$.value[0]?.titleSegments).toEqual([
|
||||||
|
{ text: 'Linked doc title', linkedDoc: true },
|
||||||
|
]);
|
||||||
|
expect(view.rowEntries$.value[0]?.title).toBe('Linked doc title');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses normal title text for multiple linked doc titles', () => {
|
||||||
|
const { view } = createCalendarView({
|
||||||
|
startColumnId: 'date',
|
||||||
|
linkedDocTitles: {
|
||||||
|
'doc-1': 'Doc 1',
|
||||||
|
'doc-2': 'Doc 2',
|
||||||
|
},
|
||||||
|
titleValue: {
|
||||||
|
deltas$: {
|
||||||
|
value: [
|
||||||
|
{
|
||||||
|
insert: 'Doc 1',
|
||||||
|
attributes: {
|
||||||
|
reference: {
|
||||||
|
type: 'LinkedPage',
|
||||||
|
pageId: 'doc-1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
insert: 'Doc 2',
|
||||||
|
attributes: {
|
||||||
|
reference: {
|
||||||
|
type: 'LinkedPage',
|
||||||
|
pageId: 'doc-2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(view.rowEntries$.value[0]?.titleSegments).toEqual([
|
||||||
|
{ text: 'Doc 1', linkedDoc: true },
|
||||||
|
{ text: 'Doc 2', linkedDoc: true },
|
||||||
|
]);
|
||||||
|
expect(view.rowEntries$.value[0]?.title).toBe('Doc 1Doc 2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the resolved title when linked doc deltas only contain placeholders', () => {
|
||||||
|
const { view } = createCalendarView({
|
||||||
|
startColumnId: 'date',
|
||||||
|
linkedDocTitles: {
|
||||||
|
'doc-1': 'Doc 1',
|
||||||
|
'doc-2': 'Doc 2',
|
||||||
|
},
|
||||||
|
titleValue: {
|
||||||
|
deltas$: {
|
||||||
|
value: [
|
||||||
|
{
|
||||||
|
insert: ' ',
|
||||||
|
attributes: {
|
||||||
|
reference: {
|
||||||
|
type: 'LinkedPage',
|
||||||
|
pageId: 'doc-1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
insert: ' ',
|
||||||
|
attributes: {
|
||||||
|
reference: {
|
||||||
|
type: 'LinkedPage',
|
||||||
|
pageId: 'doc-2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(view.rowEntries$.value[0]?.titleSegments).toEqual([
|
||||||
|
{ text: 'Doc 1', linkedDoc: true },
|
||||||
|
{ text: 'Doc 2', linkedDoc: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges linked doc placeholders with the following plain title text', () => {
|
||||||
|
const { view } = createCalendarView({
|
||||||
|
startColumnId: 'date',
|
||||||
|
titleValue: {
|
||||||
|
deltas$: {
|
||||||
|
value: [
|
||||||
|
{
|
||||||
|
insert: ' ',
|
||||||
|
attributes: {
|
||||||
|
reference: { type: 'LinkedPage', pageId: 'doc-1' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ insert: 'How to use folder and Tags' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(view.rowEntries$.value[0]?.titleSegments).toEqual([
|
||||||
|
{ text: 'How to use folder and Tags', linkedDoc: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates date mapping through setup APIs', () => {
|
||||||
|
const { view, viewData, values } = createCalendarView({
|
||||||
|
startColumnId: 'date',
|
||||||
|
});
|
||||||
|
|
||||||
|
view.moveRowToDate('row-1', day('2026-05-21'));
|
||||||
|
expect(values.get('row-1:date')).toBe(day('2026-05-21'));
|
||||||
|
|
||||||
|
view.setDateColumn('date');
|
||||||
|
expect('date' in viewData.value && viewData.value.date.startColumnId).toBe(
|
||||||
|
'date'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(view.createDateColumn()).toBe('created-date');
|
||||||
|
expect('date' in viewData.value && viewData.value.date.startColumnId).toBe(
|
||||||
|
'created-date'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aggregates external source entries without mutating view data', async () => {
|
||||||
|
const externalEntry = {
|
||||||
|
kind: 'external',
|
||||||
|
id: 'external:1',
|
||||||
|
sourceId: 'source',
|
||||||
|
externalId: '1',
|
||||||
|
title: 'External',
|
||||||
|
startAt: day('2026-05-15'),
|
||||||
|
canResizeRange: false,
|
||||||
|
} as const;
|
||||||
|
const anotherExternalEntry = {
|
||||||
|
kind: 'external',
|
||||||
|
id: 'external:2',
|
||||||
|
sourceId: 'another-source',
|
||||||
|
externalId: '2',
|
||||||
|
title: 'Another external',
|
||||||
|
startAt: day('2026-05-16'),
|
||||||
|
canResizeRange: false,
|
||||||
|
} as const;
|
||||||
|
const { view, viewData } = createCalendarView({
|
||||||
|
startColumnId: 'date',
|
||||||
|
externalFactories: new Map([
|
||||||
|
[
|
||||||
|
'source',
|
||||||
|
{
|
||||||
|
create: () => ({
|
||||||
|
id: 'source',
|
||||||
|
getEntries: () => [externalEntry],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'another-source',
|
||||||
|
{
|
||||||
|
create: () => ({
|
||||||
|
id: 'another-source',
|
||||||
|
getEntries: () => Promise.resolve([anotherExternalEntry]),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
const viewDataBefore = JSON.stringify(viewData.value);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
view.loadExternalEntries({
|
||||||
|
from: day('2026-05-01'),
|
||||||
|
to: day('2026-05-31'),
|
||||||
|
})
|
||||||
|
).resolves.toEqual([externalEntry, anotherExternalEntry]);
|
||||||
|
expect(JSON.stringify(viewData.value)).toBe(viewDataBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps successful external entries when another source fails', async () => {
|
||||||
|
const externalEntry = {
|
||||||
|
kind: 'external',
|
||||||
|
id: 'external:1',
|
||||||
|
sourceId: 'source',
|
||||||
|
externalId: '1',
|
||||||
|
title: 'External',
|
||||||
|
startAt: day('2026-05-15'),
|
||||||
|
canResizeRange: false,
|
||||||
|
} as const;
|
||||||
|
const { view } = createCalendarView({
|
||||||
|
startColumnId: 'date',
|
||||||
|
externalFactories: new Map([
|
||||||
|
[
|
||||||
|
'source',
|
||||||
|
{
|
||||||
|
create: () => ({
|
||||||
|
id: 'source',
|
||||||
|
getEntries: () => [externalEntry],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'failing-source',
|
||||||
|
{
|
||||||
|
create: () => ({
|
||||||
|
id: 'failing-source',
|
||||||
|
getEntries: () => Promise.reject(new Error('denied')),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
view.loadExternalEntries({
|
||||||
|
from: day('2026-05-01'),
|
||||||
|
to: day('2026-05-31'),
|
||||||
|
})
|
||||||
|
).resolves.toEqual([externalEntry]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let stale external entry loads overwrite newer entries', async () => {
|
||||||
|
const oldEntry = {
|
||||||
|
kind: 'external',
|
||||||
|
id: 'external:old',
|
||||||
|
sourceId: 'source',
|
||||||
|
externalId: 'old',
|
||||||
|
title: 'Old',
|
||||||
|
startAt: day('2026-05-15'),
|
||||||
|
canResizeRange: false,
|
||||||
|
} as const;
|
||||||
|
const newEntry = {
|
||||||
|
kind: 'external',
|
||||||
|
id: 'external:new',
|
||||||
|
sourceId: 'source',
|
||||||
|
externalId: 'new',
|
||||||
|
title: 'New',
|
||||||
|
startAt: day('2026-06-15'),
|
||||||
|
canResizeRange: false,
|
||||||
|
} as const;
|
||||||
|
let resolveOld!: (entries: [typeof oldEntry]) => void;
|
||||||
|
let resolveNew!: (entries: [typeof newEntry]) => void;
|
||||||
|
const oldRequest = new Promise<[typeof oldEntry]>(resolve => {
|
||||||
|
resolveOld = resolve;
|
||||||
|
});
|
||||||
|
const newRequest = new Promise<[typeof newEntry]>(resolve => {
|
||||||
|
resolveNew = resolve;
|
||||||
|
});
|
||||||
|
const getEntries = vi
|
||||||
|
.fn()
|
||||||
|
.mockReturnValueOnce(oldRequest)
|
||||||
|
.mockReturnValueOnce(newRequest);
|
||||||
|
const { view } = createCalendarView({
|
||||||
|
startColumnId: 'date',
|
||||||
|
externalFactories: new Map([
|
||||||
|
[
|
||||||
|
'source',
|
||||||
|
{
|
||||||
|
create: () => ({
|
||||||
|
id: 'source',
|
||||||
|
getEntries,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstLoad = view.loadExternalEntries({
|
||||||
|
from: day('2026-05-01'),
|
||||||
|
to: day('2026-05-31'),
|
||||||
|
});
|
||||||
|
const secondLoad = view.loadExternalEntries({
|
||||||
|
from: day('2026-06-01'),
|
||||||
|
to: day('2026-06-30'),
|
||||||
|
});
|
||||||
|
|
||||||
|
resolveNew([newEntry]);
|
||||||
|
await expect(secondLoad).resolves.toEqual([newEntry]);
|
||||||
|
expect(
|
||||||
|
view.entries$.value.filter(entry => entry.kind === 'external')
|
||||||
|
).toEqual([newEntry]);
|
||||||
|
|
||||||
|
resolveOld([oldEntry]);
|
||||||
|
await expect(firstLoad).resolves.toEqual([oldEntry]);
|
||||||
|
expect(
|
||||||
|
view.entries$.value.filter(entry => entry.kind === 'external')
|
||||||
|
).toEqual([newEntry]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('calendar entry actions', () => {
|
||||||
|
it('formats external event popover time ranges with end time', () => {
|
||||||
|
const label = formatEntryTime({
|
||||||
|
kind: 'external',
|
||||||
|
id: 'external:1',
|
||||||
|
sourceId: 'workspace-calendar',
|
||||||
|
externalId: '1',
|
||||||
|
title: 'Planning',
|
||||||
|
startAt: new Date('2026-05-15T10:00:00').getTime(),
|
||||||
|
endAt: new Date('2026-05-15T11:00:00').getTime(),
|
||||||
|
canResizeRange: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(label).toContain(' - ');
|
||||||
|
expect(label).toContain('2026');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens row entries through the detail panel hook', () => {
|
||||||
|
const openDetailPanel = vi.fn();
|
||||||
|
const { view } = createCalendarView({ startColumnId: 'date' });
|
||||||
|
const target = {} as HTMLElement;
|
||||||
|
|
||||||
|
openCalendarEntry(
|
||||||
|
{ openDetailPanel } as any,
|
||||||
|
view,
|
||||||
|
{
|
||||||
|
kind: 'row',
|
||||||
|
id: 'database:row-1',
|
||||||
|
sourceId: 'database',
|
||||||
|
rowId: 'row-1',
|
||||||
|
title: 'Doc',
|
||||||
|
startAt: day('2026-05-15'),
|
||||||
|
cardProperties: [],
|
||||||
|
canResizeRange: false,
|
||||||
|
},
|
||||||
|
target
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(openDetailPanel).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ view, rowId: 'row-1' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('calendar view converts', () => {
|
||||||
|
it('converts header/card semantics without date mapping', () => {
|
||||||
|
const tableToCalendar = viewConverts.find(
|
||||||
|
convert => convert.from === 'table' && convert.to === 'calendar'
|
||||||
|
);
|
||||||
|
const calendarToKanban = viewConverts.find(
|
||||||
|
convert => convert.from === 'calendar' && convert.to === 'kanban'
|
||||||
|
);
|
||||||
|
const filter = { type: 'group', op: 'and', conditions: [] } as const;
|
||||||
|
const sort = { columns: [] };
|
||||||
|
const header = { titleColumn: 'title' };
|
||||||
|
|
||||||
|
expect(tableToCalendar?.convert({ filter, sort, header } as any)).toEqual({
|
||||||
|
filter,
|
||||||
|
sort,
|
||||||
|
card: { titleColumnId: 'title', visiblePropertyIds: [] },
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
calendarToKanban?.convert({
|
||||||
|
filter,
|
||||||
|
sort,
|
||||||
|
card: { titleColumnId: 'title', visiblePropertyIds: ['status'] },
|
||||||
|
date: { startColumnId: 'date' },
|
||||||
|
} as any)
|
||||||
|
).toEqual({ filter, sort, header });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('calendar dnd payload', () => {
|
||||||
|
it('reads calendar entry payloads from blocksuite dnd data', () => {
|
||||||
|
expect(
|
||||||
|
getCalendarDndEntity({
|
||||||
|
bsEntity: { type: 'calendar-entry', entryId: 'database:row-1' },
|
||||||
|
})
|
||||||
|
).toEqual({ type: 'calendar-entry', entryId: 'database:row-1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes affine doc entities for future document drops', () => {
|
||||||
|
expect(
|
||||||
|
getCalendarDndEntity({
|
||||||
|
entity: { type: 'doc', id: 'doc-1' },
|
||||||
|
})
|
||||||
|
).toEqual({ type: 'doc', docId: 'doc-1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads document payloads from blocksuite dnd data', () => {
|
||||||
|
expect(
|
||||||
|
getCalendarDndEntity({ bsEntity: { type: 'doc', docId: 'doc-1' } })
|
||||||
|
).toEqual({ type: 'doc', docId: 'doc-1' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@ import { BlockSuiteError } from '@blocksuite/global/exceptions';
|
|||||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
|
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
|
||||||
import {
|
import {
|
||||||
type Clipboard,
|
type Clipboard,
|
||||||
|
type DndController,
|
||||||
type EventName,
|
type EventName,
|
||||||
ShadowlessElement,
|
ShadowlessElement,
|
||||||
type UIEventHandler,
|
type UIEventHandler,
|
||||||
@@ -29,6 +30,7 @@ import type { DataViewWidget } from './widget/index.js';
|
|||||||
|
|
||||||
export type DataViewRendererConfig = {
|
export type DataViewRendererConfig = {
|
||||||
clipboard: Clipboard;
|
clipboard: Clipboard;
|
||||||
|
dnd?: DndController;
|
||||||
onDrag?: (evt: MouseEvent, id: string) => () => void;
|
onDrag?: (evt: MouseEvent, id: string) => () => void;
|
||||||
notification: {
|
notification: {
|
||||||
toast: (message: string) => void;
|
toast: (message: string) => void;
|
||||||
|
|||||||
@@ -2,15 +2,10 @@ import {
|
|||||||
dropdownSubMenuMiddleware,
|
dropdownSubMenuMiddleware,
|
||||||
menu,
|
menu,
|
||||||
type MenuConfig,
|
type MenuConfig,
|
||||||
type MenuOptions,
|
|
||||||
popMenu,
|
|
||||||
type PopupTarget,
|
|
||||||
} from '@blocksuite/affine-components/context-menu';
|
} from '@blocksuite/affine-components/context-menu';
|
||||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
|
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
|
||||||
import { DeleteIcon, InvisibleIcon, ViewIcon } from '@blocksuite/icons/lit';
|
import { DeleteIcon, InvisibleIcon, ViewIcon } from '@blocksuite/icons/lit';
|
||||||
import { ShadowlessElement } from '@blocksuite/std';
|
import { ShadowlessElement } from '@blocksuite/std';
|
||||||
import type { Middleware } from '@floating-ui/dom';
|
|
||||||
import { autoPlacement, offset, shift } from '@floating-ui/dom';
|
|
||||||
import { computed } from '@preact/signals-core';
|
import { computed } from '@preact/signals-core';
|
||||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||||
import { css, html, unsafeCSS } from 'lit';
|
import { css, html, unsafeCSS } from 'lit';
|
||||||
@@ -260,19 +255,12 @@ export class GroupSetting extends SignalWatcher(
|
|||||||
@query('.group-sort-setting') accessor groupContainer!: HTMLElement;
|
@query('.group-sort-setting') accessor groupContainer!: HTMLElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const selectGroupByProperty = (
|
export const buildGroupSelectItems = (
|
||||||
group: GroupTrait,
|
group: GroupTrait,
|
||||||
ops?: {
|
onSelect: (id?: string) => void
|
||||||
onSelect?: (id?: string) => void;
|
): MenuConfig[] => {
|
||||||
onClose?: () => void;
|
|
||||||
onBack?: () => void;
|
|
||||||
}
|
|
||||||
): MenuOptions => {
|
|
||||||
const view = group.view;
|
const view = group.view;
|
||||||
return {
|
return [
|
||||||
onClose: ops?.onClose,
|
|
||||||
title: { text: 'Group by', onBack: ops?.onBack, onClose: ops?.onClose },
|
|
||||||
items: [
|
|
||||||
menu.group({
|
menu.group({
|
||||||
items: view.propertiesRaw$.value
|
items: view.propertiesRaw$.value
|
||||||
.filter(property => {
|
.filter(property => {
|
||||||
@@ -289,17 +277,18 @@ export const selectGroupByProperty = (
|
|||||||
const groupByService = getGroupByService(view.manager.dataSource);
|
const groupByService = getGroupByService(view.manager.dataSource);
|
||||||
return !!groupByService?.matcher.match(dataType);
|
return !!groupByService?.matcher.match(dataType);
|
||||||
})
|
})
|
||||||
.map<MenuConfig>(property => {
|
.map<MenuConfig>(property =>
|
||||||
return menu.action({
|
menu.action({
|
||||||
name: property.name$.value,
|
name: property.name$.value,
|
||||||
isSelected: group.property$.value?.id === property.id,
|
isSelected: group.property$.value?.id === property.id,
|
||||||
prefix: html` <uni-lit .uni="${property.icon}"></uni-lit>`,
|
prefix: html`<uni-lit .uni="${property.icon}"></uni-lit>`,
|
||||||
select: () => {
|
select: () => {
|
||||||
group.changeGroup(property.id);
|
group.changeGroup(property.id);
|
||||||
ops?.onSelect?.(property.id);
|
onSelect(property.id);
|
||||||
|
return false;
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
}),
|
),
|
||||||
}),
|
}),
|
||||||
menu.group({
|
menu.group({
|
||||||
items: [
|
items: [
|
||||||
@@ -311,50 +300,28 @@ export const selectGroupByProperty = (
|
|||||||
name: 'Remove Grouping',
|
name: 'Remove Grouping',
|
||||||
select: () => {
|
select: () => {
|
||||||
group.changeGroup(undefined);
|
group.changeGroup(undefined);
|
||||||
ops?.onSelect?.();
|
onSelect(undefined);
|
||||||
|
return false;
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
],
|
];
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const popSelectGroupByProperty = (
|
export const buildGroupSettingItems = (
|
||||||
target: PopupTarget,
|
|
||||||
group: GroupTrait,
|
group: GroupTrait,
|
||||||
ops?: { onSelect?: () => void; onClose?: () => void; onBack?: () => void },
|
onGroupByClick: () => void,
|
||||||
middleware?: Array<Middleware | null | undefined | false>
|
onGroupRemoved?: () => void
|
||||||
) => {
|
): MenuConfig[] => {
|
||||||
const handler = popMenu(target, {
|
|
||||||
options: selectGroupByProperty(group, ops),
|
|
||||||
middleware,
|
|
||||||
});
|
|
||||||
handler.menu.menuElement.style.minHeight = '550px';
|
|
||||||
};
|
|
||||||
|
|
||||||
export const popGroupSetting = (
|
|
||||||
target: PopupTarget,
|
|
||||||
group: GroupTrait,
|
|
||||||
onBack: () => void,
|
|
||||||
onClose?: () => void,
|
|
||||||
middleware?: Array<Middleware | null | undefined | false>
|
|
||||||
) => {
|
|
||||||
const view = group.view;
|
const view = group.view;
|
||||||
const gProp = group.property$.value;
|
const gProp = group.property$.value;
|
||||||
if (!gProp) return;
|
if (!gProp) return [];
|
||||||
const type = gProp.type$.value;
|
const type = gProp.type$.value;
|
||||||
if (!type) return;
|
if (!type) return [];
|
||||||
|
|
||||||
const icon = gProp.icon;
|
const icon = gProp.icon;
|
||||||
const menuHandler = popMenu(target, {
|
|
||||||
options: {
|
return [
|
||||||
title: {
|
|
||||||
text: 'Group',
|
|
||||||
onBack,
|
|
||||||
onClose,
|
|
||||||
},
|
|
||||||
items: [
|
|
||||||
menu.group({
|
menu.group({
|
||||||
items: [
|
items: [
|
||||||
menu.action({
|
menu.action({
|
||||||
@@ -368,39 +335,8 @@ export const popGroupSetting = (
|
|||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
select: () => {
|
select: () => {
|
||||||
const subHandler = popMenu(target, {
|
onGroupByClick();
|
||||||
options: selectGroupByProperty(group, {
|
return false;
|
||||||
onSelect: () => {
|
|
||||||
menuHandler.close();
|
|
||||||
popGroupSetting(
|
|
||||||
target,
|
|
||||||
group,
|
|
||||||
onBack,
|
|
||||||
onClose,
|
|
||||||
middleware
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onBack: () => {
|
|
||||||
menuHandler.close();
|
|
||||||
popGroupSetting(
|
|
||||||
target,
|
|
||||||
group,
|
|
||||||
onBack,
|
|
||||||
onClose,
|
|
||||||
middleware
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onClose,
|
|
||||||
}),
|
|
||||||
middleware: [
|
|
||||||
autoPlacement({
|
|
||||||
allowedPlacements: ['bottom-start', 'top-start'],
|
|
||||||
}),
|
|
||||||
offset({ mainAxis: 15, crossAxis: -162 }),
|
|
||||||
shift({ crossAxis: true }),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
subHandler.menu.menuElement.style.minHeight = '550px';
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
@@ -446,8 +382,7 @@ export const popGroupSetting = (
|
|||||||
name: label,
|
name: label,
|
||||||
label: () => {
|
label: () => {
|
||||||
const isSelected =
|
const isSelected =
|
||||||
group.groupInfo$.value?.config.name ===
|
group.groupInfo$.value?.config.name === key;
|
||||||
key;
|
|
||||||
return html`<span
|
return html`<span
|
||||||
style="font-size:14px;color:${isSelected
|
style="font-size:14px;color:${isSelected
|
||||||
? 'var(--affine-text-emphasis-color)'
|
? 'var(--affine-text-emphasis-color)'
|
||||||
@@ -501,8 +436,8 @@ export const popGroupSetting = (
|
|||||||
name: label,
|
name: label,
|
||||||
label: () => {
|
label: () => {
|
||||||
const isSelected =
|
const isSelected =
|
||||||
group.groupInfo$.value?.config
|
group.groupInfo$.value?.config.name ===
|
||||||
.name === key;
|
key;
|
||||||
return html`<span
|
return html`<span
|
||||||
style="font-size:14px;color:${isSelected
|
style="font-size:14px;color:${isSelected
|
||||||
? 'var(--affine-text-emphasis-color)'
|
? 'var(--affine-text-emphasis-color)'
|
||||||
@@ -511,8 +446,7 @@ export const popGroupSetting = (
|
|||||||
>`;
|
>`;
|
||||||
},
|
},
|
||||||
isSelected:
|
isSelected:
|
||||||
group.groupInfo$.value?.config.name ===
|
group.groupInfo$.value?.config.name === key,
|
||||||
key,
|
|
||||||
select: () => {
|
select: () => {
|
||||||
group.changeGroupMode(key);
|
group.changeGroupMode(key);
|
||||||
return false;
|
return false;
|
||||||
@@ -540,9 +474,7 @@ export const popGroupSetting = (
|
|||||||
<div
|
<div
|
||||||
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:8px;"
|
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:8px;"
|
||||||
>
|
>
|
||||||
${group.sortAsc$.value
|
${group.sortAsc$.value ? 'Oldest first' : 'Newest first'}
|
||||||
? 'Oldest first'
|
|
||||||
: 'Newest first'}
|
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
options: {
|
options: {
|
||||||
@@ -608,9 +540,9 @@ export const popGroupSetting = (
|
|||||||
}),
|
}),
|
||||||
menu.group({
|
menu.group({
|
||||||
items: [
|
items: [
|
||||||
menu => html`
|
menuObj => html`
|
||||||
<data-view-group-setting
|
<data-view-group-setting
|
||||||
@mouseenter=${() => menu.closeSubMenu()}
|
@mouseenter=${() => menuObj.closeSubMenu()}
|
||||||
.groupTrait=${group}
|
.groupTrait=${group}
|
||||||
.columnId=${gProp.id}
|
.columnId=${gProp.id}
|
||||||
></data-view-group-setting>
|
></data-view-group-setting>
|
||||||
@@ -627,14 +559,11 @@ export const popGroupSetting = (
|
|||||||
hide: () => !(view instanceof TableSingleView),
|
hide: () => !(view instanceof TableSingleView),
|
||||||
select: () => {
|
select: () => {
|
||||||
group.changeGroup(undefined);
|
group.changeGroup(undefined);
|
||||||
|
onGroupRemoved?.();
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
],
|
];
|
||||||
},
|
|
||||||
middleware,
|
|
||||||
});
|
|
||||||
menuHandler.menu.menuElement.style.minHeight = '550px';
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,605 @@
|
|||||||
|
import { DocDisplayMetaProvider } from '@blocksuite/affine-shared/services';
|
||||||
|
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
||||||
|
import type { InsertToPosition } from '@blocksuite/affine-shared/utils';
|
||||||
|
import { type DeltaInsert, Text } from '@blocksuite/store';
|
||||||
|
import { computed, type ReadonlySignal, signal } from '@preact/signals-core';
|
||||||
|
import { Doc } from 'yjs';
|
||||||
|
|
||||||
|
import { evalFilter } from '../../core/filter/eval.js';
|
||||||
|
import { generateDefaultValues } from '../../core/filter/generate-default-values.js';
|
||||||
|
import { FilterTrait, filterTraitKey } from '../../core/filter/trait.js';
|
||||||
|
import type { FilterGroup } from '../../core/filter/types.js';
|
||||||
|
import { emptyFilterGroup } from '../../core/filter/utils.js';
|
||||||
|
import { fromJson } from '../../core/property/utils';
|
||||||
|
import { SortManager, sortTraitKey } from '../../core/sort/manager.js';
|
||||||
|
import { PropertyBase } from '../../core/view-manager/property.js';
|
||||||
|
import { type Row, RowBase } from '../../core/view-manager/row.js';
|
||||||
|
import {
|
||||||
|
type SingleView,
|
||||||
|
SingleViewBase,
|
||||||
|
} from '../../core/view-manager/single-view.js';
|
||||||
|
import type { ViewManager } from '../../core/view-manager/view-manager.js';
|
||||||
|
import { getCalendarExternalSources } from './source.js';
|
||||||
|
import type {
|
||||||
|
CalendarEntry,
|
||||||
|
CalendarEntryRange,
|
||||||
|
CalendarExternalEntry,
|
||||||
|
CalendarExternalSource,
|
||||||
|
CalendarRowEntry,
|
||||||
|
CalendarStoredViewData,
|
||||||
|
CalendarTitleSegment,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
export type CalendarDateMapping =
|
||||||
|
| {
|
||||||
|
status: 'ready';
|
||||||
|
propertyId: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: 'setup';
|
||||||
|
propertyId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStartColumnId = (data?: CalendarStoredViewData) =>
|
||||||
|
data?.date?.startColumnId;
|
||||||
|
|
||||||
|
const getEndColumnId = (data?: CalendarStoredViewData) => {
|
||||||
|
return data?.date?.endColumnId;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDateData = (data: CalendarStoredViewData) => ({
|
||||||
|
...data.date,
|
||||||
|
startColumnId: getStartColumnId(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
const getCardData = (data?: CalendarStoredViewData) => {
|
||||||
|
if (data) {
|
||||||
|
return data.card;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
visiblePropertyIds: [],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const toTimestamp = (date: number | Date) =>
|
||||||
|
date instanceof Date ? date.getTime() : date;
|
||||||
|
|
||||||
|
const isValidTimestamp = (value: unknown): value is number =>
|
||||||
|
typeof value === 'number' && Number.isFinite(value);
|
||||||
|
|
||||||
|
const createLinkedDocTitle = (docId: string) => {
|
||||||
|
const text = new Text<AffineTextAttributes>();
|
||||||
|
new Doc().getMap('root').set('text', text.yText);
|
||||||
|
text.applyDelta([
|
||||||
|
{
|
||||||
|
insert: ' ',
|
||||||
|
attributes: { reference: { type: 'LinkedPage', pageId: docId } },
|
||||||
|
},
|
||||||
|
] satisfies DeltaInsert<AffineTextAttributes>[]);
|
||||||
|
return text;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTitleDeltas = (value: unknown) =>
|
||||||
|
typeof value === 'object' && value != null && 'deltas$' in value
|
||||||
|
? (value as { deltas$?: { value?: unknown } }).deltas$?.value
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const getTitleSegments = (
|
||||||
|
value: unknown,
|
||||||
|
title: string,
|
||||||
|
getLinkedDocTitle?: (pageId: string, title?: string) => string | undefined
|
||||||
|
): CalendarTitleSegment[] | undefined => {
|
||||||
|
const deltas = getTitleDeltas(value);
|
||||||
|
if (!Array.isArray(deltas)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const segments = deltas.flatMap(delta => {
|
||||||
|
const item = delta as {
|
||||||
|
insert?: unknown;
|
||||||
|
attributes?: {
|
||||||
|
reference?: {
|
||||||
|
type?: string;
|
||||||
|
pageId?: unknown;
|
||||||
|
title?: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const linkedDoc =
|
||||||
|
item.attributes?.reference?.type === 'LinkedPage' &&
|
||||||
|
typeof item.attributes.reference.pageId === 'string';
|
||||||
|
const referenceTitle = item.attributes?.reference?.title;
|
||||||
|
const resolvedLinkedDocTitle =
|
||||||
|
linkedDoc && typeof item.attributes?.reference?.pageId === 'string'
|
||||||
|
? getLinkedDocTitle?.(
|
||||||
|
item.attributes.reference.pageId,
|
||||||
|
typeof referenceTitle === 'string' ? referenceTitle : undefined
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
const text =
|
||||||
|
resolvedLinkedDocTitle ||
|
||||||
|
(linkedDoc && typeof referenceTitle === 'string' && referenceTitle
|
||||||
|
? referenceTitle
|
||||||
|
: typeof item.insert === 'string'
|
||||||
|
? item.insert.trim()
|
||||||
|
: '');
|
||||||
|
if (linkedDoc) {
|
||||||
|
return {
|
||||||
|
text,
|
||||||
|
linkedDoc,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!text) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
text,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const normalizedSegments = segments.reduce<CalendarTitleSegment[]>(
|
||||||
|
(result, segment) => {
|
||||||
|
const previous = result.at(-1);
|
||||||
|
if (
|
||||||
|
previous?.linkedDoc &&
|
||||||
|
!previous.text &&
|
||||||
|
!segment.linkedDoc &&
|
||||||
|
segment.text
|
||||||
|
) {
|
||||||
|
previous.text = segment.text;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
result.push(segment);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
if (!normalizedSegments.some(segment => segment.linkedDoc)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!normalizedSegments.some(segment => segment.text)) {
|
||||||
|
return title
|
||||||
|
? [...normalizedSegments, { text: title }]
|
||||||
|
: normalizedSegments;
|
||||||
|
}
|
||||||
|
return normalizedSegments;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class CalendarSingleView extends SingleViewBase<CalendarStoredViewData> {
|
||||||
|
private readonly externalEntries$ = signal<CalendarExternalEntry[]>([]);
|
||||||
|
|
||||||
|
private externalEntriesRequestId = 0;
|
||||||
|
|
||||||
|
propertiesRaw$ = computed(() => {
|
||||||
|
return this.dataSource.properties$.value.map(id =>
|
||||||
|
this.propertyGetOrCreate(id)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
properties$ = this.propertiesRaw$;
|
||||||
|
|
||||||
|
detailProperties$ = computed(() => {
|
||||||
|
return this.propertiesRaw$.value.filter(
|
||||||
|
property => property.type$.value !== 'title'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
private readonly filter$ = computed(() => {
|
||||||
|
return this.data$.value?.filter ?? emptyFilterGroup;
|
||||||
|
});
|
||||||
|
|
||||||
|
private readonly sortList$ = computed(() => {
|
||||||
|
return this.data$.value?.sort;
|
||||||
|
});
|
||||||
|
|
||||||
|
emptyMonthHintDismissed$ = computed(() => {
|
||||||
|
return this.data$.value?.ui?.emptyMonthHintDismissed ?? false;
|
||||||
|
});
|
||||||
|
|
||||||
|
private readonly sortManager = this.traitSet(
|
||||||
|
sortTraitKey,
|
||||||
|
new SortManager(this.sortList$, this, {
|
||||||
|
setSortList: sortList => {
|
||||||
|
this.dataUpdate(data => ({
|
||||||
|
sort: {
|
||||||
|
...data.sort,
|
||||||
|
...sortList,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
filterTrait = this.traitSet(
|
||||||
|
filterTraitKey,
|
||||||
|
new FilterTrait(this.filter$, this, {
|
||||||
|
filterSet: (filter: FilterGroup) => {
|
||||||
|
this.dataUpdate(() => ({ filter }));
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
mainProperties$ = computed(() => {
|
||||||
|
const card = getCardData(this.data$.value);
|
||||||
|
return {
|
||||||
|
titleColumn:
|
||||||
|
card.titleColumnId ??
|
||||||
|
this.propertiesRaw$.value.find(
|
||||||
|
property => property.type$.value === 'title'
|
||||||
|
)?.id,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly$ = computed(() => {
|
||||||
|
return this.manager.readonly$.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
dateProperties$ = computed(() => {
|
||||||
|
return this.propertiesRaw$.value.filter(
|
||||||
|
property => property.type$.value === 'date'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
dateMapping$: ReadonlySignal<CalendarDateMapping> = computed(() => {
|
||||||
|
const propertyId = getStartColumnId(this.data$.value);
|
||||||
|
if (
|
||||||
|
propertyId &&
|
||||||
|
this.dataSource.properties$.value.includes(propertyId) &&
|
||||||
|
this.dataSource.propertyTypeGet(propertyId) === 'date'
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
status: 'ready',
|
||||||
|
propertyId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: 'setup',
|
||||||
|
propertyId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
startDateMapping$ = this.dateMapping$;
|
||||||
|
|
||||||
|
endDateMapping$: ReadonlySignal<CalendarDateMapping> = computed(() => {
|
||||||
|
const propertyId = getEndColumnId(this.data$.value);
|
||||||
|
if (
|
||||||
|
propertyId &&
|
||||||
|
this.dataSource.properties$.value.includes(propertyId) &&
|
||||||
|
this.dataSource.propertyTypeGet(propertyId) === 'date'
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
status: 'ready',
|
||||||
|
propertyId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: 'setup',
|
||||||
|
propertyId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
private readonly visibleCardProperties$ = computed(() => {
|
||||||
|
const card = getCardData(this.data$.value);
|
||||||
|
const visiblePropertyIds = card.visiblePropertyIds ?? [];
|
||||||
|
const titleColumn = card.titleColumnId;
|
||||||
|
return visiblePropertyIds
|
||||||
|
.filter(propertyId => propertyId !== titleColumn)
|
||||||
|
.map(propertyId => this.propertyGetOrCreate(propertyId));
|
||||||
|
});
|
||||||
|
|
||||||
|
rowEntries$ = computed<CalendarRowEntry[]>(() => {
|
||||||
|
const mapping = this.dateMapping$.value;
|
||||||
|
if (mapping.status !== 'ready') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const endMapping = this.endDateMapping$.value;
|
||||||
|
return this.rows$.value.flatMap(row => {
|
||||||
|
const startAt = this.cellGetOrCreate(row.rowId, mapping.propertyId)
|
||||||
|
.jsonValue$.value;
|
||||||
|
if (!isValidTimestamp(startAt)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const endAt =
|
||||||
|
endMapping.status === 'ready'
|
||||||
|
? this.cellGetOrCreate(row.rowId, endMapping.propertyId).jsonValue$
|
||||||
|
.value
|
||||||
|
: undefined;
|
||||||
|
const titleColumn = this.mainProperties$.value.titleColumn ?? 'title';
|
||||||
|
const titleCell = this.cellGetOrCreate(row.rowId, titleColumn);
|
||||||
|
const jsonTitle = titleCell.jsonValue$.value;
|
||||||
|
const title =
|
||||||
|
(typeof jsonTitle === 'string'
|
||||||
|
? jsonTitle
|
||||||
|
: titleCell.stringValue$.value) ?? '';
|
||||||
|
const docDisplayMeta = this.manager.dataSource.serviceGet(
|
||||||
|
DocDisplayMetaProvider
|
||||||
|
);
|
||||||
|
const resolveLinkedDocTitle = (pageId: string, title?: string) =>
|
||||||
|
docDisplayMeta?.title(pageId, { title }).value;
|
||||||
|
const titleSegments = getTitleSegments(
|
||||||
|
titleCell.value$.value,
|
||||||
|
title,
|
||||||
|
resolveLinkedDocTitle
|
||||||
|
);
|
||||||
|
const cardProperties = this.visibleCardProperties$.value.flatMap(
|
||||||
|
property => {
|
||||||
|
const cell = this.cellGetOrCreate(row.rowId, property.id);
|
||||||
|
const value = cell.stringValue$.value;
|
||||||
|
if (!value) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
propertyId: property.id,
|
||||||
|
value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
kind: 'row',
|
||||||
|
id: `database:${row.rowId}`,
|
||||||
|
sourceId: 'database',
|
||||||
|
rowId: row.rowId,
|
||||||
|
title,
|
||||||
|
startAt,
|
||||||
|
endAt: isValidTimestamp(endAt) && endAt >= startAt ? endAt : undefined,
|
||||||
|
titleSegments,
|
||||||
|
cardProperties,
|
||||||
|
canResizeRange: endMapping.status === 'ready' && !this.readonly$.value,
|
||||||
|
} satisfies CalendarRowEntry;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
entries$ = computed<CalendarEntry[]>(() => {
|
||||||
|
return [...this.rowEntries$.value, ...this.externalEntries$.value];
|
||||||
|
});
|
||||||
|
|
||||||
|
externalSources$ = computed<CalendarExternalSource[]>(() => {
|
||||||
|
const viewData = this.data$.value;
|
||||||
|
if (!viewData) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return getCalendarExternalSources(this.dataSource, viewData);
|
||||||
|
});
|
||||||
|
|
||||||
|
get type(): string {
|
||||||
|
return this.data$.value?.mode ?? 'calendar';
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(viewManager: ViewManager, viewId: string) {
|
||||||
|
super(viewManager, viewId);
|
||||||
|
}
|
||||||
|
|
||||||
|
isShow(rowId: string): boolean {
|
||||||
|
if (this.filter$.value.conditions.length) {
|
||||||
|
const rowMap = Object.fromEntries(
|
||||||
|
this.propertiesRaw$.value.map(column => [
|
||||||
|
column.id,
|
||||||
|
column.cellGetOrCreate(rowId).jsonValue$.value,
|
||||||
|
])
|
||||||
|
);
|
||||||
|
return evalFilter(this.filter$.value, rowMap);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
override rowsMapping(rows: Row[]) {
|
||||||
|
return this.sortManager.sort(super.rowsMapping(rows));
|
||||||
|
}
|
||||||
|
|
||||||
|
propertyGetOrCreate(propertyId: string): CalendarProperty {
|
||||||
|
return new CalendarProperty(this, propertyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
override rowGetOrCreate(rowId: string): CalendarRow {
|
||||||
|
return new CalendarRow(this, rowId);
|
||||||
|
}
|
||||||
|
|
||||||
|
setStartDateColumn(propertyId: string) {
|
||||||
|
this.dataUpdate(data => ({
|
||||||
|
date: {
|
||||||
|
...getDateData(data),
|
||||||
|
startColumnId: propertyId,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
setDateColumn(propertyId: string) {
|
||||||
|
this.setStartDateColumn(propertyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
setEndDateColumn(propertyId: string | undefined) {
|
||||||
|
this.dataUpdate(data => ({
|
||||||
|
date: {
|
||||||
|
...getDateData(data),
|
||||||
|
endColumnId: propertyId,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
setWorkspaceCalendarEnabled(enabled: boolean) {
|
||||||
|
this.dataUpdate(data => ({
|
||||||
|
sources: {
|
||||||
|
...data.sources,
|
||||||
|
workspaceCalendar: {
|
||||||
|
...(data.sources?.workspaceCalendar ?? { enabled: true }),
|
||||||
|
enabled,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
setWorkspaceCalendarSubscriptionIds(subscriptionIds?: string[]) {
|
||||||
|
this.dataUpdate(data => ({
|
||||||
|
sources: {
|
||||||
|
...data.sources,
|
||||||
|
workspaceCalendar: {
|
||||||
|
...(data.sources?.workspaceCalendar ?? { enabled: true }),
|
||||||
|
subscriptionIds,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
dismissEmptyMonthHint() {
|
||||||
|
this.dataUpdate(data => ({
|
||||||
|
ui: {
|
||||||
|
...data.ui,
|
||||||
|
emptyMonthHintDismissed: true,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
getDocDisplayTitle(docId: string) {
|
||||||
|
return (
|
||||||
|
this.manager.dataSource.serviceGet(DocDisplayMetaProvider)?.title(docId)
|
||||||
|
.value ?? 'Untitled'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
createStartDateColumn() {
|
||||||
|
const id = this.propertyAdd('end', {
|
||||||
|
type: 'date',
|
||||||
|
name: 'Date',
|
||||||
|
});
|
||||||
|
if (id) {
|
||||||
|
this.setStartDateColumn(id);
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
createDateColumn() {
|
||||||
|
return this.createStartDateColumn();
|
||||||
|
}
|
||||||
|
|
||||||
|
createEndDateColumn() {
|
||||||
|
const id = this.propertyAdd('end', {
|
||||||
|
type: 'date',
|
||||||
|
name: 'End Date',
|
||||||
|
});
|
||||||
|
if (id) {
|
||||||
|
this.setEndDateColumn(id);
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
createRowOnDate(date: number | Date) {
|
||||||
|
const mapping = this.startDateMapping$.value;
|
||||||
|
if (mapping.status !== 'ready') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rowId = this.rowAdd('end');
|
||||||
|
const filter = this.filter$.value;
|
||||||
|
if (filter.conditions.length > 0) {
|
||||||
|
const defaultValues = generateDefaultValues(filter, this.vars$.value);
|
||||||
|
Object.entries(defaultValues).forEach(([propertyId, jsonValue]) => {
|
||||||
|
const property = this.propertyGetOrCreate(propertyId);
|
||||||
|
const propertyMeta = property.meta$.value;
|
||||||
|
if (propertyMeta) {
|
||||||
|
const value = fromJson(propertyMeta.config, {
|
||||||
|
value: jsonValue,
|
||||||
|
data: property.data$.value,
|
||||||
|
dataSource: this.dataSource,
|
||||||
|
});
|
||||||
|
this.cellGetOrCreate(rowId, propertyId).valueSet(value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.cellGetOrCreate(rowId, mapping.propertyId).jsonValueSet(
|
||||||
|
toTimestamp(date)
|
||||||
|
);
|
||||||
|
this.dismissEmptyMonthHint();
|
||||||
|
return rowId;
|
||||||
|
}
|
||||||
|
|
||||||
|
createLinkedDocRowOnDate(date: number | Date, docId: string) {
|
||||||
|
const rowId = this.createRowOnDate(date);
|
||||||
|
if (!rowId) return;
|
||||||
|
const titleColumn = this.mainProperties$.value.titleColumn ?? 'title';
|
||||||
|
this.cellGetOrCreate(rowId, titleColumn).valueSet(
|
||||||
|
createLinkedDocTitle(docId)
|
||||||
|
);
|
||||||
|
return rowId;
|
||||||
|
}
|
||||||
|
|
||||||
|
moveRowToDate(rowId: string, date: number | Date) {
|
||||||
|
const mapping = this.startDateMapping$.value;
|
||||||
|
if (mapping.status !== 'ready') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const value = toTimestamp(date);
|
||||||
|
const oldStartAt = this.cellGetOrCreate(rowId, mapping.propertyId)
|
||||||
|
.jsonValue$.value;
|
||||||
|
const endMapping = this.endDateMapping$.value;
|
||||||
|
if (endMapping.status === 'ready' && isValidTimestamp(oldStartAt)) {
|
||||||
|
const oldEndAt = this.cellGetOrCreate(rowId, endMapping.propertyId)
|
||||||
|
.jsonValue$.value;
|
||||||
|
if (isValidTimestamp(oldEndAt) && oldEndAt >= oldStartAt) {
|
||||||
|
this.cellGetOrCreate(rowId, endMapping.propertyId).jsonValueSet(
|
||||||
|
value + (oldEndAt - oldStartAt)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.cellGetOrCreate(rowId, mapping.propertyId).jsonValueSet(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
resizeRowRange(rowId: string, edge: 'start' | 'end', date: number | Date) {
|
||||||
|
const startMapping = this.startDateMapping$.value;
|
||||||
|
const endMapping = this.endDateMapping$.value;
|
||||||
|
if (startMapping.status !== 'ready' || endMapping.status !== 'ready') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const startCell = this.cellGetOrCreate(rowId, startMapping.propertyId);
|
||||||
|
const endCell = this.cellGetOrCreate(rowId, endMapping.propertyId);
|
||||||
|
const startAt = startCell.jsonValue$.value;
|
||||||
|
const endAt = endCell.jsonValue$.value;
|
||||||
|
if (!isValidTimestamp(startAt) || !isValidTimestamp(endAt)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const value = toTimestamp(date);
|
||||||
|
if (edge === 'start') {
|
||||||
|
startCell.jsonValueSet(Math.min(value, endAt));
|
||||||
|
} else {
|
||||||
|
endCell.jsonValueSet(Math.max(value, startAt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadExternalEntries(range: CalendarEntryRange) {
|
||||||
|
const requestId = ++this.externalEntriesRequestId;
|
||||||
|
const viewData = this.data$.value;
|
||||||
|
if (!viewData) {
|
||||||
|
this.externalEntries$.value = [];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
this.externalSources$.value.map(source =>
|
||||||
|
Promise.resolve(source.getEntries(range))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const entries = results.flatMap(result =>
|
||||||
|
result.status === 'fulfilled' ? result.value : []
|
||||||
|
);
|
||||||
|
if (requestId === this.externalEntriesRequestId) {
|
||||||
|
this.externalEntries$.value = entries;
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CalendarProperty extends PropertyBase {
|
||||||
|
hide$ = computed(() => false);
|
||||||
|
|
||||||
|
constructor(view: CalendarSingleView, propertyId: string) {
|
||||||
|
super(view as SingleView, propertyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
hideSet(_hide: boolean): void {}
|
||||||
|
|
||||||
|
move(_position: InsertToPosition): void {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CalendarRow extends RowBase {
|
||||||
|
constructor(
|
||||||
|
readonly calendarView: CalendarSingleView,
|
||||||
|
rowId: string
|
||||||
|
) {
|
||||||
|
super(calendarView, rowId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { viewType } from '../../core/view/data-view.js';
|
||||||
|
import { CalendarSingleView } from './calendar-view-manager.js';
|
||||||
|
import type { CalendarViewData } from './types.js';
|
||||||
|
|
||||||
|
export const calendarViewType = viewType('calendar');
|
||||||
|
|
||||||
|
export const calendarViewModel = calendarViewType.createModel<CalendarViewData>(
|
||||||
|
{
|
||||||
|
defaultName: 'Calendar View',
|
||||||
|
dataViewManager: CalendarSingleView,
|
||||||
|
defaultData: viewManager => {
|
||||||
|
return {
|
||||||
|
filter: {
|
||||||
|
type: 'group',
|
||||||
|
op: 'and',
|
||||||
|
conditions: [],
|
||||||
|
},
|
||||||
|
date: {},
|
||||||
|
card: {
|
||||||
|
titleColumnId: viewManager.dataSource.properties$.value.find(
|
||||||
|
id => viewManager.dataSource.propertyTypeGet(id) === 'title'
|
||||||
|
),
|
||||||
|
visiblePropertyIds: [],
|
||||||
|
},
|
||||||
|
sources: {
|
||||||
|
workspaceCalendar: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ui: {},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { pcEffects } from './pc/effect.js';
|
||||||
|
|
||||||
|
export function calendarEffects() {
|
||||||
|
pcEffects();
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export * from './calendar-view-manager.js';
|
||||||
|
export * from './define.js';
|
||||||
|
export * from './layout.js';
|
||||||
|
export * from './renderer.js';
|
||||||
|
export * from './source.js';
|
||||||
|
export * from './types.js';
|
||||||
250
blocksuite/affine/data-view/src/view-presets/calendar/layout.ts
Normal file
250
blocksuite/affine/data-view/src/view-presets/calendar/layout.ts
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
import type { CalendarEntry } from './types.js';
|
||||||
|
|
||||||
|
export type CalendarDayLayout = {
|
||||||
|
date: number;
|
||||||
|
inMonth: boolean;
|
||||||
|
entries: CalendarEntry[];
|
||||||
|
segments: CalendarRangeSegment[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CalendarRangeSegment = {
|
||||||
|
entry: CalendarEntry;
|
||||||
|
weekIndex: number;
|
||||||
|
startIndex: number;
|
||||||
|
span: number;
|
||||||
|
slot: number;
|
||||||
|
startsBeforeWeek: boolean;
|
||||||
|
endsAfterWeek: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CalendarMonthLayout = {
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
weeks: CalendarDayLayout[][];
|
||||||
|
days: CalendarDayLayout[];
|
||||||
|
segments: CalendarRangeSegment[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CalendarMonthLayoutOptions = {
|
||||||
|
month: number | Date;
|
||||||
|
entries: CalendarEntry[];
|
||||||
|
weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
||||||
|
};
|
||||||
|
|
||||||
|
const startOfDay = (date: Date) =>
|
||||||
|
new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
|
||||||
|
|
||||||
|
const addDays = (date: number, days: number) => {
|
||||||
|
const current = new Date(date);
|
||||||
|
return startOfDay(
|
||||||
|
new Date(
|
||||||
|
current.getFullYear(),
|
||||||
|
current.getMonth(),
|
||||||
|
current.getDate() + days
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const endOfDay = (date: number) => addDays(date, 1) - 1;
|
||||||
|
|
||||||
|
const toDate = (value: number | Date) =>
|
||||||
|
value instanceof Date ? value : new Date(value);
|
||||||
|
|
||||||
|
export const getCalendarVisibleMonthRange = (
|
||||||
|
month: number | Date,
|
||||||
|
weekStartsOn: 0 | 1 | 2 | 3 | 4 | 5 | 6 = 0
|
||||||
|
) => {
|
||||||
|
const cursor = toDate(month);
|
||||||
|
const monthStart = new Date(cursor.getFullYear(), cursor.getMonth(), 1);
|
||||||
|
const monthEnd = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 0);
|
||||||
|
const startOffset = (monthStart.getDay() - weekStartsOn + 7) % 7;
|
||||||
|
const endOffset = (weekStartsOn + 6 - monthEnd.getDay() + 7) % 7;
|
||||||
|
const from = startOfDay(
|
||||||
|
new Date(
|
||||||
|
monthStart.getFullYear(),
|
||||||
|
monthStart.getMonth(),
|
||||||
|
monthStart.getDate() - startOffset
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const to = endOfDay(
|
||||||
|
startOfDay(
|
||||||
|
new Date(
|
||||||
|
monthEnd.getFullYear(),
|
||||||
|
monthEnd.getMonth(),
|
||||||
|
monthEnd.getDate() + endOffset
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
monthStart: startOfDay(monthStart),
|
||||||
|
monthEnd: endOfDay(startOfDay(monthEnd)),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const isRangeEntry = (entry: CalendarEntry) =>
|
||||||
|
entry.endAt != null &&
|
||||||
|
getRangeEndDay(entry) > startOfDay(new Date(entry.startAt));
|
||||||
|
|
||||||
|
const getRangeEndDay = (entry: CalendarEntry) => {
|
||||||
|
const endAt = entry.endAt ?? entry.startAt;
|
||||||
|
const end = new Date(endAt);
|
||||||
|
if (
|
||||||
|
entry.kind === 'external' &&
|
||||||
|
entry.allDay &&
|
||||||
|
endAt > entry.startAt &&
|
||||||
|
end.getHours() === 0 &&
|
||||||
|
end.getMinutes() === 0 &&
|
||||||
|
end.getSeconds() === 0 &&
|
||||||
|
end.getMilliseconds() === 0
|
||||||
|
) {
|
||||||
|
return addDays(startOfDay(end), -1);
|
||||||
|
}
|
||||||
|
return startOfDay(end);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clamp = (value: number, min: number, max: number) =>
|
||||||
|
Math.min(Math.max(value, min), max);
|
||||||
|
|
||||||
|
const getDayOffset = (days: CalendarDayLayout[], date: number) =>
|
||||||
|
days.findIndex(day => day.date === date);
|
||||||
|
|
||||||
|
const assignSegmentSlots = (
|
||||||
|
weeks: CalendarDayLayout[][],
|
||||||
|
segments: CalendarRangeSegment[]
|
||||||
|
) => {
|
||||||
|
for (let weekIndex = 0; weekIndex < weeks.length; weekIndex++) {
|
||||||
|
const weekSegments = segments.filter(
|
||||||
|
segment => segment.weekIndex === weekIndex
|
||||||
|
);
|
||||||
|
const slots: boolean[][] = [];
|
||||||
|
for (const segment of weekSegments) {
|
||||||
|
let slot = 0;
|
||||||
|
while (
|
||||||
|
slots[slot]?.some(
|
||||||
|
(occupied, index) =>
|
||||||
|
occupied &&
|
||||||
|
index >= segment.startIndex &&
|
||||||
|
index < segment.startIndex + segment.span
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
slot++;
|
||||||
|
}
|
||||||
|
const slotDays = (slots[slot] ??= Array.from({ length: 7 }, () => false));
|
||||||
|
for (
|
||||||
|
let index = segment.startIndex;
|
||||||
|
index < segment.startIndex + segment.span;
|
||||||
|
index++
|
||||||
|
) {
|
||||||
|
slotDays[index] = true;
|
||||||
|
}
|
||||||
|
segment.slot = slot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCalendarDaySegmentSlots = (
|
||||||
|
day: CalendarDayLayout,
|
||||||
|
ignoredEntryId?: string
|
||||||
|
) => {
|
||||||
|
return Math.max(
|
||||||
|
0,
|
||||||
|
...day.segments
|
||||||
|
.filter(segment => segment.entry.id !== ignoredEntryId)
|
||||||
|
.map(segment => segment.slot + 1)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCalendarDayContentSlots = (
|
||||||
|
day: CalendarDayLayout,
|
||||||
|
ignoredEntryId?: string
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
getCalendarDaySegmentSlots(day, ignoredEntryId) +
|
||||||
|
day.entries.filter(entry => entry.id !== ignoredEntryId).length
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createCalendarMonthLayout = ({
|
||||||
|
month,
|
||||||
|
entries,
|
||||||
|
weekStartsOn = 0,
|
||||||
|
}: CalendarMonthLayoutOptions): CalendarMonthLayout => {
|
||||||
|
const range = getCalendarVisibleMonthRange(month, weekStartsOn);
|
||||||
|
const cursor = toDate(month);
|
||||||
|
const days: CalendarDayLayout[] = [];
|
||||||
|
const dayByTime = new Map<number, CalendarDayLayout>();
|
||||||
|
|
||||||
|
for (let date = range.from; date <= range.to; date = addDays(date, 1)) {
|
||||||
|
const day: CalendarDayLayout = {
|
||||||
|
date,
|
||||||
|
inMonth:
|
||||||
|
new Date(date).getMonth() === cursor.getMonth() &&
|
||||||
|
new Date(date).getFullYear() === cursor.getFullYear(),
|
||||||
|
entries: [],
|
||||||
|
segments: [],
|
||||||
|
};
|
||||||
|
days.push(day);
|
||||||
|
dayByTime.set(date, day);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (isRangeEntry(entry)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const day = dayByTime.get(startOfDay(new Date(entry.startAt)));
|
||||||
|
if (day) {
|
||||||
|
day.entries.push(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const segments: CalendarRangeSegment[] = [];
|
||||||
|
const rangeEntries = entries.filter(isRangeEntry);
|
||||||
|
const visibleEndDay = startOfDay(new Date(range.to));
|
||||||
|
for (const entry of rangeEntries) {
|
||||||
|
const entryStart = startOfDay(new Date(entry.startAt));
|
||||||
|
const entryEnd = getRangeEndDay(entry);
|
||||||
|
if (entryEnd < range.from || entryStart > visibleEndDay) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const start = clamp(entryStart, range.from, visibleEndDay);
|
||||||
|
const end = clamp(entryEnd, range.from, visibleEndDay);
|
||||||
|
const startOffset = getDayOffset(days, start);
|
||||||
|
const endOffset = getDayOffset(days, end);
|
||||||
|
if (startOffset < 0 || endOffset < 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let offset = startOffset;
|
||||||
|
while (offset <= endOffset) {
|
||||||
|
const weekIndex = Math.floor(offset / 7);
|
||||||
|
const startIndex = offset % 7;
|
||||||
|
const weekEndOffset = weekIndex * 7 + 6;
|
||||||
|
const span = Math.min(endOffset, weekEndOffset) - offset + 1;
|
||||||
|
const segment = {
|
||||||
|
entry,
|
||||||
|
weekIndex,
|
||||||
|
startIndex,
|
||||||
|
span,
|
||||||
|
slot: 0,
|
||||||
|
startsBeforeWeek: startOffset < weekIndex * 7,
|
||||||
|
endsAfterWeek: endOffset > weekEndOffset,
|
||||||
|
};
|
||||||
|
segments.push(segment);
|
||||||
|
for (let index = 0; index < span; index++) {
|
||||||
|
days[offset + index]?.segments.push(segment);
|
||||||
|
}
|
||||||
|
offset += span;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const weeks: CalendarDayLayout[][] = [];
|
||||||
|
for (let index = 0; index < days.length; index += 7) {
|
||||||
|
weeks.push(days.slice(index, index + 7));
|
||||||
|
}
|
||||||
|
|
||||||
|
assignSegmentSlots(weeks, segments);
|
||||||
|
|
||||||
|
return { from: range.from, to: range.to, weeks, days, segments };
|
||||||
|
};
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import {
|
||||||
|
popMenu,
|
||||||
|
popupTargetFromElement,
|
||||||
|
} from '@blocksuite/affine-components/context-menu';
|
||||||
|
import {
|
||||||
|
CalendarPanelIcon,
|
||||||
|
DateTimeIcon,
|
||||||
|
PinIcon,
|
||||||
|
TextIcon,
|
||||||
|
} from '@blocksuite/icons/lit';
|
||||||
|
import { html } from 'lit';
|
||||||
|
|
||||||
|
import type { DataViewRootUILogic } from '../../../core/data-view.js';
|
||||||
|
import type { CalendarSingleView } from '../calendar-view-manager.js';
|
||||||
|
import type { CalendarEntry } from '../types.js';
|
||||||
|
|
||||||
|
const dateTimeFormatter = new Intl.DateTimeFormat(undefined, {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short',
|
||||||
|
});
|
||||||
|
|
||||||
|
const dateFormatter = new Intl.DateTimeFormat(undefined, {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const formatEntryTime = (entry: CalendarEntry) => {
|
||||||
|
const formatter = entry.allDay ? dateFormatter : dateTimeFormatter;
|
||||||
|
const start = formatter.format(new Date(entry.startAt));
|
||||||
|
if (!entry.endAt) {
|
||||||
|
return start;
|
||||||
|
}
|
||||||
|
return `${start} - ${formatter.format(new Date(entry.endAt))}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const openCalendarEntry = (
|
||||||
|
root: DataViewRootUILogic,
|
||||||
|
view: CalendarSingleView,
|
||||||
|
entry: CalendarEntry,
|
||||||
|
target: HTMLElement,
|
||||||
|
options?: { selectEntry?: (entryId: string | undefined) => void }
|
||||||
|
) => {
|
||||||
|
if (entry.kind === 'row') {
|
||||||
|
options?.selectEntry?.(entry.id);
|
||||||
|
root.openDetailPanel({
|
||||||
|
view,
|
||||||
|
rowId: entry.rowId,
|
||||||
|
onClose: () => options?.selectEntry?.(undefined),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
popMenu(popupTargetFromElement(target), {
|
||||||
|
options: {
|
||||||
|
items: [
|
||||||
|
() => html`
|
||||||
|
<div class="calendar-event-popover">
|
||||||
|
<div class="calendar-event-popover-title">${entry.title}</div>
|
||||||
|
<div class="calendar-event-popover-row">
|
||||||
|
<span class="calendar-event-popover-icon"
|
||||||
|
>${CalendarPanelIcon()}</span
|
||||||
|
>
|
||||||
|
<span>${entry.calendarName ?? 'Calendar event'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="calendar-event-popover-row">
|
||||||
|
<span class="calendar-event-popover-icon">${DateTimeIcon()}</span>
|
||||||
|
<span>${formatEntryTime(entry)}</span>
|
||||||
|
</div>
|
||||||
|
${entry.location
|
||||||
|
? html`<div class="calendar-event-popover-row">
|
||||||
|
<span class="calendar-event-popover-icon">${PinIcon()}</span>
|
||||||
|
<span>${entry.location}</span>
|
||||||
|
</div>`
|
||||||
|
: ''}
|
||||||
|
${entry.description
|
||||||
|
? html`<div class="calendar-event-popover-row">
|
||||||
|
<span class="calendar-event-popover-icon">${TextIcon()}</span>
|
||||||
|
<span class="calendar-event-popover-description"
|
||||||
|
>${entry.description}</span
|
||||||
|
>
|
||||||
|
</div>`
|
||||||
|
: ''}
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
244
blocksuite/affine/data-view/src/view-presets/calendar/pc/dnd.ts
Normal file
244
blocksuite/affine/data-view/src/view-presets/calendar/pc/dnd.ts
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
import type { DndController } from '@blocksuite/std';
|
||||||
|
|
||||||
|
import type { CalendarEntry, CalendarRowEntry } from '../types.js';
|
||||||
|
import { getCalendarDateFromPoint } from './hit-test.js';
|
||||||
|
|
||||||
|
export type CalendarDndEntity =
|
||||||
|
| {
|
||||||
|
type: 'calendar-entry';
|
||||||
|
entryId: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'doc';
|
||||||
|
docId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CalendarDndData = {
|
||||||
|
bsEntity?: unknown;
|
||||||
|
entity?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
|
typeof value === 'object' && value !== null;
|
||||||
|
|
||||||
|
export const getCalendarDndEntity = (
|
||||||
|
data: unknown
|
||||||
|
): CalendarDndEntity | undefined => {
|
||||||
|
if (!isRecord(data)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bsEntity = (data as CalendarDndData).bsEntity;
|
||||||
|
if (isRecord(bsEntity)) {
|
||||||
|
if (
|
||||||
|
bsEntity.type === 'calendar-entry' &&
|
||||||
|
typeof bsEntity.entryId === 'string'
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
type: 'calendar-entry',
|
||||||
|
entryId: bsEntity.entryId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (bsEntity.type === 'doc' && typeof bsEntity.docId === 'string') {
|
||||||
|
return {
|
||||||
|
type: 'doc',
|
||||||
|
docId: bsEntity.docId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const entity = (data as CalendarDndData).entity;
|
||||||
|
if (
|
||||||
|
isRecord(entity) &&
|
||||||
|
entity.type === 'doc' &&
|
||||||
|
typeof entity.id === 'string'
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
type: 'doc',
|
||||||
|
docId: entity.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CalendarDndCallbacks = {
|
||||||
|
getEntry: (entryId: string) => CalendarEntry | undefined;
|
||||||
|
canDragEntry: () => boolean;
|
||||||
|
canDrop: (entity: CalendarDndEntity) => boolean;
|
||||||
|
onEntryDragStart: (entry: CalendarRowEntry) => void;
|
||||||
|
onEntryDragEnd: () => void;
|
||||||
|
onDropTargetChange: (
|
||||||
|
date: number | undefined,
|
||||||
|
entity?: CalendarDndEntity
|
||||||
|
) => void;
|
||||||
|
onDrop: (entity: CalendarDndEntity, date: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ElementCleanup = {
|
||||||
|
element: HTMLElement;
|
||||||
|
cleanup: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class CalendarDnd {
|
||||||
|
private readonly entryCleanups = new Map<string, ElementCleanup>();
|
||||||
|
|
||||||
|
private rootCleanup?: ElementCleanup;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly dnd: DndController | undefined,
|
||||||
|
private readonly callbacks: CalendarDndCallbacks
|
||||||
|
) {}
|
||||||
|
|
||||||
|
bindRoot(element?: Element) {
|
||||||
|
if (!this.dnd || !(element instanceof HTMLElement)) {
|
||||||
|
this.cleanupRoot();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.rootCleanup?.element === element) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.cleanupRoot();
|
||||||
|
|
||||||
|
const cleanup = this.dnd.dropTarget<CalendarDndEntity, { date?: number }>({
|
||||||
|
element,
|
||||||
|
getIsSticky: () => true,
|
||||||
|
setDropData: ({ input }) => ({
|
||||||
|
date: getCalendarDateFromPoint(element, input.clientX, input.clientY),
|
||||||
|
}),
|
||||||
|
canDrop: ({ source, input }) => {
|
||||||
|
const entity = getCalendarDndEntity(source.data);
|
||||||
|
const date = getCalendarDateFromPoint(
|
||||||
|
element,
|
||||||
|
input.clientX,
|
||||||
|
input.clientY
|
||||||
|
);
|
||||||
|
return entity && date !== undefined
|
||||||
|
? this.callbacks.canDrop(entity)
|
||||||
|
: false;
|
||||||
|
},
|
||||||
|
onDrag: ({ source, location }) => {
|
||||||
|
this.updateDropTarget(element, source.data, location.current.input);
|
||||||
|
},
|
||||||
|
onDragEnter: ({ source, location }) => {
|
||||||
|
this.updateDropTarget(element, source.data, location.current.input);
|
||||||
|
},
|
||||||
|
onDragLeave: () => {
|
||||||
|
this.callbacks.onDropTargetChange(undefined);
|
||||||
|
},
|
||||||
|
onDrop: ({ source, location }) => {
|
||||||
|
const entity = getCalendarDndEntity(source.data);
|
||||||
|
const date = getCalendarDateFromPoint(
|
||||||
|
element,
|
||||||
|
location.current.input.clientX,
|
||||||
|
location.current.input.clientY
|
||||||
|
);
|
||||||
|
if (entity && date !== undefined && this.callbacks.canDrop(entity)) {
|
||||||
|
this.callbacks.onDrop(entity, date);
|
||||||
|
}
|
||||||
|
this.callbacks.onDropTargetChange(undefined);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.rootCleanup = { element, cleanup };
|
||||||
|
}
|
||||||
|
|
||||||
|
bindEntry(
|
||||||
|
key: string,
|
||||||
|
entry: CalendarEntry,
|
||||||
|
element?: Element,
|
||||||
|
disabled = false
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
!this.dnd ||
|
||||||
|
!(element instanceof HTMLElement) ||
|
||||||
|
entry.kind !== 'row' ||
|
||||||
|
disabled
|
||||||
|
) {
|
||||||
|
this.cleanupEntry(key);
|
||||||
|
if (element instanceof HTMLElement) {
|
||||||
|
element.setAttribute('draggable', 'false');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = this.entryCleanups.get(key);
|
||||||
|
if (current?.element === element) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.cleanupEntry(key);
|
||||||
|
|
||||||
|
const cleanup = this.dnd.draggable<CalendarDndEntity>({
|
||||||
|
element,
|
||||||
|
canDrag: () => {
|
||||||
|
const currentEntry = this.callbacks.getEntry(entry.id);
|
||||||
|
return currentEntry?.kind === 'row'
|
||||||
|
? this.callbacks.canDragEntry()
|
||||||
|
: false;
|
||||||
|
},
|
||||||
|
setDragData: () => ({
|
||||||
|
type: 'calendar-entry',
|
||||||
|
entryId: entry.id,
|
||||||
|
}),
|
||||||
|
setDragPreview: ({ container, setOffset }) => {
|
||||||
|
const currentEntry = this.callbacks.getEntry(entry.id);
|
||||||
|
const preview = document.createElement('div');
|
||||||
|
preview.textContent = currentEntry?.title || 'Untitled';
|
||||||
|
preview.style.cssText =
|
||||||
|
'padding:0 6px;height:22px;line-height:22px;border-radius:4px;' +
|
||||||
|
'font-size:12px;white-space:nowrap;overflow:hidden;' +
|
||||||
|
'background:var(--affine-hover-color,#f5f5f5);' +
|
||||||
|
'color:var(--affine-text-primary-color,#333);' +
|
||||||
|
'max-width:140px;text-overflow:ellipsis;pointer-events:none;';
|
||||||
|
container.append(preview);
|
||||||
|
setOffset({ x: 10, y: 11 });
|
||||||
|
},
|
||||||
|
onDragStart: () => {
|
||||||
|
const currentEntry = this.callbacks.getEntry(entry.id);
|
||||||
|
if (currentEntry?.kind === 'row') {
|
||||||
|
this.callbacks.onEntryDragStart(currentEntry);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDrop: () => {
|
||||||
|
this.callbacks.onEntryDragEnd();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.entryCleanups.set(key, { element, cleanup });
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
this.cleanupRoot();
|
||||||
|
for (const key of this.entryCleanups.keys()) {
|
||||||
|
this.cleanupEntry(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanupEntry(key: string) {
|
||||||
|
this.entryCleanups.get(key)?.cleanup();
|
||||||
|
this.entryCleanups.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanupRoot() {
|
||||||
|
this.rootCleanup?.cleanup();
|
||||||
|
this.rootCleanup = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateDropTarget(
|
||||||
|
root: HTMLElement,
|
||||||
|
data: unknown,
|
||||||
|
input: {
|
||||||
|
clientX: number;
|
||||||
|
clientY: number;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const entity = getCalendarDndEntity(data);
|
||||||
|
const date = getCalendarDateFromPoint(root, input.clientX, input.clientY);
|
||||||
|
if (entity && date !== undefined && this.callbacks.canDrop(entity)) {
|
||||||
|
this.callbacks.onDropTargetChange(date, entity);
|
||||||
|
} else {
|
||||||
|
this.callbacks.onDropTargetChange(undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { CalendarViewUI } from './view.js';
|
||||||
|
|
||||||
|
export function pcEffects() {
|
||||||
|
if (customElements.get('affine-data-view-calendar')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
customElements.define('affine-data-view-calendar', CalendarViewUI);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
export const getCalendarDateFromPoint = (
|
||||||
|
root: HTMLElement,
|
||||||
|
clientX: number,
|
||||||
|
clientY: number
|
||||||
|
) => {
|
||||||
|
const doc = root.ownerDocument;
|
||||||
|
const hitStack = doc.elementsFromPoint(clientX, clientY);
|
||||||
|
|
||||||
|
for (const element of hitStack) {
|
||||||
|
const day = element.closest<HTMLElement>('.calendar-day[data-date]');
|
||||||
|
if (day && root.contains(day)) {
|
||||||
|
return Number(day.dataset['date']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const element of hitStack) {
|
||||||
|
const week =
|
||||||
|
element.closest<HTMLElement>('.calendar-week') ??
|
||||||
|
element.closest<HTMLElement>('.calendar-segments')?.parentElement;
|
||||||
|
if (week && root.contains(week)) {
|
||||||
|
const days = week.querySelectorAll<HTMLElement>('.calendar-day');
|
||||||
|
for (const day of days) {
|
||||||
|
const rect = day.getBoundingClientRect();
|
||||||
|
if (
|
||||||
|
clientX >= rect.left &&
|
||||||
|
clientX < rect.right &&
|
||||||
|
clientY >= rect.top &&
|
||||||
|
clientY < rect.bottom &&
|
||||||
|
day.dataset['date']
|
||||||
|
) {
|
||||||
|
return Number(day.dataset['date']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
};
|
||||||
@@ -0,0 +1,699 @@
|
|||||||
|
import { css } from 'lit';
|
||||||
|
|
||||||
|
export const calendarViewStyles = css`
|
||||||
|
affine-data-view-calendar {
|
||||||
|
display: block;
|
||||||
|
min-width: 720px;
|
||||||
|
--calendar-entry-height: 22px;
|
||||||
|
--calendar-entry-gap: 3px;
|
||||||
|
--calendar-entry-slot-height: calc(
|
||||||
|
var(--calendar-entry-height) + var(--calendar-entry-gap)
|
||||||
|
);
|
||||||
|
--calendar-grid-border-color: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--affine-border-color) 58%,
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
--calendar-entry-bg: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--affine-primary-color) 12%,
|
||||||
|
var(--affine-background-primary-color)
|
||||||
|
);
|
||||||
|
--calendar-entry-hover-bg: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--affine-primary-color) 18%,
|
||||||
|
var(--affine-background-primary-color)
|
||||||
|
);
|
||||||
|
--calendar-entry-text-color: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--affine-primary-color) 72%,
|
||||||
|
var(--affine-text-primary-color)
|
||||||
|
);
|
||||||
|
--calendar-external-fallback-color: #b45309;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-shell {
|
||||||
|
position: relative;
|
||||||
|
padding: 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 36px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-title {
|
||||||
|
color: var(--affine-text-primary-color);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-nav button,
|
||||||
|
.calendar-setup button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
border: 1px solid var(--affine-border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--affine-background-primary-color);
|
||||||
|
color: var(--affine-text-primary-color);
|
||||||
|
height: 28px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 20px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-nav button svg,
|
||||||
|
.calendar-setup button svg,
|
||||||
|
.calendar-new-row svg,
|
||||||
|
.calendar-empty-month-hint-action svg,
|
||||||
|
.calendar-empty-month-hint-close svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
color: var(--affine-icon-secondary);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-nav .calendar-icon-button {
|
||||||
|
width: 28px;
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-nav .calendar-today-button {
|
||||||
|
color: var(--affine-primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-weekdays,
|
||||||
|
.calendar-week {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-week {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-segments {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 30px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||||
|
grid-auto-rows: var(--calendar-entry-slot-height);
|
||||||
|
row-gap: 0;
|
||||||
|
column-gap: 0;
|
||||||
|
padding: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-segments .calendar-entry {
|
||||||
|
align-self: start;
|
||||||
|
height: var(--calendar-entry-height);
|
||||||
|
box-sizing: border-box;
|
||||||
|
pointer-events: auto;
|
||||||
|
margin: 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-segments .calendar-entry-preview {
|
||||||
|
align-self: start;
|
||||||
|
pointer-events: none;
|
||||||
|
margin: 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-weekday {
|
||||||
|
color: var(--affine-text-secondary-color);
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-grid {
|
||||||
|
border-top: 1px solid var(--calendar-grid-border-color);
|
||||||
|
border-left: 1px solid var(--calendar-grid-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day {
|
||||||
|
position: relative;
|
||||||
|
min-height: 112px;
|
||||||
|
border-right: 1px solid var(--calendar-grid-border-color);
|
||||||
|
border-bottom: 1px solid var(--calendar-grid-border-color);
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day.is-outside {
|
||||||
|
background: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--affine-background-secondary-color) 55%,
|
||||||
|
var(--affine-background-primary-color)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day:not(.is-outside):hover {
|
||||||
|
background: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--affine-primary-color) 2%,
|
||||||
|
var(--affine-background-primary-color)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day.is-drop-target {
|
||||||
|
box-shadow: inset 0 0 0 1px var(--affine-primary-color);
|
||||||
|
background: color-mix(in srgb, var(--affine-primary-color) 8%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day.is-today {
|
||||||
|
background: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--affine-primary-color) 6%,
|
||||||
|
var(--affine-background-primary-color)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day-number {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: max-content;
|
||||||
|
min-width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
padding: 0 2px;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--affine-text-secondary-color);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day:not(.is-outside) .calendar-day-number {
|
||||||
|
color: var(--affine-text-primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day.is-outside .calendar-day-number {
|
||||||
|
color: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--affine-text-secondary-color) 60%,
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day.is-today .calendar-day-number {
|
||||||
|
color: var(--affine-primary-color);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day.is-today:hover {
|
||||||
|
background: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--affine-primary-color) 9%,
|
||||||
|
var(--affine-background-primary-color)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-entry {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
min-height: var(--calendar-entry-height);
|
||||||
|
margin-top: var(--calendar-entry-gap);
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--calendar-entry-text-color);
|
||||||
|
background: var(--calendar-entry-bg);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-nav button:hover,
|
||||||
|
.calendar-setup button:hover {
|
||||||
|
background: var(--affine-hover-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-entry.row:hover {
|
||||||
|
background: var(--calendar-entry-hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-entry:focus-visible {
|
||||||
|
outline: 1px solid var(--affine-primary-color);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-entry.external:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-entry.selected {
|
||||||
|
box-shadow: inset 0 0 0 1px var(--affine-primary-color);
|
||||||
|
background: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--affine-primary-color) 15%,
|
||||||
|
var(--calendar-entry-bg)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-entry.continues-left {
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-entry.continues-right {
|
||||||
|
border-top-right-radius: 0;
|
||||||
|
border-bottom-right-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-entry-title {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-entry-title.is-empty {
|
||||||
|
color: var(--affine-text-secondary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-entry-title.title-segments {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-entry-title-segment {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-entry-title-segment.linked-doc-segment {
|
||||||
|
gap: 3px;
|
||||||
|
min-width: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-entry-title-segment.linked-doc-segment svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-entry-title-text {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-entry-title-segment.linked-doc-segment .calendar-entry-title-text {
|
||||||
|
flex-shrink: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-entry-properties {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 3px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-entry-property {
|
||||||
|
max-width: 72px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: color-mix(in srgb, var(--affine-pure-white) 80%, transparent);
|
||||||
|
color: var(--affine-text-primary-color);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-entry.external {
|
||||||
|
color: var(--affine-pure-white);
|
||||||
|
background: var(
|
||||||
|
--calendar-external-color,
|
||||||
|
var(--calendar-external-fallback-color)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-entry[draggable='true'] {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-entry[draggable='true']:active {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-resize-handle {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 6px;
|
||||||
|
cursor: ew-resize;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-resize-handle.left {
|
||||||
|
left: 0;
|
||||||
|
border-radius: 4px 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-resize-handle.right {
|
||||||
|
right: 0;
|
||||||
|
border-radius: 0 4px 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-resize-handle::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 2px;
|
||||||
|
height: 10px;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
border-radius: 1px;
|
||||||
|
background: var(--affine-icon-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-resize-handle:hover::after {
|
||||||
|
background: var(--affine-primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-entry:hover .calendar-resize-handle {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-entry-preview {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
min-height: var(--calendar-entry-height);
|
||||||
|
height: var(--calendar-entry-height);
|
||||||
|
margin-top: var(--calendar-entry-gap);
|
||||||
|
padding: 0 6px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1.5px dashed var(--affine-primary-color);
|
||||||
|
background: color-mix(in srgb, var(--affine-primary-color) 6%, transparent);
|
||||||
|
color: var(--affine-primary-color);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-entry-preview svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-entry-preview.continues-left {
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
border-left: none;
|
||||||
|
padding-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-entry-preview.continues-right {
|
||||||
|
border-top-right-radius: 0;
|
||||||
|
border-bottom-right-radius: 0;
|
||||||
|
border-right: none;
|
||||||
|
padding-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day-entries > .calendar-entry:first-child,
|
||||||
|
.calendar-day-entries > .calendar-entry-preview:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day-entries {
|
||||||
|
padding-top: calc(
|
||||||
|
var(--calendar-segment-slots, 0) * var(--calendar-entry-slot-height)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-new-row {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
width: 100%;
|
||||||
|
height: 24px;
|
||||||
|
margin-top: 3px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--affine-primary-color);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 18px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
opacity: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
box-sizing: border-box;
|
||||||
|
transition:
|
||||||
|
opacity 0.1s ease,
|
||||||
|
background 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-new-row svg,
|
||||||
|
.calendar-empty-month-hint-action svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
color: var(--affine-primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day:hover .calendar-new-row,
|
||||||
|
.calendar-new-row:focus-visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day:hover .calendar-new-row {
|
||||||
|
background: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--affine-primary-color) 10%,
|
||||||
|
var(--affine-background-primary-color)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day:hover .calendar-new-row:disabled,
|
||||||
|
.calendar-day.is-today:hover .calendar-new-row:disabled,
|
||||||
|
.calendar-new-row:disabled {
|
||||||
|
background: transparent;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day.is-today:hover .calendar-new-row,
|
||||||
|
.calendar-day.is-today .calendar-new-row:focus-visible {
|
||||||
|
background: var(--affine-primary-color);
|
||||||
|
color: var(--affine-pure-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day.is-today .calendar-new-row:hover {
|
||||||
|
background: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--affine-primary-color) 88%,
|
||||||
|
var(--affine-pure-white)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day.is-today:hover .calendar-new-row svg,
|
||||||
|
.calendar-day.is-today .calendar-new-row:focus-visible svg {
|
||||||
|
color: var(--affine-pure-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-new-row:hover {
|
||||||
|
background: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--affine-primary-color) 16%,
|
||||||
|
var(--affine-background-primary-color)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-empty-month-hint {
|
||||||
|
position: absolute;
|
||||||
|
top: 44px;
|
||||||
|
left: 8px;
|
||||||
|
right: 8px;
|
||||||
|
z-index: 3;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 6px 8px 6px 12px;
|
||||||
|
border: 1px solid
|
||||||
|
color-mix(in srgb, var(--affine-primary-color) 18%, transparent);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--affine-background-primary-color) 92%,
|
||||||
|
var(--affine-primary-color)
|
||||||
|
);
|
||||||
|
box-shadow: var(--affine-menu-shadow);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-empty-month-hint-copy {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-empty-month-hint-title {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: var(--affine-text-primary-color);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-empty-month-hint-body {
|
||||||
|
min-width: 0;
|
||||||
|
color: var(--affine-text-secondary-color);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-empty-month-hint-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-empty-month-hint-action,
|
||||||
|
.calendar-empty-month-hint-close {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--affine-primary-color) 10%,
|
||||||
|
var(--affine-background-primary-color)
|
||||||
|
);
|
||||||
|
color: var(--affine-primary-color);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-empty-month-hint-close {
|
||||||
|
width: 24px;
|
||||||
|
padding: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--affine-icon-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-empty-month-hint-close svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-empty-month-hint-action:hover,
|
||||||
|
.calendar-empty-month-hint-close:hover {
|
||||||
|
background: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--affine-primary-color) 16%,
|
||||||
|
var(--affine-background-primary-color)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-setup-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-setup-wrap .calendar-shell {
|
||||||
|
filter: grayscale(1) blur(1px);
|
||||||
|
opacity: 0.55;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-setup {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-setup button {
|
||||||
|
height: 32px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-event-popover {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
width: 318px;
|
||||||
|
padding: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-event-popover-title {
|
||||||
|
padding: 2px 4px;
|
||||||
|
color: var(--affine-text-primary-color);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 22px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-event-popover-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
color: var(--affine-text-secondary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-event-popover-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex: 0 0 16px;
|
||||||
|
height: 20px;
|
||||||
|
color: var(--affine-icon-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-event-popover-icon svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-event-popover-description {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
`;
|
||||||
1099
blocksuite/affine/data-view/src/view-presets/calendar/pc/view.ts
Normal file
1099
blocksuite/affine/data-view/src/view-presets/calendar/pc/view.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
|||||||
|
import './pc/effect.js';
|
||||||
|
|
||||||
|
import { createIcon } from '../../core/utils/uni-icon.js';
|
||||||
|
import type { DataViewUILogicBaseConstructor } from '../../core/view/data-view-base.js';
|
||||||
|
import { calendarViewModel } from './define.js';
|
||||||
|
import { CalendarViewUILogic } from './pc/view.js';
|
||||||
|
|
||||||
|
export const calendarViewMeta = calendarViewModel.createMeta({
|
||||||
|
icon: createIcon('TodayIcon'),
|
||||||
|
pcLogic: () =>
|
||||||
|
CalendarViewUILogic as unknown as DataViewUILogicBaseConstructor,
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { createIdentifier } from '@blocksuite/global/di';
|
||||||
|
|
||||||
|
import type { DataSource } from '../../core/data-source/base.js';
|
||||||
|
import type {
|
||||||
|
CalendarExternalSource,
|
||||||
|
CalendarStoredViewData,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
export type CalendarExternalSourceFactory = {
|
||||||
|
id: string;
|
||||||
|
create(viewData: CalendarStoredViewData): CalendarExternalSource;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CalendarExternalSourceProvider =
|
||||||
|
createIdentifier<CalendarExternalSourceFactory>('calendar-external-source');
|
||||||
|
|
||||||
|
export const getCalendarExternalSources = (
|
||||||
|
dataSource: DataSource,
|
||||||
|
viewData: CalendarStoredViewData
|
||||||
|
) =>
|
||||||
|
Array.from(
|
||||||
|
dataSource.provider.getAll(CalendarExternalSourceProvider).values()
|
||||||
|
).map(source => source.create(viewData));
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import type { FilterGroup } from '../../core/filter/types.js';
|
||||||
|
import type { Sort } from '../../core/sort/types.js';
|
||||||
|
import type { BasicViewDataType } from '../../core/view/data-view.js';
|
||||||
|
|
||||||
|
export type CalendarWorkspaceSourceConfig = {
|
||||||
|
enabled: boolean;
|
||||||
|
subscriptionIds?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CalendarUiData = {
|
||||||
|
emptyMonthHintDismissed?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CalendarCardProperty = {
|
||||||
|
propertyId: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CalendarTitleSegment = {
|
||||||
|
text: string;
|
||||||
|
linkedDoc?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CalendarViewDataShape = {
|
||||||
|
filter: FilterGroup;
|
||||||
|
sort?: Sort;
|
||||||
|
date: {
|
||||||
|
startColumnId?: string;
|
||||||
|
endColumnId?: string;
|
||||||
|
};
|
||||||
|
card: {
|
||||||
|
titleColumnId?: string;
|
||||||
|
visiblePropertyIds: string[];
|
||||||
|
};
|
||||||
|
sources: {
|
||||||
|
workspaceCalendar?: CalendarWorkspaceSourceConfig;
|
||||||
|
};
|
||||||
|
ui?: CalendarUiData;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CalendarViewData = BasicViewDataType<
|
||||||
|
'calendar',
|
||||||
|
CalendarViewDataShape
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type CalendarStoredViewData = CalendarViewData;
|
||||||
|
|
||||||
|
export type CalendarEntryBase = {
|
||||||
|
id: string;
|
||||||
|
sourceId: string;
|
||||||
|
title: string;
|
||||||
|
color?: string;
|
||||||
|
startAt: number;
|
||||||
|
endAt?: number;
|
||||||
|
allDay?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CalendarRowEntry = CalendarEntryBase & {
|
||||||
|
kind: 'row';
|
||||||
|
sourceId: 'database';
|
||||||
|
rowId: string;
|
||||||
|
titleSegments?: CalendarTitleSegment[];
|
||||||
|
cardProperties: CalendarCardProperty[];
|
||||||
|
canResizeRange: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CalendarExternalEntry = CalendarEntryBase & {
|
||||||
|
kind: 'external';
|
||||||
|
sourceId: string;
|
||||||
|
externalId: string;
|
||||||
|
calendarName?: string;
|
||||||
|
location?: string;
|
||||||
|
description?: string;
|
||||||
|
canResizeRange: false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CalendarEntry = CalendarRowEntry | CalendarExternalEntry;
|
||||||
|
|
||||||
|
export type CalendarEntryRange = {
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CalendarExternalSource = {
|
||||||
|
id: string;
|
||||||
|
getSubscriptionOptions?(): CalendarExternalSourceSubscription[];
|
||||||
|
openConnectSettings?(): void;
|
||||||
|
getEntries(
|
||||||
|
range: CalendarEntryRange
|
||||||
|
): CalendarExternalEntry[] | Promise<CalendarExternalEntry[]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CalendarExternalSourceSubscription = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
color?: string;
|
||||||
|
};
|
||||||
@@ -1,13 +1,45 @@
|
|||||||
import { createViewConvert } from '../core/view/convert.js';
|
import { createViewConvert } from '../core/view/convert.js';
|
||||||
|
import { calendarViewModel } from './calendar/index.js';
|
||||||
import { kanbanViewModel } from './kanban/index.js';
|
import { kanbanViewModel } from './kanban/index.js';
|
||||||
import { tableViewModel } from './table/index.js';
|
import { tableViewModel } from './table/index.js';
|
||||||
|
|
||||||
|
const headerToCalendarCard = (header?: { titleColumn?: string }) => ({
|
||||||
|
titleColumnId: header?.titleColumn,
|
||||||
|
visiblePropertyIds: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const calendarCardToHeader = (card?: { titleColumnId?: string }) => ({
|
||||||
|
titleColumn: card?.titleColumnId,
|
||||||
|
});
|
||||||
|
|
||||||
export const viewConverts = [
|
export const viewConverts = [
|
||||||
createViewConvert(tableViewModel, kanbanViewModel, data => ({
|
createViewConvert(tableViewModel, kanbanViewModel, data => ({
|
||||||
filter: data.filter,
|
filter: data.filter,
|
||||||
|
header: data.header,
|
||||||
})),
|
})),
|
||||||
createViewConvert(kanbanViewModel, tableViewModel, data => ({
|
createViewConvert(kanbanViewModel, tableViewModel, data => ({
|
||||||
filter: data.filter,
|
filter: data.filter,
|
||||||
|
header: data.header,
|
||||||
groupBy: data.groupBy,
|
groupBy: data.groupBy,
|
||||||
})),
|
})),
|
||||||
|
createViewConvert(tableViewModel, calendarViewModel, data => ({
|
||||||
|
filter: data.filter,
|
||||||
|
sort: data.sort,
|
||||||
|
card: headerToCalendarCard(data.header),
|
||||||
|
})),
|
||||||
|
createViewConvert(kanbanViewModel, calendarViewModel, data => ({
|
||||||
|
filter: data.filter,
|
||||||
|
sort: data.sort,
|
||||||
|
card: headerToCalendarCard(data.header),
|
||||||
|
})),
|
||||||
|
createViewConvert(calendarViewModel, tableViewModel, data => ({
|
||||||
|
filter: data.filter,
|
||||||
|
sort: data.sort,
|
||||||
|
header: calendarCardToHeader(data.card),
|
||||||
|
})),
|
||||||
|
createViewConvert(calendarViewModel, kanbanViewModel, data => ({
|
||||||
|
filter: data.filter,
|
||||||
|
sort: data.sort,
|
||||||
|
header: calendarCardToHeader(data.card),
|
||||||
|
})),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import { calendarEffects } from './calendar/effect.js';
|
||||||
import { kanbanEffects } from './kanban/effect.js';
|
import { kanbanEffects } from './kanban/effect.js';
|
||||||
import { tableEffects } from './table/effect.js';
|
import { tableEffects } from './table/effect.js';
|
||||||
|
|
||||||
export function viewPresetsEffects() {
|
export function viewPresetsEffects() {
|
||||||
|
calendarEffects();
|
||||||
kanbanEffects();
|
kanbanEffects();
|
||||||
tableEffects();
|
tableEffects();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { calendarViewMeta } from './calendar/index.js';
|
||||||
import { kanbanViewMeta } from './kanban/index.js';
|
import { kanbanViewMeta } from './kanban/index.js';
|
||||||
import { tableViewMeta } from './table/index.js';
|
import { tableViewMeta } from './table/index.js';
|
||||||
|
|
||||||
|
export * from './calendar/index.js';
|
||||||
export * from './convert.js';
|
export * from './convert.js';
|
||||||
export * from './kanban/index.js';
|
export * from './kanban/index.js';
|
||||||
export * from './table/index.js';
|
export * from './table/index.js';
|
||||||
@@ -8,4 +10,5 @@ export * from './table/index.js';
|
|||||||
export const viewPresets = {
|
export const viewPresets = {
|
||||||
tableViewMeta: tableViewMeta,
|
tableViewMeta: tableViewMeta,
|
||||||
kanbanViewMeta: kanbanViewMeta,
|
kanbanViewMeta: kanbanViewMeta,
|
||||||
|
calendarViewMeta: calendarViewMeta,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
type Menu,
|
||||||
menu,
|
menu,
|
||||||
type MenuButtonData,
|
type MenuButtonData,
|
||||||
type MenuConfig,
|
type MenuConfig,
|
||||||
@@ -16,22 +17,22 @@ import {
|
|||||||
InfoIcon,
|
InfoIcon,
|
||||||
LayoutIcon,
|
LayoutIcon,
|
||||||
MoreHorizontalIcon,
|
MoreHorizontalIcon,
|
||||||
|
PlusIcon,
|
||||||
SortIcon,
|
SortIcon,
|
||||||
} from '@blocksuite/icons/lit';
|
} from '@blocksuite/icons/lit';
|
||||||
import { autoPlacement, offset, shift } from '@floating-ui/dom';
|
import { autoPlacement, offset, shift } from '@floating-ui/dom';
|
||||||
|
import { signal } from '@preact/signals-core';
|
||||||
import { css, html } from 'lit';
|
import { css, html } from 'lit';
|
||||||
import { styleMap } from 'lit/directives/style-map.js';
|
import { styleMap } from 'lit/directives/style-map.js';
|
||||||
|
|
||||||
import { popPropertiesSetting } from '../../../../core/common/properties.js';
|
|
||||||
import { filterTraitKey } from '../../../../core/filter/trait.js';
|
import { filterTraitKey } from '../../../../core/filter/trait.js';
|
||||||
import {
|
import {
|
||||||
popGroupSetting,
|
buildGroupSelectItems,
|
||||||
popSelectGroupByProperty,
|
buildGroupSettingItems,
|
||||||
} from '../../../../core/group-by/setting.js';
|
} from '../../../../core/group-by/setting.js';
|
||||||
import { groupTraitKey } from '../../../../core/group-by/trait.js';
|
import { groupTraitKey } from '../../../../core/group-by/trait.js';
|
||||||
import {
|
import {
|
||||||
type DataViewUILogicBase,
|
type DataViewUILogicBase,
|
||||||
emptyFilterGroup,
|
|
||||||
popCreateFilter,
|
popCreateFilter,
|
||||||
renderUniLit,
|
renderUniLit,
|
||||||
} from '../../../../core/index.js';
|
} from '../../../../core/index.js';
|
||||||
@@ -39,8 +40,6 @@ import { popCreateSort } from '../../../../core/sort/add-sort.js';
|
|||||||
import { sortTraitKey } from '../../../../core/sort/manager.js';
|
import { sortTraitKey } from '../../../../core/sort/manager.js';
|
||||||
import { createSortUtils } from '../../../../core/sort/utils.js';
|
import { createSortUtils } from '../../../../core/sort/utils.js';
|
||||||
import { WidgetBase } from '../../../../core/widget/widget-base.js';
|
import { WidgetBase } from '../../../../core/widget/widget-base.js';
|
||||||
import { popFilterRoot } from '../../../quick-setting-bar/filter/root-panel-view.js';
|
|
||||||
import { popSortRoot } from '../../../quick-setting-bar/sort/root-panel.js';
|
|
||||||
|
|
||||||
const styles = css`
|
const styles = css`
|
||||||
.affine-database-toolbar-item.more-action {
|
.affine-database-toolbar-item.more-action {
|
||||||
@@ -95,267 +94,244 @@ declare global {
|
|||||||
'data-view-header-tools-view-options': DataViewHeaderToolsViewOptions;
|
'data-view-header-tools-view-options': DataViewHeaderToolsViewOptions;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const createSettingMenus = (
|
type Page =
|
||||||
target: PopupTarget,
|
| 'main'
|
||||||
dataViewLogic: DataViewUILogicBase,
|
| 'properties'
|
||||||
reopen: () => void,
|
| 'filter'
|
||||||
closeMenu: () => void
|
| 'sort'
|
||||||
) => {
|
| 'group'
|
||||||
const view = dataViewLogic.view;
|
| 'group-select'
|
||||||
const settingItems: MenuConfig[] = [];
|
| 'custom';
|
||||||
settingItems.push(
|
|
||||||
menu.action({
|
const pageTitles: Record<Exclude<Page, 'custom'>, string> = {
|
||||||
name: 'Properties',
|
main: 'View settings',
|
||||||
prefix: InfoIcon(),
|
properties: 'Properties',
|
||||||
closeOnSelect: false,
|
filter: 'Filter',
|
||||||
postfix: html` <div style="font-size: 14px;">
|
sort: 'Sort',
|
||||||
${view.properties$.value.length} shown
|
group: 'Group',
|
||||||
</div>
|
'group-select': 'Group by',
|
||||||
${ArrowRightSmallIcon()}`,
|
|
||||||
select: () => {
|
|
||||||
popPropertiesSetting(
|
|
||||||
target,
|
|
||||||
{
|
|
||||||
view: view,
|
|
||||||
onBack: reopen,
|
|
||||||
onClose: closeMenu,
|
|
||||||
},
|
|
||||||
[
|
|
||||||
autoPlacement({ allowedPlacements: ['bottom-start', 'top-start'] }),
|
|
||||||
offset({ mainAxis: 15, crossAxis: -162 }),
|
|
||||||
shift({ crossAxis: true }),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
const filterTrait = view.traitGet(filterTraitKey);
|
|
||||||
if (filterTrait) {
|
|
||||||
const filterCount = filterTrait.filter$.value.conditions.length;
|
|
||||||
settingItems.push(
|
|
||||||
menu.action({
|
|
||||||
name: 'Filter',
|
|
||||||
prefix: FilterIcon(),
|
|
||||||
closeOnSelect: false,
|
|
||||||
postfix: html` <div style="font-size: 14px;">
|
|
||||||
${filterCount === 0
|
|
||||||
? ''
|
|
||||||
: filterCount === 1
|
|
||||||
? '1 filter'
|
|
||||||
: `${filterCount} filters`}
|
|
||||||
</div>
|
|
||||||
${ArrowRightSmallIcon()}`,
|
|
||||||
select: () => {
|
|
||||||
if (!filterTrait.filter$.value.conditions.length) {
|
|
||||||
popCreateFilter(
|
|
||||||
target,
|
|
||||||
{
|
|
||||||
vars: view.vars$,
|
|
||||||
onBack: reopen,
|
|
||||||
onClose: closeMenu,
|
|
||||||
onSelect: filter => {
|
|
||||||
filterTrait.filterSet({
|
|
||||||
...(filterTrait.filter$.value ?? emptyFilterGroup),
|
|
||||||
conditions: [
|
|
||||||
...filterTrait.filter$.value.conditions,
|
|
||||||
filter,
|
|
||||||
],
|
|
||||||
});
|
|
||||||
popFilterRoot(
|
|
||||||
target,
|
|
||||||
{
|
|
||||||
filterTrait: filterTrait,
|
|
||||||
onBack: reopen,
|
|
||||||
onClose: closeMenu,
|
|
||||||
dataViewLogic: dataViewLogic,
|
|
||||||
},
|
|
||||||
[
|
|
||||||
autoPlacement({
|
|
||||||
allowedPlacements: ['bottom-start', 'top-start'],
|
|
||||||
}),
|
|
||||||
offset({ mainAxis: 15, crossAxis: -162 }),
|
|
||||||
shift({ crossAxis: true }),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
dataViewLogic.eventTrace('CreateDatabaseFilter', {});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
middleware: [
|
|
||||||
autoPlacement({
|
|
||||||
allowedPlacements: ['bottom-start', 'top-start'],
|
|
||||||
}),
|
|
||||||
offset({ mainAxis: 15, crossAxis: -162 }),
|
|
||||||
shift({ crossAxis: true }),
|
|
||||||
],
|
|
||||||
}
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
popFilterRoot(
|
|
||||||
target,
|
|
||||||
{
|
|
||||||
filterTrait: filterTrait,
|
|
||||||
onBack: reopen,
|
|
||||||
onClose: closeMenu,
|
|
||||||
dataViewLogic: dataViewLogic,
|
|
||||||
},
|
|
||||||
[
|
|
||||||
autoPlacement({
|
|
||||||
allowedPlacements: ['bottom-start', 'top-start'],
|
|
||||||
}),
|
|
||||||
offset({ mainAxis: 15, crossAxis: -162 }),
|
|
||||||
shift({ crossAxis: true }),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const sortTrait = view.traitGet(sortTraitKey);
|
|
||||||
if (sortTrait) {
|
|
||||||
const sortCount = sortTrait.sortList$.value.length;
|
|
||||||
settingItems.push(
|
|
||||||
menu.action({
|
|
||||||
name: 'Sort',
|
|
||||||
prefix: SortIcon(),
|
|
||||||
closeOnSelect: false,
|
|
||||||
postfix: html` <div style="font-size: 14px;">
|
|
||||||
${sortCount === 0
|
|
||||||
? ''
|
|
||||||
: sortCount === 1
|
|
||||||
? '1 sort'
|
|
||||||
: `${sortCount} sorts`}
|
|
||||||
</div>
|
|
||||||
${ArrowRightSmallIcon()}`,
|
|
||||||
select: () => {
|
|
||||||
const sortList = sortTrait.sortList$.value;
|
|
||||||
const sortUtils = createSortUtils(
|
|
||||||
sortTrait,
|
|
||||||
dataViewLogic.eventTrace
|
|
||||||
);
|
|
||||||
if (!sortList.length) {
|
|
||||||
popCreateSort(
|
|
||||||
target,
|
|
||||||
{
|
|
||||||
sortUtils: sortUtils,
|
|
||||||
onBack: reopen,
|
|
||||||
onClose: closeMenu,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
middleware: [
|
|
||||||
autoPlacement({
|
|
||||||
allowedPlacements: ['bottom-start', 'top-start'],
|
|
||||||
}),
|
|
||||||
offset({ mainAxis: 15, crossAxis: -162 }),
|
|
||||||
shift({ crossAxis: true }),
|
|
||||||
],
|
|
||||||
}
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
popSortRoot(
|
|
||||||
target,
|
|
||||||
{
|
|
||||||
sortUtils: sortUtils,
|
|
||||||
title: {
|
|
||||||
text: 'Sort',
|
|
||||||
onBack: reopen,
|
|
||||||
onClose: closeMenu,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
[
|
|
||||||
autoPlacement({
|
|
||||||
allowedPlacements: ['bottom-start', 'top-start'],
|
|
||||||
}),
|
|
||||||
offset({ mainAxis: 15, crossAxis: -162 }),
|
|
||||||
shift({ crossAxis: true }),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const groupTrait = view.traitGet(groupTraitKey);
|
|
||||||
if (groupTrait) {
|
|
||||||
settingItems.push(
|
|
||||||
menu.action({
|
|
||||||
name: 'Group',
|
|
||||||
prefix: GroupingIcon(),
|
|
||||||
closeOnSelect: false,
|
|
||||||
postfix: html` <div style="font-size: 14px;">
|
|
||||||
${groupTrait.property$.value?.name$.value ?? ''}
|
|
||||||
</div>
|
|
||||||
${ArrowRightSmallIcon()}`,
|
|
||||||
select: () => {
|
|
||||||
const groupBy = groupTrait.property$.value;
|
|
||||||
if (!groupBy) {
|
|
||||||
popSelectGroupByProperty(
|
|
||||||
target,
|
|
||||||
groupTrait,
|
|
||||||
{
|
|
||||||
onSelect: () =>
|
|
||||||
popGroupSetting(target, groupTrait, reopen, closeMenu, [
|
|
||||||
autoPlacement({
|
|
||||||
allowedPlacements: ['bottom-start', 'top-start'],
|
|
||||||
}),
|
|
||||||
offset({ mainAxis: 15, crossAxis: -162 }),
|
|
||||||
shift({ crossAxis: true }),
|
|
||||||
]),
|
|
||||||
onBack: reopen,
|
|
||||||
onClose: closeMenu,
|
|
||||||
},
|
|
||||||
[
|
|
||||||
autoPlacement({
|
|
||||||
allowedPlacements: ['bottom-start', 'top-start'],
|
|
||||||
}),
|
|
||||||
offset({ mainAxis: 15, crossAxis: -162 }),
|
|
||||||
shift({ crossAxis: true }),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
popGroupSetting(target, groupTrait, reopen, closeMenu, [
|
|
||||||
autoPlacement({
|
|
||||||
allowedPlacements: ['bottom-start', 'top-start'],
|
|
||||||
}),
|
|
||||||
offset({ mainAxis: 15, crossAxis: -162 }),
|
|
||||||
shift({ crossAxis: true }),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return settingItems;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const popViewOptions = (
|
export const popViewOptions = (
|
||||||
target: PopupTarget,
|
target: PopupTarget,
|
||||||
dataViewLogic: DataViewUILogicBase,
|
dataViewLogic: DataViewUILogicBase,
|
||||||
onClose?: () => void
|
onClose?: () => void
|
||||||
) => {
|
) => {
|
||||||
const view = dataViewLogic.view;
|
const view = dataViewLogic.view;
|
||||||
const reopen = () => {
|
|
||||||
popViewOptions(target, dataViewLogic);
|
const currentPage = signal<Page>('main');
|
||||||
|
const pageStack: Page[] = ['main'];
|
||||||
|
|
||||||
|
let menuHandler!: ReturnType<typeof popMenu>;
|
||||||
|
let mainPageHeight: number | null = null;
|
||||||
|
let customPageTitle = '';
|
||||||
|
let customPageItems: () => MenuConfig[] = () => [];
|
||||||
|
|
||||||
|
const isDesktopMenu = () =>
|
||||||
|
menuHandler.menu.menuElement.tagName.toLowerCase() === 'affine-menu';
|
||||||
|
|
||||||
|
const navigate = (page: Page) => {
|
||||||
|
if (!isDesktopMenu()) {
|
||||||
|
pageStack.push(page);
|
||||||
|
currentPage.value = page;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mainPageHeight === null) {
|
||||||
|
mainPageHeight =
|
||||||
|
menuHandler.menu.menuElement.getBoundingClientRect().height;
|
||||||
|
}
|
||||||
|
menuHandler.menu.menuElement.style.height = `${mainPageHeight}px`;
|
||||||
|
pageStack.push(page);
|
||||||
|
currentPage.value = page;
|
||||||
};
|
};
|
||||||
let handler: ReturnType<typeof popMenu>;
|
|
||||||
|
const goBack = () => {
|
||||||
|
if (pageStack.length > 1) {
|
||||||
|
pageStack.pop();
|
||||||
|
const dest = pageStack[pageStack.length - 1] ?? 'main';
|
||||||
|
currentPage.value = dest;
|
||||||
|
if (dest === 'main') {
|
||||||
|
menuHandler.menu.menuElement.style.height = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigateToCustomPage = (
|
||||||
|
title: string,
|
||||||
|
getItems: () => MenuConfig[]
|
||||||
|
) => {
|
||||||
|
customPageTitle = title;
|
||||||
|
customPageItems = getItems;
|
||||||
|
navigate('custom');
|
||||||
|
};
|
||||||
|
|
||||||
|
const titleConfig = {
|
||||||
|
get text() {
|
||||||
|
if (currentPage.value === 'custom') return customPageTitle;
|
||||||
|
return (
|
||||||
|
pageTitles[currentPage.value as Exclude<Page, 'custom'>] ??
|
||||||
|
'View settings'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
get onBack(): ((menu: Menu) => false) | undefined {
|
||||||
|
return currentPage.value !== 'main'
|
||||||
|
? (_: Menu) => {
|
||||||
|
goBack();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
},
|
||||||
|
get postfix() {
|
||||||
|
if (currentPage.value !== 'properties') return undefined;
|
||||||
|
const items = view.propertiesRaw$.value;
|
||||||
|
const isAllShowed = items.every(p => !p.hide$.value);
|
||||||
|
const clickChangeAll = () => {
|
||||||
|
items.forEach(p => {
|
||||||
|
if (p.hideCanSet) p.hideSet(isAllShowed);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return () =>
|
||||||
|
html`<div
|
||||||
|
class="properties-group-op"
|
||||||
|
style="padding:4px 8px;font-size:12px;line-height:20px;font-weight:500;border-radius:4px;cursor:pointer;color:var(--affine-primary-color);"
|
||||||
|
@click="${clickChangeAll}"
|
||||||
|
>
|
||||||
|
${isAllShowed ? 'Hide All' : 'Show All'}
|
||||||
|
</div>`;
|
||||||
|
},
|
||||||
|
get onClose() {
|
||||||
|
return () => menuHandler?.menu.close();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPropertiesPageItems = (): MenuConfig[] => [
|
||||||
|
menu.group({
|
||||||
|
items: [
|
||||||
|
() =>
|
||||||
|
html`<data-view-properties-setting
|
||||||
|
.view="${view}"
|
||||||
|
></data-view-properties-setting>`,
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const getFilterPageItems = (): MenuConfig[] => {
|
||||||
|
const filterTrait = view.traitGet(filterTraitKey);
|
||||||
|
if (!filterTrait) return getMainPageItems();
|
||||||
|
return [
|
||||||
|
menu.group({
|
||||||
|
items: [
|
||||||
|
() =>
|
||||||
|
html`<filter-root-view
|
||||||
|
.onBack="${goBack}"
|
||||||
|
.vars="${view.vars$}"
|
||||||
|
.filterGroup="${filterTrait.filter$}"
|
||||||
|
.onChange="${filterTrait.filterSet}"
|
||||||
|
></filter-root-view>`,
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
menu.group({
|
||||||
|
items: [
|
||||||
|
menu.action({
|
||||||
|
name: 'Add',
|
||||||
|
prefix: PlusIcon(),
|
||||||
|
select: ele => {
|
||||||
|
const value = filterTrait.filter$.value;
|
||||||
|
popCreateFilter(popupTargetFromElement(ele), {
|
||||||
|
vars: view.vars$,
|
||||||
|
onSelect: filter => {
|
||||||
|
filterTrait.filterSet({
|
||||||
|
...value,
|
||||||
|
conditions: [...value.conditions, filter],
|
||||||
|
});
|
||||||
|
dataViewLogic.eventTrace('CreateDatabaseFilter', {});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSortPageItems = (): MenuConfig[] => {
|
||||||
|
const sortTrait = view.traitGet(sortTraitKey);
|
||||||
|
if (!sortTrait) return getMainPageItems();
|
||||||
|
const sortUtils = createSortUtils(sortTrait, dataViewLogic.eventTrace);
|
||||||
|
return [
|
||||||
|
() => html`<sort-root-view .sortUtils="${sortUtils}"></sort-root-view>`,
|
||||||
|
menu.action({
|
||||||
|
name: 'Add sort',
|
||||||
|
prefix: PlusIcon(),
|
||||||
|
select: ele => {
|
||||||
|
popCreateSort(popupTargetFromElement(ele), { sortUtils });
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
menu.action({
|
||||||
|
name: 'Delete',
|
||||||
|
class: { 'delete-item': true },
|
||||||
|
prefix: DeleteIcon(),
|
||||||
|
select: () => {
|
||||||
|
sortUtils.removeAll();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getGroupPageItems = (): MenuConfig[] => {
|
||||||
|
const groupTrait = view.traitGet(groupTraitKey);
|
||||||
|
if (!groupTrait) return getMainPageItems();
|
||||||
|
const gProp = groupTrait.property$.value;
|
||||||
|
if (!gProp) return [];
|
||||||
|
return buildGroupSettingItems(
|
||||||
|
groupTrait,
|
||||||
|
() => navigate('group-select'),
|
||||||
|
() => navigate('main')
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getGroupSelectPageItems = (): MenuConfig[] => {
|
||||||
|
const groupTrait = view.traitGet(groupTraitKey);
|
||||||
|
if (!groupTrait) return getMainPageItems();
|
||||||
|
return buildGroupSelectItems(groupTrait, id => {
|
||||||
|
if (id) {
|
||||||
|
if (pageStack.at(-1) === 'group-select') {
|
||||||
|
pageStack[pageStack.length - 1] = 'group';
|
||||||
|
} else {
|
||||||
|
pageStack.push('group');
|
||||||
|
}
|
||||||
|
currentPage.value = 'group';
|
||||||
|
} else {
|
||||||
|
while (pageStack.length > 1) pageStack.pop();
|
||||||
|
currentPage.value = 'main';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMainPageItems = (): MenuConfig[] => {
|
||||||
const items: MenuConfig[] = [];
|
const items: MenuConfig[] = [];
|
||||||
|
|
||||||
items.push(
|
items.push(
|
||||||
menu.input({
|
menu.input({
|
||||||
initialValue: view.name$.value,
|
initialValue: view.name$.value,
|
||||||
placeholder: 'View name',
|
placeholder: 'View name',
|
||||||
|
disableAutoFocus: true,
|
||||||
onChange: text => {
|
onChange: text => {
|
||||||
view.nameSet(text);
|
view.nameSet(text);
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
items.push(
|
items.push(
|
||||||
menu.group({
|
menu.group({
|
||||||
items: [
|
items: [
|
||||||
menu => {
|
menuObj => {
|
||||||
const viewTypeItems = menu.renderItems(
|
const viewTypeItems = menuObj.renderItems(
|
||||||
view.manager.viewMetas.map<MenuConfig>(meta => {
|
view.manager.viewMetas.map<MenuConfig>(meta => {
|
||||||
return menu => {
|
return menuObj => {
|
||||||
if (!menu.search(meta.model.defaultName)) {
|
if (!menuObj.search(meta.model.defaultName)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const isSelected =
|
const isSelected =
|
||||||
@@ -376,12 +352,14 @@ export const popViewOptions = (
|
|||||||
const buttonData: MenuButtonData = {
|
const buttonData: MenuButtonData = {
|
||||||
content: () => html`
|
content: () => html`
|
||||||
<div
|
<div
|
||||||
style="width:100%;display: flex;flex-direction: column;align-items: center;justify-content: center;padding: 6px 16px;white-space: nowrap"
|
style="width:100%;min-width:0;display: flex;flex-direction: column;align-items: center;justify-content: center;padding: 6px 4px;white-space: nowrap;box-sizing:border-box;"
|
||||||
>
|
>
|
||||||
<div style="${iconStyle}">
|
<div style="${iconStyle}">
|
||||||
${renderUniLit(meta.renderer.icon)}
|
${renderUniLit(meta.renderer.icon)}
|
||||||
</div>
|
</div>
|
||||||
<div style="${textStyle}">${meta.model.defaultName}</div>
|
<div style="${textStyle}">
|
||||||
|
${meta.model.defaultName}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
select: () => {
|
select: () => {
|
||||||
@@ -400,7 +378,7 @@ export const popViewOptions = (
|
|||||||
return html`<affine-menu-button
|
return html`<affine-menu-button
|
||||||
style="${containerStyle}"
|
style="${containerStyle}"
|
||||||
.data="${buttonData}"
|
.data="${buttonData}"
|
||||||
.menu="${menu}"
|
.menu="${menuObj}"
|
||||||
></affine-menu-button>`;
|
></affine-menu-button>`;
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
@@ -409,7 +387,9 @@ export const popViewOptions = (
|
|||||||
return html``;
|
return html``;
|
||||||
}
|
}
|
||||||
return html`
|
return html`
|
||||||
<div style="display:flex;align-items:center;gap:8px;padding:0 2px;">
|
<div
|
||||||
|
style="display:flex;align-items:center;gap:8px;padding:0 2px;"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
style="display:flex;align-items:center;color:var(--affine-icon-color);"
|
style="display:flex;align-items:center;color:var(--affine-icon-color);"
|
||||||
>
|
>
|
||||||
@@ -421,7 +401,7 @@ export const popViewOptions = (
|
|||||||
Layout
|
Layout
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:8px;margin-top:8px;">
|
<div style="display:flex;gap:4px;margin-top:8px;">
|
||||||
${viewTypeItems}
|
${viewTypeItems}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -430,18 +410,124 @@ export const popViewOptions = (
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
items.push(
|
const settingItems: MenuConfig[] = [];
|
||||||
menu.group({
|
|
||||||
items: createSettingMenus(target, dataViewLogic, reopen, () =>
|
settingItems.push(
|
||||||
handler.close()
|
menu.action({
|
||||||
),
|
name: 'Properties',
|
||||||
|
prefix: InfoIcon(),
|
||||||
|
closeOnSelect: false,
|
||||||
|
postfix: html`
|
||||||
|
<div style="font-size: 14px;">
|
||||||
|
${view.properties$.value.length} shown
|
||||||
|
</div>
|
||||||
|
${ArrowRightSmallIcon()}
|
||||||
|
`,
|
||||||
|
select: () => {
|
||||||
|
navigate('properties');
|
||||||
|
return false;
|
||||||
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const filterTrait = view.traitGet(filterTraitKey);
|
||||||
|
if (filterTrait) {
|
||||||
|
const filterCount = filterTrait.filter$.value.conditions.length;
|
||||||
|
settingItems.push(
|
||||||
|
menu.action({
|
||||||
|
name: 'Filter',
|
||||||
|
prefix: FilterIcon(),
|
||||||
|
closeOnSelect: false,
|
||||||
|
postfix: html`
|
||||||
|
<div style="font-size: 14px;">
|
||||||
|
${filterCount === 0
|
||||||
|
? ''
|
||||||
|
: filterCount === 1
|
||||||
|
? '1 active'
|
||||||
|
: `${filterCount} active`}
|
||||||
|
</div>
|
||||||
|
${ArrowRightSmallIcon()}
|
||||||
|
`,
|
||||||
|
select: () => {
|
||||||
|
navigate('filter');
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortTrait = view.traitGet(sortTraitKey);
|
||||||
|
if (sortTrait) {
|
||||||
|
const sortCount = sortTrait.sortList$.value.length;
|
||||||
|
settingItems.push(
|
||||||
|
menu.action({
|
||||||
|
name: 'Sort',
|
||||||
|
prefix: SortIcon(),
|
||||||
|
closeOnSelect: false,
|
||||||
|
postfix: html`
|
||||||
|
<div style="font-size: 14px;">
|
||||||
|
${sortCount === 0
|
||||||
|
? ''
|
||||||
|
: sortCount === 1
|
||||||
|
? '1 active'
|
||||||
|
: `${sortCount} active`}
|
||||||
|
</div>
|
||||||
|
${ArrowRightSmallIcon()}
|
||||||
|
`,
|
||||||
|
select: () => {
|
||||||
|
navigate('sort');
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupTrait = view.traitGet(groupTraitKey);
|
||||||
|
if (groupTrait) {
|
||||||
|
settingItems.push(
|
||||||
|
menu.action({
|
||||||
|
name: 'Group',
|
||||||
|
prefix: GroupingIcon(),
|
||||||
|
closeOnSelect: false,
|
||||||
|
postfix: html`
|
||||||
|
<div style="font-size: 14px;">
|
||||||
|
${groupTrait.property$.value?.name$.value ?? ''}
|
||||||
|
</div>
|
||||||
|
${ArrowRightSmallIcon()}
|
||||||
|
`,
|
||||||
|
select: () => {
|
||||||
|
const hasGroup = !!groupTrait.property$.value;
|
||||||
|
navigate(hasGroup ? 'group' : 'group-select');
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push(menu.group({ items: settingItems }));
|
||||||
|
|
||||||
|
const viewSpecificItems =
|
||||||
|
(
|
||||||
|
dataViewLogic as DataViewUILogicBase & {
|
||||||
|
getViewOptionsSettingItems?: (
|
||||||
|
navigateToSubPage?: (
|
||||||
|
title: string,
|
||||||
|
getItems: () => MenuConfig[]
|
||||||
|
) => void,
|
||||||
|
goBack?: () => void
|
||||||
|
) => MenuConfig[];
|
||||||
|
}
|
||||||
|
).getViewOptionsSettingItems?.(navigateToCustomPage, goBack) ?? [];
|
||||||
|
|
||||||
|
if (viewSpecificItems.length) {
|
||||||
|
items.push(menu.group({ items: viewSpecificItems }));
|
||||||
|
}
|
||||||
|
|
||||||
items.push(
|
items.push(
|
||||||
menu.group({
|
menu.group({
|
||||||
items: [
|
items: [
|
||||||
menu.action({
|
menu.action({
|
||||||
name: 'Duplicate',
|
name: 'Duplicate view',
|
||||||
prefix: DuplicateIcon(),
|
prefix: DuplicateIcon(),
|
||||||
closeOnSelect: false,
|
closeOnSelect: false,
|
||||||
select: () => {
|
select: () => {
|
||||||
@@ -449,7 +535,7 @@ export const popViewOptions = (
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
menu.action({
|
menu.action({
|
||||||
name: 'Delete',
|
name: 'Delete view',
|
||||||
prefix: DeleteIcon(),
|
prefix: DeleteIcon(),
|
||||||
closeOnSelect: false,
|
closeOnSelect: false,
|
||||||
select: () => {
|
select: () => {
|
||||||
@@ -460,14 +546,34 @@ export const popViewOptions = (
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
handler = popMenu(target, {
|
|
||||||
|
return items;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPageItems = (): MenuConfig[] => {
|
||||||
|
switch (currentPage.value) {
|
||||||
|
case 'properties':
|
||||||
|
return getPropertiesPageItems();
|
||||||
|
case 'filter':
|
||||||
|
return getFilterPageItems();
|
||||||
|
case 'sort':
|
||||||
|
return getSortPageItems();
|
||||||
|
case 'group':
|
||||||
|
return getGroupPageItems();
|
||||||
|
case 'group-select':
|
||||||
|
return getGroupSelectPageItems();
|
||||||
|
case 'custom':
|
||||||
|
return customPageItems();
|
||||||
|
default:
|
||||||
|
return getMainPageItems();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
menuHandler = popMenu(target, {
|
||||||
options: {
|
options: {
|
||||||
title: {
|
title: titleConfig,
|
||||||
text: 'View settings',
|
items: [menu.dynamic(getPageItems)],
|
||||||
onClose: () => handler.close(),
|
onClose,
|
||||||
},
|
|
||||||
items,
|
|
||||||
onClose: onClose,
|
|
||||||
},
|
},
|
||||||
middleware: [
|
middleware: [
|
||||||
autoPlacement({ allowedPlacements: ['bottom-start'] }),
|
autoPlacement({ allowedPlacements: ['bottom-start'] }),
|
||||||
@@ -475,6 +581,23 @@ export const popViewOptions = (
|
|||||||
shift({ crossAxis: true }),
|
shift({ crossAxis: true }),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
handler.menu.menuElement.style.minHeight = '550px';
|
if (isDesktopMenu()) {
|
||||||
return handler;
|
menuHandler.menu.menuElement.style.minWidth = '380px';
|
||||||
|
menuHandler.menu.menuElement.style.maxWidth = '380px';
|
||||||
|
menuHandler.menu.menuElement.style.borderRadius = '10px';
|
||||||
|
menuHandler.menu.menuElement.style.padding = '12px';
|
||||||
|
menuHandler.menu.menuElement.style.gap = '10px';
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const bodyEl =
|
||||||
|
menuHandler.menu.menuElement.querySelector<HTMLElement>(
|
||||||
|
'.affine-menu-body'
|
||||||
|
);
|
||||||
|
if (bodyEl) {
|
||||||
|
bodyEl.style.overflowY = 'auto';
|
||||||
|
bodyEl.style.flex = '1';
|
||||||
|
bodyEl.style.minHeight = '0';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return menuHandler;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
"@blocksuite/affine-block-root": "workspace:*",
|
"@blocksuite/affine-block-root": "workspace:*",
|
||||||
"@blocksuite/affine-components": "workspace:*",
|
"@blocksuite/affine-components": "workspace:*",
|
||||||
"@blocksuite/affine-shared": "workspace:*",
|
"@blocksuite/affine-shared": "workspace:*",
|
||||||
|
"@blocksuite/data-view": "workspace:*",
|
||||||
"@blocksuite/global": "workspace:*",
|
"@blocksuite/global": "workspace:*",
|
||||||
"@blocksuite/icons": "^2.2.17",
|
"@blocksuite/icons": "^2.2.17",
|
||||||
"@blocksuite/std": "workspace:*",
|
"@blocksuite/std": "workspace:*",
|
||||||
|
|||||||
@@ -0,0 +1,319 @@
|
|||||||
|
/** @vitest-environment happy-dom */
|
||||||
|
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
calendarEventToExternalEntry,
|
||||||
|
selectWorkspaceCalendarSubscriptionIds,
|
||||||
|
WorkspaceCalendarExternalSource,
|
||||||
|
} from './workspace-calendar-source';
|
||||||
|
|
||||||
|
const accountCalendarsState = (value: unknown) => ({
|
||||||
|
['accountCalendars$']: { value },
|
||||||
|
});
|
||||||
|
|
||||||
|
const workspaceCalendarsState = (value: unknown) => ({
|
||||||
|
['workspaceCalendars$']: { value },
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('workspace calendar source', () => {
|
||||||
|
it('intersects workspace enabled items with view subscription ids', () => {
|
||||||
|
const workspaceItems = [
|
||||||
|
{ subscriptionId: 'a', enabled: true },
|
||||||
|
{ subscriptionId: 'b', enabled: false },
|
||||||
|
{ subscriptionId: 'c', enabled: true },
|
||||||
|
];
|
||||||
|
const cases = [
|
||||||
|
{
|
||||||
|
viewConfig: {
|
||||||
|
enabled: true,
|
||||||
|
subscriptionIds: ['b', 'c'],
|
||||||
|
},
|
||||||
|
expected: ['c'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
viewConfig: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
expected: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { viewConfig, expected } of cases) {
|
||||||
|
const ids = selectWorkspaceCalendarSubscriptionIds(
|
||||||
|
workspaceItems,
|
||||||
|
viewConfig
|
||||||
|
);
|
||||||
|
|
||||||
|
expect([...ids]).toEqual(expected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps event range to an external entry', () => {
|
||||||
|
const entry = calendarEventToExternalEntry(
|
||||||
|
{
|
||||||
|
id: 'event-1',
|
||||||
|
subscriptionId: 'sub-1',
|
||||||
|
externalEventId: 'external-1',
|
||||||
|
title: 'Planning',
|
||||||
|
description: 'Discuss roadmap',
|
||||||
|
location: 'Room A',
|
||||||
|
startAtUtc: '2026-05-15T01:00:00.000Z',
|
||||||
|
endAtUtc: '2026-05-16T01:00:00.000Z',
|
||||||
|
allDay: false,
|
||||||
|
} as any,
|
||||||
|
{
|
||||||
|
color: '#2f7d32',
|
||||||
|
calendarName: 'Work',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(entry).toMatchObject({
|
||||||
|
kind: 'external',
|
||||||
|
id: 'workspace-calendar:event-1',
|
||||||
|
externalId: 'external-1',
|
||||||
|
title: 'Planning',
|
||||||
|
color: '#2f7d32',
|
||||||
|
calendarName: 'Work',
|
||||||
|
description: 'Discuss roadmap',
|
||||||
|
location: 'Room A',
|
||||||
|
});
|
||||||
|
expect(entry.endAt).toBeGreaterThan(entry.startAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to stable visible colors for muted calendar colors', () => {
|
||||||
|
const event = {
|
||||||
|
id: 'event-1',
|
||||||
|
subscriptionId: 'sub-1',
|
||||||
|
title: 'Planning',
|
||||||
|
startAtUtc: '2026-05-15T01:00:00.000Z',
|
||||||
|
endAtUtc: '2026-05-16T01:00:00.000Z',
|
||||||
|
allDay: false,
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
expect(calendarEventToExternalEntry(event, { color: '#00f' }).color).toBe(
|
||||||
|
'#6f6b2f'
|
||||||
|
);
|
||||||
|
expect(calendarEventToExternalEntry(event, { color: '#eee' }).color).toBe(
|
||||||
|
'#6f6b2f'
|
||||||
|
);
|
||||||
|
expect(calendarEventToExternalEntry(event).color).toBe('#6f6b2f');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses workspace color override before account calendar color', async () => {
|
||||||
|
const source = new WorkspaceCalendarExternalSource(
|
||||||
|
{
|
||||||
|
...accountCalendarsState(
|
||||||
|
new Map([['account-1', [{ id: 'sub-1', color: '#111' }]]])
|
||||||
|
),
|
||||||
|
...workspaceCalendarsState([
|
||||||
|
{
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
subscriptionId: 'sub-1',
|
||||||
|
enabled: true,
|
||||||
|
colorOverride: '#ad3b69',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
revalidateEventsRange: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'event-1',
|
||||||
|
subscriptionId: 'sub-1',
|
||||||
|
title: 'Planning',
|
||||||
|
startAtUtc: '2026-05-15T01:00:00.000Z',
|
||||||
|
endAtUtc: '2026-05-15T02:00:00.000Z',
|
||||||
|
allDay: false,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
} as any,
|
||||||
|
() => true,
|
||||||
|
{
|
||||||
|
sources: {
|
||||||
|
workspaceCalendar: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as any
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
source.getEntries({ from: Date.now(), to: Date.now() })
|
||||||
|
).resolves.toMatchObject([{ color: '#ad3b69' }]);
|
||||||
|
expect(source.getSubscriptionOptions()).toEqual([
|
||||||
|
{
|
||||||
|
id: 'sub-1',
|
||||||
|
name: 'sub-1',
|
||||||
|
color: '#ad3b69',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty entries without server', async () => {
|
||||||
|
const revalidateEventsRange = vi.fn();
|
||||||
|
const source = new WorkspaceCalendarExternalSource(
|
||||||
|
{
|
||||||
|
...accountCalendarsState(new Map()),
|
||||||
|
...workspaceCalendarsState([
|
||||||
|
{
|
||||||
|
items: [{ subscriptionId: 'sub-1', enabled: true }],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
revalidateEventsRange,
|
||||||
|
} as any,
|
||||||
|
() => false,
|
||||||
|
{
|
||||||
|
sources: {
|
||||||
|
workspaceCalendar: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as any
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
source.getEntries({ from: Date.now(), to: Date.now() })
|
||||||
|
).resolves.toEqual([]);
|
||||||
|
expect(revalidateEventsRange).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens workspace integration settings from connect entry', () => {
|
||||||
|
const openSettings = vi.fn();
|
||||||
|
const source = new WorkspaceCalendarExternalSource(
|
||||||
|
undefined,
|
||||||
|
() => false,
|
||||||
|
{
|
||||||
|
sources: {
|
||||||
|
workspaceCalendar: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
openSettings
|
||||||
|
);
|
||||||
|
|
||||||
|
source.openConnectSettings();
|
||||||
|
|
||||||
|
expect(openSettings).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads empty calendar caches before fetching entries', async () => {
|
||||||
|
const loadAccountCalendars = vi.fn().mockResolvedValue(
|
||||||
|
new Map([
|
||||||
|
[
|
||||||
|
'account-1',
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 'sub-1',
|
||||||
|
displayName: 'Work',
|
||||||
|
color: '#111',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
])
|
||||||
|
);
|
||||||
|
const revalidateWorkspaceCalendars = vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
items: [{ subscriptionId: 'sub-1', enabled: true }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const source = new WorkspaceCalendarExternalSource(
|
||||||
|
{
|
||||||
|
...accountCalendarsState(new Map()),
|
||||||
|
...workspaceCalendarsState([]),
|
||||||
|
loadAccountCalendars,
|
||||||
|
revalidateWorkspaceCalendars,
|
||||||
|
revalidateEventsRange: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'event-1',
|
||||||
|
subscriptionId: 'sub-1',
|
||||||
|
title: 'Planning',
|
||||||
|
startAtUtc: '2026-05-15T01:00:00.000Z',
|
||||||
|
endAtUtc: '2026-05-15T02:00:00.000Z',
|
||||||
|
allDay: false,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
} as any,
|
||||||
|
() => true,
|
||||||
|
{
|
||||||
|
sources: {
|
||||||
|
workspaceCalendar: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as any
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
source.getEntries({ from: Date.now(), to: Date.now() })
|
||||||
|
).resolves.toMatchObject([{ title: 'Planning' }]);
|
||||||
|
expect(loadAccountCalendars).toHaveBeenCalled();
|
||||||
|
expect(revalidateWorkspaceCalendars).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty entries when calendar requests fail', async () => {
|
||||||
|
const source = new WorkspaceCalendarExternalSource(
|
||||||
|
{
|
||||||
|
...accountCalendarsState(new Map()),
|
||||||
|
...workspaceCalendarsState([
|
||||||
|
{
|
||||||
|
items: [{ subscriptionId: 'sub-1', enabled: true }],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
loadAccountCalendars: vi.fn().mockResolvedValue(new Map()),
|
||||||
|
revalidateEventsRange: vi.fn().mockRejectedValue(new Error('denied')),
|
||||||
|
} as any,
|
||||||
|
() => true,
|
||||||
|
{
|
||||||
|
sources: {
|
||||||
|
workspaceCalendar: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as any
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
source.getEntries({ from: Date.now(), to: Date.now() })
|
||||||
|
).resolves.toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty entries when account calendar loading fails', async () => {
|
||||||
|
const revalidateEventsRange = vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'event-1',
|
||||||
|
subscriptionId: 'sub-1',
|
||||||
|
title: 'Planning',
|
||||||
|
startAtUtc: '2026-05-15T01:00:00.000Z',
|
||||||
|
endAtUtc: '2026-05-15T02:00:00.000Z',
|
||||||
|
allDay: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const source = new WorkspaceCalendarExternalSource(
|
||||||
|
{
|
||||||
|
...accountCalendarsState(new Map()),
|
||||||
|
...workspaceCalendarsState([
|
||||||
|
{
|
||||||
|
items: [{ subscriptionId: 'sub-1', enabled: true }],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
loadAccountCalendars: vi.fn().mockRejectedValue(new Error('denied')),
|
||||||
|
revalidateEventsRange,
|
||||||
|
} as any,
|
||||||
|
() => true,
|
||||||
|
{
|
||||||
|
sources: {
|
||||||
|
workspaceCalendar: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as any
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
source.getEntries({ from: Date.now(), to: Date.now() })
|
||||||
|
).resolves.toEqual([]);
|
||||||
|
expect(revalidateEventsRange).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
import { CALENDAR_INTEGRATION_SCROLL_ANCHOR } from '@affine/core/desktop/dialogs/setting/navigation-constants';
|
||||||
|
import { WorkspaceServerService } from '@affine/core/modules/cloud';
|
||||||
|
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
|
||||||
|
import { IntegrationService } from '@affine/core/modules/integration';
|
||||||
|
import type {
|
||||||
|
CalendarEntryRange,
|
||||||
|
CalendarExternalEntry,
|
||||||
|
CalendarViewData,
|
||||||
|
} from '@blocksuite/data-view/view-presets';
|
||||||
|
import type { FrameworkProvider } from '@toeverything/infra';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
type CalendarIntegrationLike = IntegrationService['calendar'];
|
||||||
|
|
||||||
|
type CalendarEventPayload = Awaited<
|
||||||
|
ReturnType<CalendarIntegrationLike['revalidateEventsRange']>
|
||||||
|
>[number];
|
||||||
|
|
||||||
|
const calendarColorPalette = [
|
||||||
|
'#2f7d32',
|
||||||
|
'#b45309',
|
||||||
|
'#ad3b69',
|
||||||
|
'#8f6a00',
|
||||||
|
'#6f6b2f',
|
||||||
|
'#9f4f1a',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const hashString = (value: string) => {
|
||||||
|
let hash = 0;
|
||||||
|
for (let index = 0; index < value.length; index++) {
|
||||||
|
hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
|
||||||
|
}
|
||||||
|
return hash;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseHexColor = (color: string) => {
|
||||||
|
const hex = color.trim();
|
||||||
|
const match = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex);
|
||||||
|
if (!match) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const value =
|
||||||
|
match[1].length === 3
|
||||||
|
? match[1]
|
||||||
|
.split('')
|
||||||
|
.map(char => char + char)
|
||||||
|
.join('')
|
||||||
|
: match[1];
|
||||||
|
return {
|
||||||
|
r: Number.parseInt(value.slice(0, 2), 16),
|
||||||
|
g: Number.parseInt(value.slice(2, 4), 16),
|
||||||
|
b: Number.parseInt(value.slice(4, 6), 16),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getColorHue = ({ r, g, b }: { r: number; g: number; b: number }) => {
|
||||||
|
const red = r / 255;
|
||||||
|
const green = g / 255;
|
||||||
|
const blue = b / 255;
|
||||||
|
const max = Math.max(red, green, blue);
|
||||||
|
const min = Math.min(red, green, blue);
|
||||||
|
const delta = max - min;
|
||||||
|
if (delta === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (max === red) {
|
||||||
|
return ((green - blue) / delta + (green < blue ? 6 : 0)) * 60;
|
||||||
|
}
|
||||||
|
if (max === green) {
|
||||||
|
return ((blue - red) / delta + 2) * 60;
|
||||||
|
}
|
||||||
|
return ((red - green) / delta + 4) * 60;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isMutedCalendarColor = (color: string) => {
|
||||||
|
const rgb = parseHexColor(color);
|
||||||
|
if (!rgb) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const { r, g, b } = rgb;
|
||||||
|
const max = Math.max(r, g, b);
|
||||||
|
const min = Math.min(r, g, b);
|
||||||
|
const lightness = (max + min) / 510;
|
||||||
|
const saturation =
|
||||||
|
max === min ? 0 : (max - min) / (255 - Math.abs(max + min - 255));
|
||||||
|
const hue = getColorHue(rgb);
|
||||||
|
return (
|
||||||
|
lightness > 0.9 ||
|
||||||
|
saturation < 0.18 ||
|
||||||
|
(hue >= 190 && hue <= 260 && saturation > 0.18)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCalendarColor = (subscriptionId: string, color?: string | null) => {
|
||||||
|
if (color && !isMutedCalendarColor(color)) {
|
||||||
|
return color;
|
||||||
|
}
|
||||||
|
return calendarColorPalette[
|
||||||
|
hashString(subscriptionId) % calendarColorPalette.length
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const selectWorkspaceCalendarSubscriptionIds = (
|
||||||
|
workspaceItems: Array<{
|
||||||
|
subscriptionId: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}>,
|
||||||
|
viewConfig?: CalendarViewData['sources']['workspaceCalendar']
|
||||||
|
) => {
|
||||||
|
if (!viewConfig?.enabled) {
|
||||||
|
return new Set<string>();
|
||||||
|
}
|
||||||
|
const viewIds = viewConfig.subscriptionIds
|
||||||
|
? new Set(viewConfig.subscriptionIds)
|
||||||
|
: undefined;
|
||||||
|
return new Set(
|
||||||
|
workspaceItems
|
||||||
|
.filter(item => item.enabled)
|
||||||
|
.filter(item => !viewIds || viewIds.has(item.subscriptionId))
|
||||||
|
.map(item => item.subscriptionId)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const calendarEventToExternalEntry = (
|
||||||
|
event: CalendarEventPayload,
|
||||||
|
options?: {
|
||||||
|
color?: string | null;
|
||||||
|
calendarName?: string;
|
||||||
|
}
|
||||||
|
) =>
|
||||||
|
({
|
||||||
|
kind: 'external',
|
||||||
|
id: `workspace-calendar:${event.id}`,
|
||||||
|
sourceId: 'workspace-calendar',
|
||||||
|
externalId: event.externalEventId ?? event.id,
|
||||||
|
title: event.title ?? '',
|
||||||
|
color:
|
||||||
|
options?.color !== undefined
|
||||||
|
? getCalendarColor(event.subscriptionId, options.color)
|
||||||
|
: getCalendarColor(event.subscriptionId),
|
||||||
|
calendarName: options?.calendarName,
|
||||||
|
location: event.location ?? undefined,
|
||||||
|
description: event.description ?? undefined,
|
||||||
|
startAt: dayjs(event.startAtUtc).valueOf(),
|
||||||
|
endAt: dayjs(event.endAtUtc).valueOf(),
|
||||||
|
allDay: event.allDay,
|
||||||
|
canResizeRange: false,
|
||||||
|
}) as CalendarExternalEntry;
|
||||||
|
|
||||||
|
export class WorkspaceCalendarExternalSource {
|
||||||
|
id = 'workspace-calendar';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly calendar: CalendarIntegrationLike | undefined,
|
||||||
|
private readonly hasServer: () => boolean,
|
||||||
|
private readonly viewData: CalendarViewData,
|
||||||
|
private readonly openSettings?: () => void
|
||||||
|
) {}
|
||||||
|
|
||||||
|
openConnectSettings() {
|
||||||
|
this.openSettings?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEntries(range: CalendarEntryRange) {
|
||||||
|
const calendar = this.calendar;
|
||||||
|
if (!calendar || !this.hasServer()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaceCalendars =
|
||||||
|
calendar.workspaceCalendars$.value.length > 0
|
||||||
|
? calendar.workspaceCalendars$.value
|
||||||
|
: await calendar.revalidateWorkspaceCalendars().catch(() => []);
|
||||||
|
if (calendar.accountCalendars$.value.size === 0) {
|
||||||
|
const accountCalendars = await calendar
|
||||||
|
.loadAccountCalendars()
|
||||||
|
.catch(() => undefined);
|
||||||
|
if (!accountCalendars) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaceCalendar = workspaceCalendars[0];
|
||||||
|
const workspaceItems = workspaceCalendar?.items ?? [];
|
||||||
|
const subscriptionIds = selectWorkspaceCalendarSubscriptionIds(
|
||||||
|
workspaceItems,
|
||||||
|
this.viewData.sources?.workspaceCalendar
|
||||||
|
);
|
||||||
|
if (subscriptionIds.size === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = await calendar
|
||||||
|
.revalidateEventsRange(dayjs(range.from), dayjs(range.to))
|
||||||
|
.catch(() => []);
|
||||||
|
const infoBySubscriptionId = this.getSubscriptionInfo();
|
||||||
|
const colorBySubscriptionId = new Map<string, string | null | undefined>();
|
||||||
|
for (const calendars of calendar.accountCalendars$.value.values()) {
|
||||||
|
for (const subscription of calendars) {
|
||||||
|
colorBySubscriptionId.set(subscription.id, subscription.color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const item of workspaceItems) {
|
||||||
|
if (item.colorOverride) {
|
||||||
|
colorBySubscriptionId.set(item.subscriptionId, item.colorOverride);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return events
|
||||||
|
.filter(event => subscriptionIds.has(event.subscriptionId))
|
||||||
|
.map(event =>
|
||||||
|
calendarEventToExternalEntry(event, {
|
||||||
|
color: getCalendarColor(
|
||||||
|
event.subscriptionId,
|
||||||
|
colorBySubscriptionId.get(event.subscriptionId)
|
||||||
|
),
|
||||||
|
calendarName: infoBySubscriptionId.get(event.subscriptionId)?.name,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getSubscriptionOptions() {
|
||||||
|
const workspaceItems =
|
||||||
|
this.calendar?.workspaceCalendars$.value[0]?.items ?? [];
|
||||||
|
const enabledIds = new Set(
|
||||||
|
workspaceItems
|
||||||
|
.filter(item => item.enabled)
|
||||||
|
.map(item => item.subscriptionId)
|
||||||
|
);
|
||||||
|
return [...this.getSubscriptionInfo()]
|
||||||
|
.filter(([id]) => enabledIds.has(id))
|
||||||
|
.map(([id, info]) => ({
|
||||||
|
id,
|
||||||
|
name: info.name,
|
||||||
|
color: getCalendarColor(
|
||||||
|
id,
|
||||||
|
workspaceItems.find(item => item.subscriptionId === id)
|
||||||
|
?.colorOverride ?? info.color
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSubscriptionInfo() {
|
||||||
|
const infoBySubscriptionId = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
name: string;
|
||||||
|
color?: string | null;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
for (const calendars of this.calendar?.accountCalendars$.value.values() ??
|
||||||
|
[]) {
|
||||||
|
for (const subscription of calendars) {
|
||||||
|
infoBySubscriptionId.set(subscription.id, {
|
||||||
|
name:
|
||||||
|
subscription.displayName ??
|
||||||
|
subscription.externalCalendarId ??
|
||||||
|
subscription.id,
|
||||||
|
color: subscription.color,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return infoBySubscriptionId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createWorkspaceCalendarExternalSource = (
|
||||||
|
framework?: FrameworkProvider
|
||||||
|
) => {
|
||||||
|
if (!framework) {
|
||||||
|
return {
|
||||||
|
id: 'workspace-calendar',
|
||||||
|
create: (viewData: CalendarViewData) =>
|
||||||
|
new WorkspaceCalendarExternalSource(undefined, () => false, viewData),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const integration = framework.get(IntegrationService);
|
||||||
|
const server = framework.get(WorkspaceServerService);
|
||||||
|
const dialog = framework.get(WorkspaceDialogService);
|
||||||
|
return {
|
||||||
|
id: 'workspace-calendar',
|
||||||
|
create: (viewData: CalendarViewData) =>
|
||||||
|
new WorkspaceCalendarExternalSource(
|
||||||
|
integration.calendar,
|
||||||
|
() => !!server.server,
|
||||||
|
viewData,
|
||||||
|
() =>
|
||||||
|
dialog.open('setting', {
|
||||||
|
activeTab: 'workspace:integrations',
|
||||||
|
scrollAnchor: CALENDAR_INTEGRATION_SCROLL_ANCHOR,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -224,6 +224,7 @@ class ViewProvider {
|
|||||||
};
|
};
|
||||||
|
|
||||||
private readonly _configureDatabase = (framework?: FrameworkProvider) => {
|
private readonly _configureDatabase = (framework?: FrameworkProvider) => {
|
||||||
|
this._manager.configure(AffineDatabaseViewExtension, { framework });
|
||||||
if (framework) {
|
if (framework) {
|
||||||
this._manager.configure(
|
this._manager.configure(
|
||||||
DatabaseViewExtension,
|
DatabaseViewExtension,
|
||||||
|
|||||||
@@ -3,11 +3,16 @@ import {
|
|||||||
ExternalGroupByConfigProvider,
|
ExternalGroupByConfigProvider,
|
||||||
} from '@blocksuite/affine/blocks/database';
|
} from '@blocksuite/affine/blocks/database';
|
||||||
import type { ExtensionType } from '@blocksuite/affine/store';
|
import type { ExtensionType } from '@blocksuite/affine/store';
|
||||||
|
import { CalendarExternalSourceProvider } from '@blocksuite/data-view/view-presets';
|
||||||
|
import type { FrameworkProvider } from '@toeverything/infra';
|
||||||
|
|
||||||
|
import { createWorkspaceCalendarExternalSource } from '../../database-block/calendar/workspace-calendar-source';
|
||||||
import { groupByConfigList } from '../../database-block/group-by';
|
import { groupByConfigList } from '../../database-block/group-by';
|
||||||
import { propertiesPresets } from '../../database-block/properties';
|
import { propertiesPresets } from '../../database-block/properties';
|
||||||
|
|
||||||
export function patchDatabaseBlockConfigService(): ExtensionType {
|
export function patchDatabaseBlockConfigService(
|
||||||
|
framework?: FrameworkProvider
|
||||||
|
): ExtensionType {
|
||||||
//TODO use service
|
//TODO use service
|
||||||
DatabaseBlockDataSource.externalProperties.value = propertiesPresets;
|
DatabaseBlockDataSource.externalProperties.value = propertiesPresets;
|
||||||
return {
|
return {
|
||||||
@@ -15,6 +20,8 @@ export function patchDatabaseBlockConfigService(): ExtensionType {
|
|||||||
groupByConfigList.forEach(config => {
|
groupByConfigList.forEach(config => {
|
||||||
di.addValue(ExternalGroupByConfigProvider(config.name), config);
|
di.addValue(ExternalGroupByConfigProvider(config.name), config);
|
||||||
});
|
});
|
||||||
|
const source = createWorkspaceCalendarExternalSource(framework);
|
||||||
|
di.addValue(CalendarExternalSourceProvider(source.id), source);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,14 @@ import {
|
|||||||
type ViewExtensionContext,
|
type ViewExtensionContext,
|
||||||
ViewExtensionProvider,
|
ViewExtensionProvider,
|
||||||
} from '@blocksuite/affine/ext-loader';
|
} from '@blocksuite/affine/ext-loader';
|
||||||
|
import { FrameworkProvider } from '@toeverything/infra';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { patchDatabaseBlockConfigService } from './database-block-config-service';
|
import { patchDatabaseBlockConfigService } from './database-block-config-service';
|
||||||
|
|
||||||
const optionsSchema = z.object({});
|
const optionsSchema = z.object({
|
||||||
|
framework: z.instanceof(FrameworkProvider).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
export type AffineDatabaseViewOptions = z.infer<typeof optionsSchema>;
|
export type AffineDatabaseViewOptions = z.infer<typeof optionsSchema>;
|
||||||
|
|
||||||
@@ -21,6 +24,6 @@ export class AffineDatabaseViewExtension extends ViewExtensionProvider<AffineDat
|
|||||||
) {
|
) {
|
||||||
super.setup(context, options);
|
super.setup(context, options);
|
||||||
|
|
||||||
context.register(patchDatabaseBlockConfigService());
|
context.register(patchDatabaseBlockConfigService(options?.framework));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
{ "path": "../../../blocksuite/affine/blocks/root" },
|
{ "path": "../../../blocksuite/affine/blocks/root" },
|
||||||
{ "path": "../../../blocksuite/affine/components" },
|
{ "path": "../../../blocksuite/affine/components" },
|
||||||
{ "path": "../../../blocksuite/affine/shared" },
|
{ "path": "../../../blocksuite/affine/shared" },
|
||||||
|
{ "path": "../../../blocksuite/affine/data-view" },
|
||||||
{ "path": "../../../blocksuite/framework/global" },
|
{ "path": "../../../blocksuite/framework/global" },
|
||||||
{ "path": "../../../blocksuite/framework/std" },
|
{ "path": "../../../blocksuite/framework/std" },
|
||||||
{ "path": "../../common/infra" },
|
{ "path": "../../common/infra" },
|
||||||
|
|||||||
475
tests/blocksuite/e2e/database/calendar.spec.ts
Normal file
475
tests/blocksuite/e2e/database/calendar.spec.ts
Normal file
@@ -0,0 +1,475 @@
|
|||||||
|
import type { DatabaseBlockModel } from '@blocksuite/affine/model';
|
||||||
|
import type { DeltaInsert } from '@blocksuite/store';
|
||||||
|
import { expect, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
enterPlaygroundRoom,
|
||||||
|
getEditorHostLocator,
|
||||||
|
waitNextFrame,
|
||||||
|
} from '../utils/actions/index.js';
|
||||||
|
import { scoped, test } from '../utils/playwright.js';
|
||||||
|
|
||||||
|
const createCalendarDatabase = async (
|
||||||
|
page: Page,
|
||||||
|
options?: {
|
||||||
|
withDateColumn?: boolean;
|
||||||
|
mapDateColumn?: boolean;
|
||||||
|
readonly?: boolean;
|
||||||
|
rowCount?: number;
|
||||||
|
linkedDocTitle?: string;
|
||||||
|
withEndDateColumn?: boolean;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
return page.evaluate(
|
||||||
|
({
|
||||||
|
withDateColumn,
|
||||||
|
mapDateColumn,
|
||||||
|
readonly,
|
||||||
|
rowCount,
|
||||||
|
linkedDocTitle,
|
||||||
|
withEndDateColumn,
|
||||||
|
}) => {
|
||||||
|
const { doc } = window;
|
||||||
|
const rows = rowCount ?? 1;
|
||||||
|
const linkedDocId = linkedDocTitle ? `doc:${linkedDocTitle}` : undefined;
|
||||||
|
doc.captureSync();
|
||||||
|
const rootId = doc.addBlock('affine:page', {
|
||||||
|
title: new window.$blocksuite.store.Text(),
|
||||||
|
});
|
||||||
|
const noteId = doc.addBlock('affine:note', {}, rootId);
|
||||||
|
const databaseId = doc.addBlock(
|
||||||
|
'affine:database',
|
||||||
|
{
|
||||||
|
title: new window.$blocksuite.store.Text('Calendar database'),
|
||||||
|
},
|
||||||
|
noteId
|
||||||
|
);
|
||||||
|
if (linkedDocId && linkedDocTitle) {
|
||||||
|
const linkedDoc = window.collection.createDoc(linkedDocId).getStore();
|
||||||
|
linkedDoc.load();
|
||||||
|
const linkedRootId = linkedDoc.addBlock('affine:page', {
|
||||||
|
title: new window.$blocksuite.store.Text(linkedDocTitle),
|
||||||
|
});
|
||||||
|
linkedDoc.addBlock('affine:surface', {}, linkedRootId);
|
||||||
|
linkedDoc.addBlock('affine:note', {}, linkedRootId);
|
||||||
|
}
|
||||||
|
const rowIds = Array.from({ length: rows }, (_, index) => {
|
||||||
|
const text =
|
||||||
|
index === 0 && linkedDocId
|
||||||
|
? new window.$blocksuite.store.Text([
|
||||||
|
{
|
||||||
|
insert: ' ',
|
||||||
|
attributes: {
|
||||||
|
reference: {
|
||||||
|
type: 'LinkedPage',
|
||||||
|
pageId: linkedDocId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] as unknown as DeltaInsert[])
|
||||||
|
: new window.$blocksuite.store.Text(`Task ${index + 1}`);
|
||||||
|
return doc.addBlock(
|
||||||
|
'affine:paragraph',
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text,
|
||||||
|
},
|
||||||
|
databaseId
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const rowId = rowIds[0];
|
||||||
|
const model = doc.getModelById(databaseId) as DatabaseBlockModel;
|
||||||
|
const datasource =
|
||||||
|
new window.$blocksuite.blocks.database.DatabaseBlockDataSource(model);
|
||||||
|
const dateColumnId = withDateColumn
|
||||||
|
? datasource.propertyAdd('end', {
|
||||||
|
type: 'date',
|
||||||
|
name: 'Date',
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
|
const endDateColumnId = withEndDateColumn
|
||||||
|
? datasource.propertyAdd('end', {
|
||||||
|
type: 'date',
|
||||||
|
name: 'End Date',
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
|
if (dateColumnId) {
|
||||||
|
for (const id of rowIds) {
|
||||||
|
datasource.cellValueChange(
|
||||||
|
id,
|
||||||
|
dateColumnId,
|
||||||
|
new Date('2026-05-15T00:00:00').getTime()
|
||||||
|
);
|
||||||
|
if (endDateColumnId) {
|
||||||
|
datasource.cellValueChange(
|
||||||
|
id,
|
||||||
|
endDateColumnId,
|
||||||
|
new Date('2026-05-17T00:00:00').getTime()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const viewId = datasource.viewManager.viewAdd('calendar');
|
||||||
|
datasource.viewManager.setCurrentView(viewId);
|
||||||
|
if (dateColumnId && mapDateColumn) {
|
||||||
|
const view = datasource.viewManager.viewGet(viewId) as
|
||||||
|
| {
|
||||||
|
setDateColumn: (propertyId: string) => void;
|
||||||
|
setEndDateColumn: (propertyId: string) => void;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
view?.setDateColumn(dateColumnId);
|
||||||
|
if (endDateColumnId) {
|
||||||
|
view?.setEndDateColumn(endDateColumnId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (readonly) {
|
||||||
|
doc.readonly = true;
|
||||||
|
}
|
||||||
|
doc.captureSync();
|
||||||
|
return {
|
||||||
|
databaseId,
|
||||||
|
rowId,
|
||||||
|
rowIds,
|
||||||
|
dateColumnId,
|
||||||
|
endDateColumnId,
|
||||||
|
viewId,
|
||||||
|
linkedDocId,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
options ?? {}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
test(scoped`database calendar setup and row interactions`, async ({ page }) => {
|
||||||
|
await enterPlaygroundRoom(page);
|
||||||
|
const ids = await createCalendarDatabase(page, {
|
||||||
|
withDateColumn: true,
|
||||||
|
mapDateColumn: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const editorHost = getEditorHostLocator(page);
|
||||||
|
await expect(editorHost.getByTestId('dv-calendar-view')).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: 'Select or create date property' })
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'Select or create date property' })
|
||||||
|
.click();
|
||||||
|
await page.getByText('Date', { exact: true }).click();
|
||||||
|
await expect(
|
||||||
|
page.locator('.calendar-entry').filter({ hasText: 'Task 1' })
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
await page.locator('.calendar-entry').filter({ hasText: 'Task 1' }).click();
|
||||||
|
await expect(page.locator('affine-data-view-record-detail')).toBeVisible();
|
||||||
|
|
||||||
|
await page.evaluate(({ databaseId, dateColumnId }) => {
|
||||||
|
if (!dateColumnId) {
|
||||||
|
throw new Error('dateColumnId is required');
|
||||||
|
}
|
||||||
|
const model = window.doc.getModelById(databaseId) as DatabaseBlockModel;
|
||||||
|
const datasource =
|
||||||
|
new window.$blocksuite.blocks.database.DatabaseBlockDataSource(model);
|
||||||
|
datasource.propertyDelete(dateColumnId);
|
||||||
|
}, ids);
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: 'Select or create date property' })
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
scoped`database calendar creates date property from setup`,
|
||||||
|
async ({ page }) => {
|
||||||
|
await enterPlaygroundRoom(page);
|
||||||
|
await createCalendarDatabase(page);
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'Select or create date property' })
|
||||||
|
.click();
|
||||||
|
await page.getByText('Create date property', { exact: true }).click();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: 'Select or create date property' })
|
||||||
|
).toBeHidden();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
test(scoped`database calendar creates row from empty day`, async ({ page }) => {
|
||||||
|
await enterPlaygroundRoom(page);
|
||||||
|
const ids = await createCalendarDatabase(page, {
|
||||||
|
withDateColumn: true,
|
||||||
|
mapDateColumn: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const targetDay = page
|
||||||
|
.locator('.calendar-day')
|
||||||
|
.filter({ has: page.locator('.calendar-day-number', { hasText: '20' }) })
|
||||||
|
.first();
|
||||||
|
await targetDay.hover();
|
||||||
|
await targetDay.getByRole('button', { name: '+ New row' }).click();
|
||||||
|
|
||||||
|
await expect(page.locator('affine-data-view-record-detail')).toBeVisible();
|
||||||
|
|
||||||
|
const expectedDate = await page.evaluate(() =>
|
||||||
|
new Date('2026-05-20T00:00:00').getTime()
|
||||||
|
);
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
page.evaluate(
|
||||||
|
({ databaseId, dateColumnId, expectedDate }) => {
|
||||||
|
if (!dateColumnId) {
|
||||||
|
throw new Error('dateColumnId is required');
|
||||||
|
}
|
||||||
|
const model = window.doc.getModelById(
|
||||||
|
databaseId
|
||||||
|
) as DatabaseBlockModel;
|
||||||
|
const datasource =
|
||||||
|
new window.$blocksuite.blocks.database.DatabaseBlockDataSource(
|
||||||
|
model
|
||||||
|
);
|
||||||
|
return datasource.rows$.value.some(
|
||||||
|
rowId =>
|
||||||
|
datasource.cellValueGet(rowId, dateColumnId) === expectedDate
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ ...ids, expectedDate }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
scoped`database calendar opens linked doc row via row detail`,
|
||||||
|
async ({ page }) => {
|
||||||
|
await enterPlaygroundRoom(page);
|
||||||
|
const ids = await createCalendarDatabase(page, {
|
||||||
|
withDateColumn: true,
|
||||||
|
mapDateColumn: true,
|
||||||
|
linkedDocTitle: 'Calendar linked doc',
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.evaluate(({ databaseId }) => {
|
||||||
|
const database = document.querySelector(
|
||||||
|
`affine-database[data-block-id="${databaseId}"]`
|
||||||
|
) as any;
|
||||||
|
database.dataViewRootLogic.value.openDetailPanel = ({
|
||||||
|
rowId,
|
||||||
|
}: {
|
||||||
|
rowId: string;
|
||||||
|
}) => {
|
||||||
|
(window as any).__calendarRowOpen = rowId;
|
||||||
|
};
|
||||||
|
}, ids);
|
||||||
|
|
||||||
|
const entry = page.locator('.calendar-entry.row').first();
|
||||||
|
await expect(entry).toContainText('Calendar linked doc');
|
||||||
|
await entry.click();
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() => page.evaluate(() => (window as any).__calendarRowOpen))
|
||||||
|
.toBe(ids.rowId);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
scoped`database calendar hides setup create action in readonly`,
|
||||||
|
async ({ page }) => {
|
||||||
|
await enterPlaygroundRoom(page);
|
||||||
|
await createCalendarDatabase(page, { readonly: true });
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'Select or create date property' })
|
||||||
|
.click();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByText('Create date property', { exact: true })
|
||||||
|
).toBeHidden();
|
||||||
|
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.doc.readonly = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
test(scoped`database calendar shows all day entries`, async ({ page }) => {
|
||||||
|
await enterPlaygroundRoom(page);
|
||||||
|
await createCalendarDatabase(page, {
|
||||||
|
withDateColumn: true,
|
||||||
|
mapDateColumn: true,
|
||||||
|
rowCount: 4,
|
||||||
|
});
|
||||||
|
|
||||||
|
const may15 = page
|
||||||
|
.locator('.calendar-day')
|
||||||
|
.filter({ has: page.locator('.calendar-day-number', { hasText: '15' }) })
|
||||||
|
.first();
|
||||||
|
|
||||||
|
await expect(may15.locator('.calendar-entry')).toHaveCount(4);
|
||||||
|
await expect(may15.locator('.calendar-overflow')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
scoped`database calendar drag updates date cell and readonly disables drag`,
|
||||||
|
async ({ page }) => {
|
||||||
|
await enterPlaygroundRoom(page);
|
||||||
|
const ids = await createCalendarDatabase(page, {
|
||||||
|
withDateColumn: true,
|
||||||
|
mapDateColumn: true,
|
||||||
|
});
|
||||||
|
await waitNextFrame(page);
|
||||||
|
|
||||||
|
const entry = page.locator('.calendar-entry').filter({ hasText: 'Task 1' });
|
||||||
|
const targetDay = page
|
||||||
|
.locator('.calendar-day')
|
||||||
|
.filter({ has: page.locator('.calendar-day-number', { hasText: '20' }) })
|
||||||
|
.first();
|
||||||
|
await entry.dragTo(targetDay);
|
||||||
|
|
||||||
|
const expectedDate = await page.evaluate(() =>
|
||||||
|
new Date('2026-05-20T00:00:00').getTime()
|
||||||
|
);
|
||||||
|
await expect
|
||||||
|
.poll(async () =>
|
||||||
|
page.evaluate(({ databaseId, rowId, dateColumnId }) => {
|
||||||
|
if (!dateColumnId) {
|
||||||
|
throw new Error('dateColumnId is required');
|
||||||
|
}
|
||||||
|
const model = window.doc.getModelById(
|
||||||
|
databaseId
|
||||||
|
) as DatabaseBlockModel;
|
||||||
|
const datasource =
|
||||||
|
new window.$blocksuite.blocks.database.DatabaseBlockDataSource(
|
||||||
|
model
|
||||||
|
);
|
||||||
|
return datasource.cellValueGet(rowId, dateColumnId);
|
||||||
|
}, ids)
|
||||||
|
)
|
||||||
|
.toBe(expectedDate);
|
||||||
|
|
||||||
|
const readonlyIds = await createCalendarDatabase(page, {
|
||||||
|
withDateColumn: true,
|
||||||
|
mapDateColumn: true,
|
||||||
|
readonly: true,
|
||||||
|
});
|
||||||
|
await waitNextFrame(page);
|
||||||
|
const readonlyCalendar = page.getByTestId('dv-calendar-view').last();
|
||||||
|
await expect(
|
||||||
|
readonlyCalendar
|
||||||
|
.locator('.calendar-entry')
|
||||||
|
.filter({ hasText: 'Task 1' })
|
||||||
|
.first()
|
||||||
|
).toHaveAttribute('draggable', 'false');
|
||||||
|
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.doc.readonly = false;
|
||||||
|
});
|
||||||
|
expect(readonlyIds.databaseId).toBeTruthy();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
scoped`database calendar drags row range preserving duration`,
|
||||||
|
async ({ page }) => {
|
||||||
|
await enterPlaygroundRoom(page);
|
||||||
|
const ids = await createCalendarDatabase(page, {
|
||||||
|
withDateColumn: true,
|
||||||
|
withEndDateColumn: true,
|
||||||
|
mapDateColumn: true,
|
||||||
|
});
|
||||||
|
await waitNextFrame(page);
|
||||||
|
|
||||||
|
const entry = page
|
||||||
|
.locator('.calendar-entry.row')
|
||||||
|
.filter({ hasText: 'Task 1' })
|
||||||
|
.first();
|
||||||
|
const targetDay = page
|
||||||
|
.locator('.calendar-day')
|
||||||
|
.filter({ has: page.locator('.calendar-day-number', { hasText: '20' }) })
|
||||||
|
.first();
|
||||||
|
await entry.dragTo(targetDay);
|
||||||
|
|
||||||
|
const expectedStart = await page.evaluate(() =>
|
||||||
|
new Date('2026-05-20T00:00:00').getTime()
|
||||||
|
);
|
||||||
|
const expectedEnd = await page.evaluate(() =>
|
||||||
|
new Date('2026-05-22T00:00:00').getTime()
|
||||||
|
);
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
page.evaluate(
|
||||||
|
({ databaseId, rowId, dateColumnId, endDateColumnId }) => {
|
||||||
|
if (!dateColumnId || !endDateColumnId) {
|
||||||
|
throw new Error('date columns are required');
|
||||||
|
}
|
||||||
|
const model = window.doc.getModelById(
|
||||||
|
databaseId
|
||||||
|
) as DatabaseBlockModel;
|
||||||
|
const datasource =
|
||||||
|
new window.$blocksuite.blocks.database.DatabaseBlockDataSource(
|
||||||
|
model
|
||||||
|
);
|
||||||
|
return [
|
||||||
|
datasource.cellValueGet(rowId, dateColumnId),
|
||||||
|
datasource.cellValueGet(rowId, endDateColumnId),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
ids
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.toEqual([expectedStart, expectedEnd]);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
test(scoped`database calendar resizes row range end date`, async ({ page }) => {
|
||||||
|
await enterPlaygroundRoom(page);
|
||||||
|
const ids = await createCalendarDatabase(page, {
|
||||||
|
withDateColumn: true,
|
||||||
|
withEndDateColumn: true,
|
||||||
|
mapDateColumn: true,
|
||||||
|
});
|
||||||
|
await waitNextFrame(page);
|
||||||
|
|
||||||
|
const entry = page
|
||||||
|
.locator('.calendar-entry.row')
|
||||||
|
.filter({ hasText: 'Task 1' })
|
||||||
|
.filter({ has: page.locator('.calendar-resize-handle.right') })
|
||||||
|
.first();
|
||||||
|
await entry.hover();
|
||||||
|
const handle = entry.locator('.calendar-resize-handle.right');
|
||||||
|
const targetDay = page
|
||||||
|
.locator('.calendar-day')
|
||||||
|
.filter({ has: page.locator('.calendar-day-number', { hasText: '20' }) })
|
||||||
|
.first();
|
||||||
|
const handleBox = await handle.boundingBox();
|
||||||
|
const targetBox = await targetDay.boundingBox();
|
||||||
|
if (!handleBox || !targetBox) {
|
||||||
|
throw new Error('resize target is not visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.mouse.move(
|
||||||
|
handleBox.x + handleBox.width / 2,
|
||||||
|
handleBox.y + handleBox.height / 2
|
||||||
|
);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(targetBox.x + targetBox.width / 2, targetBox.y + 16);
|
||||||
|
await page.mouse.up();
|
||||||
|
|
||||||
|
const expectedEnd = await page.evaluate(() =>
|
||||||
|
new Date('2026-05-20T00:00:00').getTime()
|
||||||
|
);
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
page.evaluate(({ databaseId, rowId, endDateColumnId }) => {
|
||||||
|
if (!endDateColumnId) {
|
||||||
|
throw new Error('endDateColumnId is required');
|
||||||
|
}
|
||||||
|
const model = window.doc.getModelById(databaseId) as DatabaseBlockModel;
|
||||||
|
const datasource =
|
||||||
|
new window.$blocksuite.blocks.database.DatabaseBlockDataSource(model);
|
||||||
|
return datasource.cellValueGet(rowId, endDateColumnId);
|
||||||
|
}, ids)
|
||||||
|
)
|
||||||
|
.toBe(expectedEnd);
|
||||||
|
});
|
||||||
@@ -608,7 +608,7 @@ test.describe('slash search', () => {
|
|||||||
await expect(slashMenu).toBeVisible();
|
await expect(slashMenu).toBeVisible();
|
||||||
|
|
||||||
await type(page, 'c');
|
await type(page, 'c');
|
||||||
await expect(slashItems).toHaveCount(11);
|
await expect(slashItems).toHaveCount(12);
|
||||||
await expect(slashItems.nth(0).locator('.text')).toHaveText(['Copy']);
|
await expect(slashItems.nth(0).locator('.text')).toHaveText(['Copy']);
|
||||||
await expect(slashItems.nth(1).locator('.text')).toHaveText(['Italic']);
|
await expect(slashItems.nth(1).locator('.text')).toHaveText(['Italic']);
|
||||||
await expect(slashItems.nth(2).locator('.text')).toHaveText(['Callout']);
|
await expect(slashItems.nth(2).locator('.text')).toHaveText(['Callout']);
|
||||||
@@ -632,11 +632,14 @@ test.describe('slash search', () => {
|
|||||||
|
|
||||||
const slashItems = slashMenu.locator('icon-button');
|
const slashItems = slashMenu.locator('icon-button');
|
||||||
await type(page, 'database');
|
await type(page, 'database');
|
||||||
await expect(slashItems).toHaveCount(2);
|
await expect(slashItems).toHaveCount(3);
|
||||||
await expect(slashItems.nth(0).locator('.text')).toHaveText(['Table View']);
|
await expect(slashItems.nth(0).locator('.text')).toHaveText(['Table View']);
|
||||||
await expect(slashItems.nth(1).locator('.text')).toHaveText([
|
await expect(slashItems.nth(1).locator('.text')).toHaveText([
|
||||||
'Kanban View',
|
'Kanban View',
|
||||||
]);
|
]);
|
||||||
|
await expect(slashItems.nth(2).locator('.text')).toHaveText([
|
||||||
|
'Calendar View',
|
||||||
|
]);
|
||||||
await type(page, 'v');
|
await type(page, 'v');
|
||||||
await expect(slashItems).toHaveCount(0);
|
await expect(slashItems).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1302,6 +1302,7 @@ export const PackageList = [
|
|||||||
'blocksuite/affine/blocks/root',
|
'blocksuite/affine/blocks/root',
|
||||||
'blocksuite/affine/components',
|
'blocksuite/affine/components',
|
||||||
'blocksuite/affine/shared',
|
'blocksuite/affine/shared',
|
||||||
|
'blocksuite/affine/data-view',
|
||||||
'blocksuite/framework/global',
|
'blocksuite/framework/global',
|
||||||
'blocksuite/framework/std',
|
'blocksuite/framework/std',
|
||||||
'packages/common/infra',
|
'packages/common/infra',
|
||||||
|
|||||||
@@ -390,6 +390,7 @@ __metadata:
|
|||||||
"@blocksuite/affine-components": "workspace:*"
|
"@blocksuite/affine-components": "workspace:*"
|
||||||
"@blocksuite/affine-ext-loader": "workspace:*"
|
"@blocksuite/affine-ext-loader": "workspace:*"
|
||||||
"@blocksuite/affine-shared": "workspace:*"
|
"@blocksuite/affine-shared": "workspace:*"
|
||||||
|
"@blocksuite/data-view": "workspace:*"
|
||||||
"@blocksuite/global": "workspace:*"
|
"@blocksuite/global": "workspace:*"
|
||||||
"@blocksuite/icons": "npm:^2.2.17"
|
"@blocksuite/icons": "npm:^2.2.17"
|
||||||
"@blocksuite/std": "workspace:*"
|
"@blocksuite/std": "workspace:*"
|
||||||
|
|||||||
Reference in New Issue
Block a user