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
@@ -254,6 +254,7 @@ export class DataViewBlockComponent extends CaptionedBlockComponent<DataViewBloc
dataSource: this.dataSource,
headerWidget: this.headerWidget,
clipboard: this.std.clipboard,
dnd: this.std.dnd,
notification: {
toast: message => {
const notification = this.std.getOptional(NotificationProvider);
@@ -6,6 +6,7 @@ import { viewPresets } from '@blocksuite/data-view/view-presets';
import {
DatabaseKanbanViewIcon,
DatabaseTableViewIcon,
TodayIcon,
} from '@blocksuite/icons/lit';
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',
description: 'Visualize data in a dashboard.',
@@ -34,6 +34,7 @@ import {
type SingleView,
uniMap,
} from '@blocksuite/data-view';
import { CalendarExternalSourceProvider } from '@blocksuite/data-view/view-presets';
import { widgetPresets } from '@blocksuite/data-view/widget-presets';
import { IS_MOBILE } from '@blocksuite/global/env';
import { Rect } from '@blocksuite/global/gfx';
@@ -150,6 +151,14 @@ export class DatabaseBlockComponent extends CaptionedBlockComponent<DatabaseBloc
config
);
});
this.std.provider
.getAll(CalendarExternalSourceProvider)
.forEach(source => {
dataSource.serviceSet(
CalendarExternalSourceProvider(source.id),
source
);
});
});
const id = currentViewStorage.getCurrentView(this.model.id);
if (id && dataSource.viewManager.viewGet(id)) {
@@ -293,6 +302,12 @@ export class DatabaseBlockComponent extends CaptionedBlockComponent<DatabaseBloc
widgetPresets.tools.viewOptions,
widgetPresets.tools.tableAddRow,
],
calendar: [
widgetPresets.tools.filter,
widgetPresets.tools.search,
widgetPresets.tools.viewOptions,
widgetPresets.tools.tableAddRow,
],
});
private readonly viewSelection$ = computed(() => {
@@ -427,6 +442,7 @@ export class DatabaseBlockComponent extends CaptionedBlockComponent<DatabaseBloc
headerWidget: this.headerWidget,
onDrag: this.onDrag,
clipboard: this.std.clipboard,
dnd: this.std.dnd,
notification: {
toast: message => {
const notification = this.std.getOptional(NotificationProvider);
@@ -4,6 +4,7 @@ import { viewConverts, viewPresets } from '@blocksuite/data-view/view-presets';
export const databaseBlockViews: ViewMeta[] = [
viewPresets.tableViewMeta,
viewPresets.kanbanViewMeta,
viewPresets.calendarViewMeta,
];
export const databaseBlockViewMap = Object.fromEntries(
@@ -95,7 +95,9 @@ export class MenuInput extends MenuFocusable {
});
requestAnimationFrame(() => {
requestAnimationFrame(() => {
this.inputRef.select();
if (!this.data.disableAutoFocus) {
this.inputRef.select();
}
});
});
}
@@ -223,6 +225,7 @@ export const menuInputItems = {
onComplete?: (value: string) => void;
onChange?: (value: string) => void;
onBlur?: (value: string) => void;
disableAutoFocus?: boolean;
class?: string;
style?: Readonly<StyleInfo>;
}) =>
@@ -237,6 +240,7 @@ export const menuInputItems = {
onComplete: config.onComplete,
onChange: config.onChange,
onBlur: config.onBlur,
disableAutoFocus: config.disableAutoFocus,
};
const style = styleMap({
display: 'flex',
@@ -111,8 +111,10 @@ export class MenuComponent
}
const onBack = this.menu.options.title?.onBack;
if (e.key === 'Backspace' && onBack && !this.menu.showSearch$.value) {
this.menu.close();
onBack(this.menu);
const result = onBack(this.menu);
if (result !== false) {
this.menu.close();
}
return;
}
if (e.key === 'Enter' && !e.isComposing) {
@@ -214,8 +216,10 @@ export class MenuComponent
${title.onBack
? html` <div
@click="${() => {
title.onBack?.(this.menu);
this.menu.close();
const result = title.onBack?.(this.menu);
if (result !== false) {
this.menu.close();
}
}}"
class="dv-icon-20 dv-hover dv-pd-2 dv-round-4"
style="display:flex;"
@@ -15,7 +15,7 @@ export type MenuOptions = {
onClose?: () => void;
title?: {
text: string;
onBack?: (menu: Menu) => void;
onBack?: (menu: Menu) => boolean | void;
onClose?: () => void;
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);
});
});
@@ -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 {
type Clipboard,
type DndController,
type EventName,
ShadowlessElement,
type UIEventHandler,
@@ -29,6 +30,7 @@ import type { DataViewWidget } from './widget/index.js';
export type DataViewRendererConfig = {
clipboard: Clipboard;
dnd?: DndController;
onDrag?: (evt: MouseEvent, id: string) => () => void;
notification: {
toast: (message: string) => void;
@@ -2,15 +2,10 @@ import {
dropdownSubMenuMiddleware,
menu,
type MenuConfig,
type MenuOptions,
popMenu,
type PopupTarget,
} from '@blocksuite/affine-components/context-menu';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import { DeleteIcon, InvisibleIcon, ViewIcon } from '@blocksuite/icons/lit';
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 { cssVarV2 } from '@toeverything/theme/v2';
import { css, html, unsafeCSS } from 'lit';
@@ -260,188 +255,183 @@ export class GroupSetting extends SignalWatcher(
@query('.group-sort-setting') accessor groupContainer!: HTMLElement;
}
export const selectGroupByProperty = (
export const buildGroupSelectItems = (
group: GroupTrait,
ops?: {
onSelect?: (id?: string) => void;
onClose?: () => void;
onBack?: () => void;
}
): MenuOptions => {
onSelect: (id?: string) => void
): MenuConfig[] => {
const view = group.view;
return {
onClose: ops?.onClose,
title: { text: 'Group by', onBack: ops?.onBack, onClose: ops?.onClose },
items: [
menu.group({
items: view.propertiesRaw$.value
.filter(property => {
if (property.type$.value === 'title') {
return false;
}
if (view instanceof KanbanSingleView) {
return canGroupable(view.manager.dataSource, property.id);
}
const dataType = property.dataType$.value;
if (!dataType) {
return false;
}
const groupByService = getGroupByService(view.manager.dataSource);
return !!groupByService?.matcher.match(dataType);
})
.map<MenuConfig>(property => {
return menu.action({
name: property.name$.value,
isSelected: group.property$.value?.id === property.id,
prefix: html` <uni-lit .uni="${property.icon}"></uni-lit>`,
select: () => {
group.changeGroup(property.id);
ops?.onSelect?.(property.id);
},
});
}),
}),
menu.group({
items: [
return [
menu.group({
items: view.propertiesRaw$.value
.filter(property => {
if (property.type$.value === 'title') {
return false;
}
if (view instanceof KanbanSingleView) {
return canGroupable(view.manager.dataSource, property.id);
}
const dataType = property.dataType$.value;
if (!dataType) {
return false;
}
const groupByService = getGroupByService(view.manager.dataSource);
return !!groupByService?.matcher.match(dataType);
})
.map<MenuConfig>(property =>
menu.action({
prefix: DeleteIcon(),
hide: () =>
view instanceof KanbanSingleView || !group.property$.value,
class: { 'delete-item': true },
name: 'Remove Grouping',
name: property.name$.value,
isSelected: group.property$.value?.id === property.id,
prefix: html`<uni-lit .uni="${property.icon}"></uni-lit>`,
select: () => {
group.changeGroup(undefined);
ops?.onSelect?.();
group.changeGroup(property.id);
onSelect(property.id);
return false;
},
}),
],
}),
],
};
})
),
}),
menu.group({
items: [
menu.action({
prefix: DeleteIcon(),
hide: () =>
view instanceof KanbanSingleView || !group.property$.value,
class: { 'delete-item': true },
name: 'Remove Grouping',
select: () => {
group.changeGroup(undefined);
onSelect(undefined);
return false;
},
}),
],
}),
];
};
export const popSelectGroupByProperty = (
target: PopupTarget,
export const buildGroupSettingItems = (
group: GroupTrait,
ops?: { onSelect?: () => void; onClose?: () => void; onBack?: () => void },
middleware?: Array<Middleware | null | undefined | false>
) => {
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>
) => {
onGroupByClick: () => void,
onGroupRemoved?: () => void
): MenuConfig[] => {
const view = group.view;
const gProp = group.property$.value;
if (!gProp) return;
if (!gProp) return [];
const type = gProp.type$.value;
if (!type) return;
if (!type) return [];
const icon = gProp.icon;
const menuHandler = popMenu(target, {
options: {
title: {
text: 'Group',
onBack,
onClose,
},
items: [
menu.group({
items: [
menu.action({
name: 'Group By',
postfix: html`
<div
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:8px;"
class="dv-icon-16"
>
${renderUniLit(icon, {})} ${gProp.name$.value}
</div>
`,
select: () => {
const subHandler = popMenu(target, {
options: selectGroupByProperty(group, {
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';
},
}),
],
}),
...(type === 'date'
? [
menu.group({
items: [
menu.dynamic(() => [
menu.subMenu({
name: 'Date by',
openOnHover: false,
middleware: dropdownSubMenuMiddleware,
autoHeight: true,
postfix: html`
<div
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:30px;"
>
${dateModeLabel(group.groupInfo$.value?.config.name)}
</div>
`,
options: {
items: [
menu.dynamic(() =>
(
[
['Relative', 'date-relative'],
['Day', 'date-day'],
return [
menu.group({
items: [
menu.action({
name: 'Group By',
postfix: html`
<div
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:8px;"
class="dv-icon-16"
>
${renderUniLit(icon, {})} ${gProp.name$.value}
</div>
`,
select: () => {
onGroupByClick();
return false;
},
}),
],
}),
...(type === 'date'
? [
menu.group({
items: [
menu.dynamic(() => [
menu.subMenu({
name: 'Date by',
openOnHover: false,
middleware: dropdownSubMenuMiddleware,
autoHeight: true,
postfix: html`
<div
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:30px;"
>
${dateModeLabel(group.groupInfo$.value?.config.name)}
</div>
`,
options: {
items: [
menu.dynamic(() =>
(
[
['Relative', 'date-relative'],
['Day', 'date-day'],
[
'Week',
group.groupInfo$.value?.config.name ===
'date-week-mon'
? 'date-week-mon'
: 'date-week-sun',
],
['Month', 'date-month'],
['Year', 'date-year'],
] as [string, string][]
).map(
([label, key]): MenuConfig =>
menu.action({
name: label,
label: () => {
const isSelected =
group.groupInfo$.value?.config.name === key;
return html`<span
style="font-size:14px;color:${isSelected
? 'var(--affine-text-emphasis-color)'
: 'var(--affine-text-secondary-color)'}"
>${label}</span
>`;
},
isSelected:
group.groupInfo$.value?.config.name === key,
select: () => {
group.changeGroupMode(key);
return false;
},
})
)
),
],
},
}),
]),
],
}),
...(group.groupInfo$.value?.config.name?.startsWith('date-week')
? [
menu.group({
items: [
menu.dynamic(() => [
menu.subMenu({
name: 'Start week on',
postfix: html`
<div
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:8px;"
>
${group.groupInfo$.value?.config.name ===
'date-week-mon'
? 'Monday'
: 'Sunday'}
</div>
`,
options: {
items: [
menu.dynamic(() =>
(
[
'Week',
group.groupInfo$.value?.config.name ===
'date-week-mon'
? 'date-week-mon'
: 'date-week-sun',
],
['Month', 'date-month'],
['Year', 'date-year'],
] as [string, string][]
).map(
([label, key]): MenuConfig =>
['Monday', 'date-week-mon'],
['Sunday', 'date-week-sun'],
] as [string, string][]
).map(([label, key]) =>
menu.action({
name: label,
label: () => {
@@ -462,179 +452,118 @@ export const popGroupSetting = (
return false;
},
})
)
),
],
},
}),
]),
],
}),
)
),
],
},
}),
]),
],
}),
]
: []),
menu.group({
items: [
menu.dynamic(() => [
menu.subMenu({
name: 'Sort',
openOnHover: false,
middleware: dropdownSubMenuMiddleware,
autoHeight: true,
postfix: html`
<div
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 ? 'Oldest first' : 'Newest first'}
</div>
`,
options: {
items: [
menu.dynamic(() => [
menu.action({
name: 'Oldest first',
label: () => {
const isSelected = group.sortAsc$.value;
return html`<span
style="font-size:14px;color:${isSelected
? 'var(--affine-text-emphasis-color)'
: 'var(--affine-text-secondary-color)'}"
>Oldest first</span
>`;
},
isSelected: group.sortAsc$.value,
select: () => {
group.setDateSortOrder(true);
return false;
},
}),
menu.action({
name: 'Newest first',
label: () => {
const isSelected = !group.sortAsc$.value;
return html`<span
style="font-size:14px;color:${isSelected
? 'var(--affine-text-emphasis-color)'
: 'var(--affine-text-secondary-color)'}"
>Newest first</span
>`;
},
isSelected: !group.sortAsc$.value,
select: () => {
group.setDateSortOrder(false);
return false;
},
}),
]),
],
},
}),
]),
],
}),
]
: []),
...(group.groupInfo$.value?.config.name?.startsWith('date-week')
? [
menu.group({
items: [
menu.dynamic(() => [
menu.subMenu({
name: 'Start week on',
postfix: html`
<div
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:8px;"
>
${group.groupInfo$.value?.config.name ===
'date-week-mon'
? 'Monday'
: 'Sunday'}
</div>
`,
options: {
items: [
menu.dynamic(() =>
(
[
['Monday', 'date-week-mon'],
['Sunday', 'date-week-sun'],
] as [string, string][]
).map(([label, key]) =>
menu.action({
name: label,
label: () => {
const isSelected =
group.groupInfo$.value?.config
.name === key;
return html`<span
style="font-size:14px;color:${isSelected
? 'var(--affine-text-emphasis-color)'
: 'var(--affine-text-secondary-color)'}"
>${label}</span
>`;
},
isSelected:
group.groupInfo$.value?.config.name ===
key,
select: () => {
group.changeGroupMode(key);
return false;
},
})
)
),
],
},
}),
]),
],
}),
]
: []),
menu.group({
items: [
menu.dynamic(() => [
menu.subMenu({
name: 'Sort',
openOnHover: false,
middleware: dropdownSubMenuMiddleware,
autoHeight: true,
postfix: html`
<div
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
? 'Oldest first'
: 'Newest first'}
</div>
`,
options: {
items: [
menu.dynamic(() => [
menu.action({
name: 'Oldest first',
label: () => {
const isSelected = group.sortAsc$.value;
return html`<span
style="font-size:14px;color:${isSelected
? 'var(--affine-text-emphasis-color)'
: 'var(--affine-text-secondary-color)'}"
>Oldest first</span
>`;
},
isSelected: group.sortAsc$.value,
select: () => {
group.setDateSortOrder(true);
return false;
},
}),
menu.action({
name: 'Newest first',
label: () => {
const isSelected = !group.sortAsc$.value;
return html`<span
style="font-size:14px;color:${isSelected
? 'var(--affine-text-emphasis-color)'
: 'var(--affine-text-secondary-color)'}"
>Newest first</span
>`;
},
isSelected: !group.sortAsc$.value,
select: () => {
group.setDateSortOrder(false);
return false;
},
}),
]),
],
},
}),
]),
],
}),
]
: []),
menu.group({
items: [
menu.dynamic(() => [
menu.action({
name: 'Hide empty groups',
isSelected: group.hideEmpty$.value,
select: () => {
group.setHideEmpty(!group.hideEmpty$.value);
return false;
},
}),
]),
],
}),
menu.group({
items: [
menuObj => html`
<data-view-group-setting
@mouseenter=${() => menuObj.closeSubMenu()}
.groupTrait=${group}
.columnId=${gProp.id}
></data-view-group-setting>
`,
],
}),
menu.group({
items: [
menu.dynamic(() => [
menu.action({
name: 'Hide empty groups',
isSelected: group.hideEmpty$.value,
select: () => {
group.setHideEmpty(!group.hideEmpty$.value);
return false;
},
}),
]),
],
}),
menu.group({
items: [
menu => html`
<data-view-group-setting
@mouseenter=${() => menu.closeSubMenu()}
.groupTrait=${group}
.columnId=${gProp.id}
></data-view-group-setting>
`,
],
}),
menu.group({
items: [
menu.action({
name: 'Remove grouping',
prefix: DeleteIcon(),
class: { 'delete-item': true },
hide: () => !(view instanceof TableSingleView),
select: () => {
group.changeGroup(undefined);
return false;
},
}),
],
menu.group({
items: [
menu.action({
name: 'Remove grouping',
prefix: DeleteIcon(),
class: { 'delete-item': true },
hide: () => !(view instanceof TableSingleView),
select: () => {
group.changeGroup(undefined);
onGroupRemoved?.();
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';
@@ -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>
`,
],
},
});
};
@@ -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;
}
`;
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 { calendarViewModel } from './calendar/index.js';
import { kanbanViewModel } from './kanban/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 = [
createViewConvert(tableViewModel, kanbanViewModel, data => ({
filter: data.filter,
header: data.header,
})),
createViewConvert(kanbanViewModel, tableViewModel, data => ({
filter: data.filter,
header: data.header,
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 { tableEffects } from './table/effect.js';
export function viewPresetsEffects() {
calendarEffects();
kanbanEffects();
tableEffects();
}
@@ -1,6 +1,8 @@
import { calendarViewMeta } from './calendar/index.js';
import { kanbanViewMeta } from './kanban/index.js';
import { tableViewMeta } from './table/index.js';
export * from './calendar/index.js';
export * from './convert.js';
export * from './kanban/index.js';
export * from './table/index.js';
@@ -8,4 +10,5 @@ export * from './table/index.js';
export const viewPresets = {
tableViewMeta: tableViewMeta,
kanbanViewMeta: kanbanViewMeta,
calendarViewMeta: calendarViewMeta,
};
@@ -1,4 +1,5 @@
import {
type Menu,
menu,
type MenuButtonData,
type MenuConfig,
@@ -16,22 +17,22 @@ import {
InfoIcon,
LayoutIcon,
MoreHorizontalIcon,
PlusIcon,
SortIcon,
} from '@blocksuite/icons/lit';
import { autoPlacement, offset, shift } from '@floating-ui/dom';
import { signal } from '@preact/signals-core';
import { css, html } from 'lit';
import { styleMap } from 'lit/directives/style-map.js';
import { popPropertiesSetting } from '../../../../core/common/properties.js';
import { filterTraitKey } from '../../../../core/filter/trait.js';
import {
popGroupSetting,
popSelectGroupByProperty,
buildGroupSelectItems,
buildGroupSettingItems,
} from '../../../../core/group-by/setting.js';
import { groupTraitKey } from '../../../../core/group-by/trait.js';
import {
type DataViewUILogicBase,
emptyFilterGroup,
popCreateFilter,
renderUniLit,
} from '../../../../core/index.js';
@@ -39,8 +40,6 @@ import { popCreateSort } from '../../../../core/sort/add-sort.js';
import { sortTraitKey } from '../../../../core/sort/manager.js';
import { createSortUtils } from '../../../../core/sort/utils.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`
.affine-database-toolbar-item.more-action {
@@ -95,379 +94,486 @@ declare global {
'data-view-header-tools-view-options': DataViewHeaderToolsViewOptions;
}
}
const createSettingMenus = (
target: PopupTarget,
dataViewLogic: DataViewUILogicBase,
reopen: () => void,
closeMenu: () => void
) => {
const view = dataViewLogic.view;
const settingItems: MenuConfig[] = [];
settingItems.push(
menu.action({
name: 'Properties',
prefix: InfoIcon(),
closeOnSelect: false,
postfix: html` <div style="font-size: 14px;">
${view.properties$.value.length} shown
</div>
${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;
type Page =
| 'main'
| 'properties'
| 'filter'
| 'sort'
| 'group'
| 'group-select'
| 'custom';
const pageTitles: Record<Exclude<Page, 'custom'>, string> = {
main: 'View settings',
properties: 'Properties',
filter: 'Filter',
sort: 'Sort',
group: 'Group',
'group-select': 'Group by',
};
export const popViewOptions = (
target: PopupTarget,
dataViewLogic: DataViewUILogicBase,
onClose?: () => void
) => {
const view = dataViewLogic.view;
const reopen = () => {
popViewOptions(target, dataViewLogic);
};
let handler: ReturnType<typeof popMenu>;
const items: MenuConfig[] = [];
items.push(
menu.input({
initialValue: view.name$.value,
placeholder: 'View name',
onChange: text => {
view.nameSet(text);
},
})
);
items.push(
menu.group({
items: [
menu => {
const viewTypeItems = menu.renderItems(
view.manager.viewMetas.map<MenuConfig>(meta => {
return menu => {
if (!menu.search(meta.model.defaultName)) {
return;
}
const isSelected =
meta.type === view.manager.currentView$.value?.type;
const iconStyle = styleMap({
fontSize: '24px',
color: isSelected
? 'var(--affine-text-emphasis-color)'
: 'var(--affine-icon-secondary)',
});
const textStyle = styleMap({
fontSize: '14px',
lineHeight: '22px',
color: isSelected
? 'var(--affine-text-emphasis-color)'
: 'var(--affine-text-secondary-color)',
});
const buttonData: MenuButtonData = {
content: () => html`
<div
style="width:100%;display: flex;flex-direction: column;align-items: center;justify-content: center;padding: 6px 16px;white-space: nowrap"
>
<div style="${iconStyle}">
${renderUniLit(meta.renderer.icon)}
</div>
<div style="${textStyle}">${meta.model.defaultName}</div>
</div>
`,
select: () => {
const id = view.manager.currentViewId$.value;
if (!id || meta.type === view.type) {
return;
}
view.manager.viewChangeType(id, meta.type);
dataViewLogic.clearSelection();
},
class: {},
};
const containerStyle = styleMap({
flex: '1',
});
return html`<affine-menu-button
style="${containerStyle}"
.data="${buttonData}"
.menu="${menu}"
></affine-menu-button>`;
};
})
);
if (!viewTypeItems.length) {
return html``;
}
return html`
<div style="display:flex;align-items:center;gap:8px;padding:0 2px;">
<div
style="display:flex;align-items:center;color:var(--affine-icon-color);"
>
${LayoutIcon()}
</div>
<div
style="font-size:14px;line-height:22px;color:var(--affine-text-secondary-color);"
>
Layout
</div>
</div>
<div style="display:flex;gap:8px;margin-top:8px;">
${viewTypeItems}
</div>
`;
},
],
})
);
items.push(
menu.group({
items: createSettingMenus(target, dataViewLogic, reopen, () =>
handler.close()
),
})
);
items.push(
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;
};
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: [
menu.action({
name: 'Duplicate',
prefix: DuplicateIcon(),
closeOnSelect: false,
select: () => {
view.duplicate();
},
}),
menu.action({
name: 'Delete',
prefix: DeleteIcon(),
closeOnSelect: false,
select: () => {
view.delete();
},
class: { 'delete-item': true },
}),
() =>
html`<data-view-properties-setting
.view="${view}"
></data-view-properties-setting>`,
],
})
);
handler = popMenu(target, {
}),
];
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[] = [];
items.push(
menu.input({
initialValue: view.name$.value,
placeholder: 'View name',
disableAutoFocus: true,
onChange: text => {
view.nameSet(text);
},
})
);
items.push(
menu.group({
items: [
menuObj => {
const viewTypeItems = menuObj.renderItems(
view.manager.viewMetas.map<MenuConfig>(meta => {
return menuObj => {
if (!menuObj.search(meta.model.defaultName)) {
return;
}
const isSelected =
meta.type === view.manager.currentView$.value?.type;
const iconStyle = styleMap({
fontSize: '24px',
color: isSelected
? 'var(--affine-text-emphasis-color)'
: 'var(--affine-icon-secondary)',
});
const textStyle = styleMap({
fontSize: '14px',
lineHeight: '22px',
color: isSelected
? 'var(--affine-text-emphasis-color)'
: 'var(--affine-text-secondary-color)',
});
const buttonData: MenuButtonData = {
content: () => html`
<div
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}">
${renderUniLit(meta.renderer.icon)}
</div>
<div style="${textStyle}">
${meta.model.defaultName}
</div>
</div>
`,
select: () => {
const id = view.manager.currentViewId$.value;
if (!id || meta.type === view.type) {
return;
}
view.manager.viewChangeType(id, meta.type);
dataViewLogic.clearSelection();
},
class: {},
};
const containerStyle = styleMap({
flex: '1',
});
return html`<affine-menu-button
style="${containerStyle}"
.data="${buttonData}"
.menu="${menuObj}"
></affine-menu-button>`;
};
})
);
if (!viewTypeItems.length) {
return html``;
}
return html`
<div
style="display:flex;align-items:center;gap:8px;padding:0 2px;"
>
<div
style="display:flex;align-items:center;color:var(--affine-icon-color);"
>
${LayoutIcon()}
</div>
<div
style="font-size:14px;line-height:22px;color:var(--affine-text-secondary-color);"
>
Layout
</div>
</div>
<div style="display:flex;gap:4px;margin-top:8px;">
${viewTypeItems}
</div>
`;
},
],
})
);
const settingItems: MenuConfig[] = [];
settingItems.push(
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(
menu.group({
items: [
menu.action({
name: 'Duplicate view',
prefix: DuplicateIcon(),
closeOnSelect: false,
select: () => {
view.duplicate();
},
}),
menu.action({
name: 'Delete view',
prefix: DeleteIcon(),
closeOnSelect: false,
select: () => {
view.delete();
},
class: { 'delete-item': true },
}),
],
})
);
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: {
title: {
text: 'View settings',
onClose: () => handler.close(),
},
items,
onClose: onClose,
title: titleConfig,
items: [menu.dynamic(getPageItems)],
onClose,
},
middleware: [
autoPlacement({ allowedPlacements: ['bottom-start'] }),
@@ -475,6 +581,23 @@ export const popViewOptions = (
shift({ crossAxis: true }),
],
});
handler.menu.menuElement.style.minHeight = '550px';
return handler;
if (isDesktopMenu()) {
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;
};