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:
DarkSky
2026-05-17 20:40:36 +08:00
committed by GitHub
parent e9ef3c50c8
commit 0f5778ac89
40 changed files with 6367 additions and 724 deletions
@@ -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) => {
this._manager.configure(AffineDatabaseViewExtension, { framework });
if (framework) {
this._manager.configure(
DatabaseViewExtension,
@@ -3,11 +3,16 @@ import {
ExternalGroupByConfigProvider,
} from '@blocksuite/affine/blocks/database';
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 { propertiesPresets } from '../../database-block/properties';
export function patchDatabaseBlockConfigService(): ExtensionType {
export function patchDatabaseBlockConfigService(
framework?: FrameworkProvider
): ExtensionType {
//TODO use service
DatabaseBlockDataSource.externalProperties.value = propertiesPresets;
return {
@@ -15,6 +20,8 @@ export function patchDatabaseBlockConfigService(): ExtensionType {
groupByConfigList.forEach(config => {
di.addValue(ExternalGroupByConfigProvider(config.name), config);
});
const source = createWorkspaceCalendarExternalSource(framework);
di.addValue(CalendarExternalSourceProvider(source.id), source);
},
};
}
@@ -2,11 +2,14 @@ import {
type ViewExtensionContext,
ViewExtensionProvider,
} from '@blocksuite/affine/ext-loader';
import { FrameworkProvider } from '@toeverything/infra';
import { z } from 'zod';
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>;
@@ -21,6 +24,6 @@ export class AffineDatabaseViewExtension extends ViewExtensionProvider<AffineDat
) {
super.setup(context, options);
context.register(patchDatabaseBlockConfigService());
context.register(patchDatabaseBlockConfigService(options?.framework));
}
}