diff --git a/blocksuite/affine/blocks/data-view/src/data-view-block.ts b/blocksuite/affine/blocks/data-view/src/data-view-block.ts index 36bd5e0a86cae..9f86a725eef30 100644 --- a/blocksuite/affine/blocks/data-view/src/data-view-block.ts +++ b/blocksuite/affine/blocks/data-view/src/data-view-block.ts @@ -254,6 +254,7 @@ export class DataViewBlockComponent extends CaptionedBlockComponent { const notification = this.std.getOptional(NotificationProvider); diff --git a/blocksuite/affine/blocks/database/src/configs/slash-menu.ts b/blocksuite/affine/blocks/database/src/configs/slash-menu.ts index 887053a39fde6..b28181f2440e5 100644 --- a/blocksuite/affine/blocks/database/src/configs/slash-menu.ts +++ b/blocksuite/affine/blocks/database/src/configs/slash-menu.ts @@ -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.', diff --git a/blocksuite/affine/blocks/database/src/database-block.ts b/blocksuite/affine/blocks/database/src/database-block.ts index 952a76e3944a7..fa2afa9927ea0 100644 --- a/blocksuite/affine/blocks/database/src/database-block.ts +++ b/blocksuite/affine/blocks/database/src/database-block.ts @@ -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 { + 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 { @@ -427,6 +442,7 @@ export class DatabaseBlockComponent extends CaptionedBlockComponent { const notification = this.std.getOptional(NotificationProvider); diff --git a/blocksuite/affine/blocks/database/src/views/index.ts b/blocksuite/affine/blocks/database/src/views/index.ts index 21b24fa95edd9..5fcf48121f851 100644 --- a/blocksuite/affine/blocks/database/src/views/index.ts +++ b/blocksuite/affine/blocks/database/src/views/index.ts @@ -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( diff --git a/blocksuite/affine/components/src/context-menu/input.ts b/blocksuite/affine/components/src/context-menu/input.ts index 6c86c69f85c80..68c2bf36c251a 100644 --- a/blocksuite/affine/components/src/context-menu/input.ts +++ b/blocksuite/affine/components/src/context-menu/input.ts @@ -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; }) => @@ -237,6 +240,7 @@ export const menuInputItems = { onComplete: config.onComplete, onChange: config.onChange, onBlur: config.onBlur, + disableAutoFocus: config.disableAutoFocus, }; const style = styleMap({ display: 'flex', diff --git a/blocksuite/affine/components/src/context-menu/menu-renderer.ts b/blocksuite/affine/components/src/context-menu/menu-renderer.ts index 84ba4bbb93f08..de4ffbffe3724 100644 --- a/blocksuite/affine/components/src/context-menu/menu-renderer.ts +++ b/blocksuite/affine/components/src/context-menu/menu-renderer.ts @@ -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`
void; title?: { text: string; - onBack?: (menu: Menu) => void; + onBack?: (menu: Menu) => boolean | void; onClose?: () => void; postfix?: () => TemplateResult; }; diff --git a/blocksuite/affine/data-view/src/__tests__/calendar-layout.unit.spec.ts b/blocksuite/affine/data-view/src/__tests__/calendar-layout.unit.spec.ts new file mode 100644 index 0000000000000..0700e5f96ba33 --- /dev/null +++ b/blocksuite/affine/data-view/src/__tests__/calendar-layout.unit.spec.ts @@ -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); + }); +}); diff --git a/blocksuite/affine/data-view/src/__tests__/calendar.unit.spec.ts b/blocksuite/affine/data-view/src/__tests__/calendar.unit.spec.ts new file mode 100644 index 0000000000000..a02dd65da691b --- /dev/null +++ b/blocksuite/affine/data-view/src/__tests__/calendar.unit.spec.ts @@ -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; + visiblePropertyIds?: string[]; + externalFactories?: Map; +}) => { + const rows = signal(options?.rows ?? ['row-1']); + const columns = signal(['title', 'date', 'end-date', 'status']); + const viewData = signal({ + 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([ + ['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([ + ['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 + ) => { + 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' }); + }); +}); diff --git a/blocksuite/affine/data-view/src/core/data-view.ts b/blocksuite/affine/data-view/src/core/data-view.ts index 3e601c96b19f7..389d7efaa5e09 100644 --- a/blocksuite/affine/data-view/src/core/data-view.ts +++ b/blocksuite/affine/data-view/src/core/data-view.ts @@ -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; diff --git a/blocksuite/affine/data-view/src/core/group-by/setting.ts b/blocksuite/affine/data-view/src/core/group-by/setting.ts index e6615b4e642da..524af08fa64a4 100644 --- a/blocksuite/affine/data-view/src/core/group-by/setting.ts +++ b/blocksuite/affine/data-view/src/core/group-by/setting.ts @@ -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(property => { - return menu.action({ - name: property.name$.value, - isSelected: group.property$.value?.id === property.id, - prefix: html` `, - 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(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``, select: () => { - group.changeGroup(undefined); - ops?.onSelect?.(); + group.changeGroup(property.id); + onSelect(property.id); + return false; }, - }), - ], - }), - ], - }; -}; - -export const popSelectGroupByProperty = ( - target: PopupTarget, - group: GroupTrait, - ops?: { onSelect?: () => void; onClose?: () => void; onBack?: () => void }, - middleware?: Array -) => { - const handler = popMenu(target, { - options: selectGroupByProperty(group, ops), - middleware, - }); - handler.menu.menuElement.style.minHeight = '550px'; + }) + ), + }), + 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 popGroupSetting = ( - target: PopupTarget, +export const buildGroupSettingItems = ( group: GroupTrait, - onBack: () => void, - onClose?: () => void, - middleware?: Array -) => { + 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, - }, + + return [ + menu.group({ items: [ - menu.group({ - items: [ - menu.action({ - name: 'Group By', - postfix: html` -
- ${renderUniLit(icon, {})} ${gProp.name$.value} -
- `, - 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'; - }, - }), - ], + menu.action({ + name: 'Group By', + postfix: html` +
+ ${renderUniLit(icon, {})} ${gProp.name$.value} +
+ `, + select: () => { + onGroupByClick(); + return false; + }, }), + ], + }), - ...(type === 'date' - ? [ - menu.group({ - items: [ - menu.dynamic(() => [ - menu.subMenu({ - name: 'Date by', - openOnHover: false, - middleware: dropdownSubMenuMiddleware, - autoHeight: true, - postfix: html` -
- ${dateModeLabel(group.groupInfo$.value?.config.name)} -
- `, - options: { - items: [ - menu.dynamic(() => - ( - [ - ['Relative', 'date-relative'], - ['Day', 'date-day'], + ...(type === 'date' + ? [ + menu.group({ + items: [ + menu.dynamic(() => [ + menu.subMenu({ + name: 'Date by', + openOnHover: false, + middleware: dropdownSubMenuMiddleware, + autoHeight: true, + postfix: html` +
+ ${dateModeLabel(group.groupInfo$.value?.config.name)} +
+ `, + 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`${label}`; + }, + 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` +
+ ${group.groupInfo$.value?.config.name === + 'date-week-mon' + ? 'Monday' + : 'Sunday'} +
+ `, + 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; }, }) - ) - ), - ], - }, - }), - ]), - ], - }), - - ...(group.groupInfo$.value?.config.name?.startsWith('date-week') - ? [ - menu.group({ - items: [ - menu.dynamic(() => [ - menu.subMenu({ - name: 'Start week on', - postfix: html` -
- ${group.groupInfo$.value?.config.name === - 'date-week-mon' - ? 'Monday' - : 'Sunday'} -
- `, - 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`${label}`; - }, - 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` -
- ${group.sortAsc$.value - ? 'Oldest first' - : 'Newest first'} -
- `, - options: { - items: [ - menu.dynamic(() => [ - menu.action({ - name: 'Oldest first', - label: () => { - const isSelected = group.sortAsc$.value; - return html`Oldest first`; - }, - isSelected: group.sortAsc$.value, - select: () => { - group.setDateSortOrder(true); - return false; - }, - }), - menu.action({ - name: 'Newest first', - label: () => { - const isSelected = !group.sortAsc$.value; - return html`Newest first`; - }, - isSelected: !group.sortAsc$.value, - select: () => { - group.setDateSortOrder(false); - return false; - }, - }), - ]), - ], - }, - }), - ]), - ], - }), - ] - : []), + ) + ), + ], + }, + }), + ]), + ], + }), + ] + : []), + menu.group({ + items: [ + menu.dynamic(() => [ + menu.subMenu({ + name: 'Sort', + openOnHover: false, + middleware: dropdownSubMenuMiddleware, + autoHeight: true, + postfix: html` +
+ ${group.sortAsc$.value ? 'Oldest first' : 'Newest first'} +
+ `, + options: { + items: [ + menu.dynamic(() => [ + menu.action({ + name: 'Oldest first', + label: () => { + const isSelected = group.sortAsc$.value; + return html`Oldest first`; + }, + isSelected: group.sortAsc$.value, + select: () => { + group.setDateSortOrder(true); + return false; + }, + }), + menu.action({ + name: 'Newest first', + label: () => { + const isSelected = !group.sortAsc$.value; + return html`Newest first`; + }, + 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: [ - menu => html` - menu.closeSubMenu()} - .groupTrait=${group} - .columnId=${gProp.id} - > - `, - ], - }), + 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` + menuObj.closeSubMenu()} + .groupTrait=${group} + .columnId=${gProp.id} + > + `, + ], + }), - 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'; + }), + ]; }; diff --git a/blocksuite/affine/data-view/src/view-presets/calendar/calendar-view-manager.ts b/blocksuite/affine/data-view/src/view-presets/calendar/calendar-view-manager.ts new file mode 100644 index 0000000000000..a7280618754a7 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/calendar/calendar-view-manager.ts @@ -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(); + new Doc().getMap('root').set('text', text.yText); + text.applyDelta([ + { + insert: ' ', + attributes: { reference: { type: 'LinkedPage', pageId: docId } }, + }, + ] satisfies DeltaInsert[]); + 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( + (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 { + private readonly externalEntries$ = signal([]); + + 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 = 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 = 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(() => { + 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(() => { + return [...this.rowEntries$.value, ...this.externalEntries$.value]; + }); + + externalSources$ = computed(() => { + 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); + } +} diff --git a/blocksuite/affine/data-view/src/view-presets/calendar/define.ts b/blocksuite/affine/data-view/src/view-presets/calendar/define.ts new file mode 100644 index 0000000000000..52b3f2a6afaff --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/calendar/define.ts @@ -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( + { + 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: {}, + }; + }, + } +); diff --git a/blocksuite/affine/data-view/src/view-presets/calendar/effect.ts b/blocksuite/affine/data-view/src/view-presets/calendar/effect.ts new file mode 100644 index 0000000000000..30288f9fda683 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/calendar/effect.ts @@ -0,0 +1,5 @@ +import { pcEffects } from './pc/effect.js'; + +export function calendarEffects() { + pcEffects(); +} diff --git a/blocksuite/affine/data-view/src/view-presets/calendar/index.ts b/blocksuite/affine/data-view/src/view-presets/calendar/index.ts new file mode 100644 index 0000000000000..3c8589df9acad --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/calendar/index.ts @@ -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'; diff --git a/blocksuite/affine/data-view/src/view-presets/calendar/layout.ts b/blocksuite/affine/data-view/src/view-presets/calendar/layout.ts new file mode 100644 index 0000000000000..63ce6a0e5d2f3 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/calendar/layout.ts @@ -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(); + + 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 }; +}; diff --git a/blocksuite/affine/data-view/src/view-presets/calendar/pc/actions.ts b/blocksuite/affine/data-view/src/view-presets/calendar/pc/actions.ts new file mode 100644 index 0000000000000..50b876c998f0f --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/calendar/pc/actions.ts @@ -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` +
+
${entry.title}
+
+ ${CalendarPanelIcon()} + ${entry.calendarName ?? 'Calendar event'} +
+
+ ${DateTimeIcon()} + ${formatEntryTime(entry)} +
+ ${entry.location + ? html`
+ ${PinIcon()} + ${entry.location} +
` + : ''} + ${entry.description + ? html`
+ ${TextIcon()} + ${entry.description} +
` + : ''} +
+ `, + ], + }, + }); +}; diff --git a/blocksuite/affine/data-view/src/view-presets/calendar/pc/dnd.ts b/blocksuite/affine/data-view/src/view-presets/calendar/pc/dnd.ts new file mode 100644 index 0000000000000..21edb6743c7b9 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/calendar/pc/dnd.ts @@ -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 => + 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(); + + 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({ + 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({ + 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); + } + } +} diff --git a/blocksuite/affine/data-view/src/view-presets/calendar/pc/effect.ts b/blocksuite/affine/data-view/src/view-presets/calendar/pc/effect.ts new file mode 100644 index 0000000000000..5c1275ff30c57 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/calendar/pc/effect.ts @@ -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); +} diff --git a/blocksuite/affine/data-view/src/view-presets/calendar/pc/hit-test.ts b/blocksuite/affine/data-view/src/view-presets/calendar/pc/hit-test.ts new file mode 100644 index 0000000000000..ced6c880b4725 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/calendar/pc/hit-test.ts @@ -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('.calendar-day[data-date]'); + if (day && root.contains(day)) { + return Number(day.dataset['date']); + } + } + + for (const element of hitStack) { + const week = + element.closest('.calendar-week') ?? + element.closest('.calendar-segments')?.parentElement; + if (week && root.contains(week)) { + const days = week.querySelectorAll('.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; +}; diff --git a/blocksuite/affine/data-view/src/view-presets/calendar/pc/styles.ts b/blocksuite/affine/data-view/src/view-presets/calendar/pc/styles.ts new file mode 100644 index 0000000000000..48fc757015c09 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/calendar/pc/styles.ts @@ -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; + } +`; diff --git a/blocksuite/affine/data-view/src/view-presets/calendar/pc/view.ts b/blocksuite/affine/data-view/src/view-presets/calendar/pc/view.ts new file mode 100644 index 0000000000000..79cadcf128bf8 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/calendar/pc/view.ts @@ -0,0 +1,1099 @@ +import { + menu, + type MenuConfig, + popFilterableSimpleMenu, + popupTargetFromElement, +} from '@blocksuite/affine-components/context-menu'; +import type { InsertToPosition } from '@blocksuite/affine-shared/utils'; +import { + ArrowLeftSmallIcon, + ArrowRightSmallIcon, + CloseIcon, + DateTimeIcon, + IntegrationsIcon, + LinkedPageIcon, + PlusIcon, + TodayIcon, +} from '@blocksuite/icons/lit'; +import { html, nothing, type TemplateResult } from 'lit'; +import { ref } from 'lit/directives/ref.js'; +import { repeat } from 'lit/directives/repeat.js'; + +import { + createUniComponentFromWebComponent, + renderUniLit, +} from '../../../core/index.js'; +import { + DataViewUIBase, + DataViewUILogicBase, +} from '../../../core/view/data-view-base.js'; +import type { CalendarSingleView } from '../calendar-view-manager.js'; +import type { CalendarDayLayout, CalendarRangeSegment } from '../layout.js'; +import { + createCalendarMonthLayout, + getCalendarDayContentSlots, + getCalendarDaySegmentSlots, + getCalendarVisibleMonthRange, +} from '../layout.js'; +import type { CalendarEntry, CalendarRowEntry } from '../types.js'; +import { openCalendarEntry } from './actions.js'; +import { CalendarDnd, type CalendarDndEntity } from './dnd.js'; +import { getCalendarDateFromPoint } from './hit-test.js'; +import { calendarViewStyles } from './styles.js'; + +const dateFormatter = new Intl.DateTimeFormat(undefined, { day: 'numeric' }); +const monthFormatter = new Intl.DateTimeFormat(undefined, { + month: 'long', + year: 'numeric', +}); +const weekdayFormatter = new Intl.DateTimeFormat(undefined, { + weekday: 'short', +}); + +const startOfDay = (time: number | Date) => { + const date = time instanceof Date ? time : new Date(time); + return new Date( + date.getFullYear(), + date.getMonth(), + date.getDate() + ).getTime(); +}; + +const getDefaultCreateDate = (month: number) => { + const today = startOfDay(Date.now()); + const monthDate = new Date(month); + const todayDate = new Date(today); + if ( + monthDate.getFullYear() === todayDate.getFullYear() && + monthDate.getMonth() === todayDate.getMonth() + ) { + return today; + } + return new Date(monthDate.getFullYear(), monthDate.getMonth(), 1).getTime(); +}; + +type CalendarInteractionState = + | { type: 'drag'; entry: CalendarRowEntry; targetDay?: number } + | { type: 'doc'; docId: string; targetDay?: number } + | { + type: 'resize'; + entry: CalendarRowEntry; + edge: 'start' | 'end'; + targetDay?: number; + }; + +export class CalendarViewUILogic extends DataViewUILogicBase { + private ui?: CalendarViewUI; + + private readonly dnd = new CalendarDnd(this.root.config.dnd, { + getEntry: entryId => + this.view.entries$.value.find(entry => entry.id === entryId), + canDragEntry: () => !this.view.readonly$.value, + canDrop: entity => this.canDropDndEntity(entity), + onEntryDragStart: entry => { + this.interactionState = { type: 'drag', entry }; + this.ui?.requestUpdate(); + }, + onEntryDragEnd: () => { + this.endInteraction(); + }, + onDropTargetChange: (day, entity) => { + this.setInteractionTarget(day, entity); + }, + onDrop: (entity, date) => { + this.dropDndEntity(entity, date); + }, + }); + + selectedEntryId: string | undefined; + + interactionState: CalendarInteractionState | undefined; + + private suppressNextClick = false; + + private cleanupResize?: () => void; + + getPreviewRange(): { start: number; end: number } | undefined { + const state = this.interactionState; + const target = state?.targetDay; + if (!state || target === undefined) return undefined; + + if (state.type === 'doc') { + return { start: target, end: target }; + } + + if (state.type === 'drag') { + const entry = state.entry; + const duration = + entry.endAt !== undefined ? entry.endAt - entry.startAt : 0; + return { start: target, end: startOfDay(target + duration) }; + } + + const entry = state.entry; + const entryStart = startOfDay(entry.startAt); + const entryEnd = + entry.endAt !== undefined ? startOfDay(entry.endAt) : entryStart; + if (state.edge === 'start') { + return { + start: Math.min(target, entryEnd), + end: Math.max(target, entryEnd), + }; + } + return { + start: Math.min(entryStart, target), + end: Math.max(entryStart, target), + }; + } + + isDayInPreview(day: number): boolean { + const range = this.getPreviewRange(); + if (!range) return false; + return day >= range.start && day <= range.end; + } + + isEntryBeingMoved(entryId: string): boolean { + const state = this.interactionState; + return ( + state !== undefined && state.type !== 'doc' && state.entry.id === entryId + ); + } + + currentMonth = startOfDay(Date.now()); + + clearSelection = () => { + this.setSelection(undefined); + }; + + addRow = (position: InsertToPosition) => { + if (this.view.readonly$.value) return; + const rowId = this.view.rowAdd(position); + if (rowId) { + this.root.openDetailPanel({ + view: this.view, + rowId, + }); + } + return rowId; + }; + + focusFirstCell = () => {}; + + showIndicator = () => false; + + hideIndicator = () => {}; + + moveTo = () => {}; + + renderer = createUniComponentFromWebComponent(CalendarViewUI); + + attach(ui: CalendarViewUI) { + this.ui = ui; + this.loadExternalEntries(); + } + + detach(ui: CalendarViewUI) { + if (this.ui !== ui) return; + this.cleanupResizeInteraction(); + this.dnd.cleanup(); + this.endInteraction(); + this.ui = undefined; + } + + moveMonth(offset: number) { + const date = new Date(this.currentMonth); + this.currentMonth = startOfDay( + new Date(date.getFullYear(), date.getMonth() + offset, 1) + ); + this.ui?.requestUpdate(); + this.loadExternalEntries(); + } + + goToday() { + this.currentMonth = startOfDay(Date.now()); + this.ui?.requestUpdate(); + this.loadExternalEntries(); + } + + isCurrentMonth() { + const cursor = new Date(this.currentMonth); + const today = new Date(); + return ( + cursor.getFullYear() === today.getFullYear() && + cursor.getMonth() === today.getMonth() + ); + } + + createRowOnDate(date: number) { + if (this.view.readonly$.value) return; + const rowId = this.view.createRowOnDate(date); + if (rowId) { + this.root.openDetailPanel({ + view: this.view, + rowId, + }); + } + } + + openSetupMenu(target: HTMLElement) { + const items = this.view.dateProperties$.value.map(property => + menu.action({ + name: property.name$.value || 'Date', + select: () => { + this.view.setDateColumn(property.id); + }, + }) + ); + if (!this.view.readonly$.value) { + items.push( + menu.action({ + name: 'Create date property', + select: () => { + this.view.createDateColumn(); + }, + }) + ); + } + popFilterableSimpleMenu(popupTargetFromElement(target), items); + } + + private getWorkspaceCalendarConfig() { + return ( + this.view.data$.value?.sources.workspaceCalendar ?? { + enabled: true, + } + ); + } + + private createSourceControlItems(): MenuConfig[] { + const workspaceCalendar = this.getWorkspaceCalendarConfig(); + const selectedIds = workspaceCalendar.subscriptionIds + ? new Set(workspaceCalendar.subscriptionIds) + : undefined; + const hasSubscriptionOptions = this.view.externalSources$.value.some( + source => (source.getSubscriptionOptions?.().length ?? 0) > 0 + ); + const subscriptionItems = this.view.externalSources$.value.flatMap( + source => + source.getSubscriptionOptions?.().map(subscription => + menu.action({ + name: `${selectedIds && !selectedIds.has(subscription.id) ? 'Show' : 'Hide'} ${subscription.name}`, + closeOnSelect: false, + select: () => { + const allIds = source + .getSubscriptionOptions?.() + .map(subscription => subscription.id); + if (!allIds?.length) { + return; + } + const next = new Set(selectedIds ?? allIds); + if (next.has(subscription.id)) { + next.delete(subscription.id); + } else { + next.add(subscription.id); + } + this.view.setWorkspaceCalendarSubscriptionIds([...next]); + this.loadExternalEntries(); + }, + }) + ) ?? [] + ); + const connectItems = this.view.externalSources$.value.flatMap(source => + source.openConnectSettings + ? [ + menu.action({ + name: 'Connect calendar', + closeOnSelect: false, + select: () => { + source.openConnectSettings?.(); + }, + }), + ] + : [] + ); + const toggleItems = hasSubscriptionOptions + ? [ + menu.action({ + name: workspaceCalendar.enabled + ? 'Hide workspace calendar' + : 'Show workspace calendar', + closeOnSelect: false, + select: () => { + this.view.setWorkspaceCalendarEnabled(!workspaceCalendar.enabled); + this.loadExternalEntries(); + }, + }), + menu.action({ + name: 'Show all workspace calendars', + closeOnSelect: false, + select: () => { + this.view.setWorkspaceCalendarSubscriptionIds(undefined); + this.loadExternalEntries(); + }, + }), + ] + : []; + return [...toggleItems, ...subscriptionItems, ...connectItems]; + } + + openSourceMenu(target: HTMLElement) { + popFilterableSimpleMenu( + popupTargetFromElement(target), + this.createSourceControlItems() + ); + } + + private getDatePropertyMenuItems( + selectedPropertyId: string | undefined, + onSelect: (propertyId: string | undefined) => void, + create?: () => void, + options?: { + includeNone?: boolean; + createLabel?: string; + closeOnSelect?: boolean; + } + ): MenuConfig[] { + const closeOnSelect = options?.closeOnSelect; + const items: MenuConfig[] = []; + if (options?.includeNone) { + items.push( + menu.action({ + name: 'None', + isSelected: !selectedPropertyId, + closeOnSelect, + select: () => onSelect(undefined), + }) + ); + } + items.push( + ...this.view.dateProperties$.value.map(property => + menu.action({ + name: property.name$.value || 'Date', + isSelected: property.id === selectedPropertyId, + closeOnSelect, + select: () => onSelect(property.id), + }) + ) + ); + if (!this.view.readonly$.value && create) { + items.push( + menu.action({ + name: options?.createLabel ?? 'Create date property', + closeOnSelect, + select: create, + }) + ); + } + return items; + } + + getViewOptionsSettingItems( + navigateToSubPage: (title: string, getItems: () => MenuConfig[]) => void, + goBack: () => void + ): MenuConfig[] { + const selectedStart = this.view.startDateMapping$.value.propertyId; + const selectedEnd = this.view.endDateMapping$.value.propertyId; + return [ + menu.group({ + name: 'Date range', + items: [ + menu.action({ + name: 'Calendar by', + prefix: TodayIcon(), + closeOnSelect: false, + postfix: html`
+ ${this.view.startDateMapping$.value.status === 'ready' + ? this.view.propertyGetOrCreate(selectedStart ?? '').name$ + .value + : ''} +
+ ${ArrowRightSmallIcon()}`, + select: () => { + navigateToSubPage('Calendar by', () => + this.getDatePropertyMenuItems( + this.view.startDateMapping$.value.propertyId, + propertyId => { + if (propertyId) { + this.view.setStartDateColumn(propertyId); + goBack(); + } + }, + () => { + this.view.createStartDateColumn(); + goBack(); + }, + { closeOnSelect: false } + ) + ); + }, + }), + menu.action({ + name: 'End date', + prefix: DateTimeIcon(), + closeOnSelect: false, + postfix: html`
+ ${selectedEnd + ? this.view.propertyGetOrCreate(selectedEnd).name$.value + : 'None'} +
+ ${ArrowRightSmallIcon()}`, + select: () => { + navigateToSubPage('End date', () => + this.getDatePropertyMenuItems( + this.view.endDateMapping$.value.propertyId, + propertyId => { + this.view.setEndDateColumn(propertyId); + goBack(); + }, + () => { + this.view.createEndDateColumn(); + goBack(); + }, + { + includeNone: true, + createLabel: 'Create end date property', + closeOnSelect: false, + } + ) + ); + }, + }), + menu.action({ + name: 'External calendars', + prefix: IntegrationsIcon(), + closeOnSelect: false, + postfix: html`${ArrowRightSmallIcon()}`, + select: () => { + navigateToSubPage('External calendars', () => + this.createSourceControlItems() + ); + }, + }), + ], + }), + ]; + } + + openEntry(entry: CalendarEntry, target: HTMLElement) { + openCalendarEntry(this.root, this.view, entry, target, { + selectEntry: entryId => { + this.selectedEntryId = entryId; + this.ui?.requestUpdate(); + }, + }); + } + + get isInteracting() { + return this.interactionState !== undefined; + } + + private setInteractionTarget( + day: number | undefined, + entity?: CalendarDndEntity + ) { + if (day !== undefined && entity?.type === 'doc') { + const current = this.interactionState; + if ( + current?.type === 'doc' && + current.docId === entity.docId && + current.targetDay === day + ) { + return; + } + this.interactionState = { + type: 'doc', + docId: entity.docId, + targetDay: day, + }; + this.ui?.requestUpdate(); + return; + } + + if (this.interactionState?.type === 'doc') { + this.interactionState = undefined; + this.ui?.requestUpdate(); + return; + } + + if (this.interactionState?.targetDay !== day) { + if (this.interactionState) { + this.interactionState = { + ...this.interactionState, + targetDay: day, + }; + } + this.ui?.requestUpdate(); + } + } + + private endInteraction() { + this.interactionState = undefined; + this.ui?.requestUpdate(); + } + + bindCalendarDropTarget(element?: Element) { + this.dnd.bindRoot(element); + } + + bindEntryDraggable(key: string, entry: CalendarEntry, element?: Element) { + this.dnd.bindEntry(key, entry, element, this.view.readonly$.value); + } + + private canDropDndEntity(entity: CalendarDndEntity) { + if (this.view.readonly$.value) { + return false; + } + if (entity.type === 'calendar-entry') { + const entry = this.view.entries$.value.find( + entry => entry.id === entity.entryId + ); + return entry?.kind === 'row'; + } + if (entity.type === 'doc') { + return this.view.startDateMapping$.value.status === 'ready'; + } + return false; + } + + private dropDndEntity(entity: CalendarDndEntity, day: number) { + try { + if (entity.type === 'calendar-entry') { + const entry = this.view.entries$.value.find( + entry => entry.id === entity.entryId + ); + if (entry?.kind === 'row') { + this.view.moveRowToDate(entry.rowId, day); + } + } + if (entity.type === 'doc') { + this.view.createLinkedDocRowOnDate(day, entity.docId); + } + } finally { + this.endInteraction(); + } + } + + startResize( + entry: CalendarEntry, + edge: 'start' | 'end', + event: PointerEvent + ) { + if (entry.kind !== 'row' || !entry.canResizeRange) { + return; + } + event.preventDefault(); + event.stopPropagation(); + this.cleanupResizeInteraction(); + + this.interactionState = { + type: 'resize', + entry: entry as CalendarRowEntry, + edge, + }; + this.ui?.requestUpdate(); + const root = (event.currentTarget as HTMLElement).closest( + '.calendar-grid' + ); + const doc = (event.currentTarget as HTMLElement).ownerDocument; + + const dayFromPointer = (pointerEvent: PointerEvent) => { + if (!root) { + return; + } + return getCalendarDateFromPoint( + root, + pointerEvent.clientX, + pointerEvent.clientY + ); + }; + + const onPointerMove = (pointerEvent: PointerEvent) => { + this.setInteractionTarget(dayFromPointer(pointerEvent)); + }; + + const onPointerUp = (pointerEvent: PointerEvent) => { + cleanup(); + + const date = dayFromPointer(pointerEvent); + if (date !== undefined) { + this.view.resizeRowRange(entry.rowId, edge, date); + } + + this.endInteraction(); + + // Suppress the click event that the browser fires after pointerup + // when the pointer is released on top of the entry element. + this.suppressNextClick = true; + requestAnimationFrame(() => { + this.suppressNextClick = false; + }); + }; + + const cleanup = () => { + doc.removeEventListener('pointermove', onPointerMove); + doc.removeEventListener('pointerup', onPointerUp); + if (this.cleanupResize === cleanup) { + this.cleanupResize = undefined; + } + }; + + this.cleanupResize = cleanup; + doc.addEventListener('pointermove', onPointerMove); + doc.addEventListener('pointerup', onPointerUp); + } + + private cleanupResizeInteraction() { + this.cleanupResize?.(); + this.cleanupResize = undefined; + } + + handleEntryClick(entry: CalendarEntry, target: HTMLElement) { + if (this.suppressNextClick) { + return; + } + this.openEntry(entry, target); + } + + handleEntryKeydown(entry: CalendarEntry, event: KeyboardEvent) { + if (event.key !== 'Enter' && event.key !== ' ') { + return; + } + event.preventDefault(); + this.handleEntryClick(entry, event.currentTarget as HTMLElement); + } + + private loadExternalEntries() { + const range = getCalendarVisibleMonthRange(this.currentMonth); + this.view + .loadExternalEntries({ from: range.from, to: range.to }) + .catch(() => { + this.root.config.notification.toast('Failed to load calendar entries'); + }); + } +} + +export class CalendarViewUI extends DataViewUIBase { + static override styles = calendarViewStyles; + + override connectedCallback(): void { + super.connectedCallback(); + this.logic.attach(this); + this.dataset['testid'] = 'dv-calendar-view'; + } + + override disconnectedCallback(): void { + this.logic.detach(this); + super.disconnectedCallback(); + } + + private renderEntry( + entry: CalendarEntry, + extraStyle = '', + segment?: CalendarRangeSegment + ): TemplateResult { + const dndKey = segment + ? `${entry.id}:${segment.weekIndex}:${segment.startIndex}` + : `${entry.id}:day`; + const colorStyle = + entry.kind === 'external' && entry.color + ? `--calendar-external-color:${entry.color};` + : ''; + const segmentClass = segment + ? `${segment.startsBeforeWeek ? 'continues-left' : ''} ${ + segment.endsAfterWeek ? 'continues-right' : '' + }` + : ''; + const canResize = entry.kind === 'row' && entry.canResizeRange; + const showLeftHandle = canResize && segment && !segment.startsBeforeWeek; + const showRightHandle = canResize && segment && !segment.endsAfterWeek; + + return html` +
this.logic.bindEntryDraggable(dndKey, entry, element))} + class="calendar-entry ${entry.kind} ${segmentClass} ${this.logic + .selectedEntryId === entry.id + ? 'selected' + : ''}" + role="button" + tabindex="0" + aria-label=${entry.title || 'Untitled'} + style=${`${colorStyle}${extraStyle}`} + @click=${(event: MouseEvent) => { + this.logic.handleEntryClick( + entry, + event.currentTarget as HTMLElement + ); + }} + @keydown=${(event: KeyboardEvent) => { + this.logic.handleEntryKeydown(entry, event); + }} + > + ${showLeftHandle + ? html` + this.logic.startResize(entry, 'start', event)} + >` + : nothing} + ${this.renderEntryTitle(entry)} + ${entry.kind === 'row' && entry.cardProperties.length + ? html` + ${entry.cardProperties.map( + property => + html`${property.value}` + )} + ` + : nothing} + ${showRightHandle + ? html` + this.logic.startResize(entry, 'end', event)} + >` + : nothing} +
+ `; + } + + private renderEntryTitle(entry: CalendarEntry): TemplateResult { + if (entry.kind !== 'row') { + return html`${entry.title || 'Untitled'}`; + } + if (entry.titleSegments?.length) { + return html` + ${entry.titleSegments.map( + segment => + html` + ${segment.linkedDoc ? LinkedPageIcon() : nothing} + ${segment.text + ? html`${segment.text}` + : nothing} + ` + )} + `; + } + return html`${entry.title || 'Untitled'}`; + } + + private getMovingEntryId() { + const state = this.logic.interactionState; + return state?.type !== 'doc' ? state?.entry.id : undefined; + } + + private renderDayPreview(dayDate: number): TemplateResult | typeof nothing { + const range = this.logic.getPreviewRange(); + if (!range || range.start !== range.end) return nothing; + if (dayDate !== range.start) return nothing; + const state = this.logic.interactionState; + if (!state) return nothing; + if (state.type === 'doc') { + return html`
+ ${LinkedPageIcon()} + ${this.logic.view.getDocDisplayTitle(state.docId)} +
`; + } + const title = state.entry.title || 'Untitled'; + return html`
${title}
`; + } + + private getSegmentPreviewLayout(week: CalendarDayLayout[]) { + const range = this.logic.getPreviewRange(); + if (!range || range.start === range.end) return; + const state = this.logic.interactionState; + if (!state || state.type === 'doc') return; + + const weekStart = week[0]?.date; + const weekEnd = week[6]?.date; + if (weekStart === undefined || weekEnd === undefined) return; + if (range.start > weekEnd || range.end < weekStart) return; + + const segStart = Math.max(range.start, weekStart); + const segEnd = Math.min(range.end, weekEnd); + const sIdx = week.findIndex(d => d.date === segStart); + const eIdx = week.findIndex(d => d.date === segEnd); + if (sIdx < 0 || eIdx < 0) return; + + const slot = Math.max( + 0, + ...week + .slice(sIdx, eIdx + 1) + .map(day => getCalendarDayContentSlots(day, state.entry.id)) + ); + + return { + sIdx, + eIdx, + span: eIdx - sIdx + 1, + slot, + continuesLeft: range.start < weekStart, + continuesRight: range.end > weekEnd, + title: range.start < weekStart ? '' : state.entry.title || 'Untitled', + }; + } + + private renderPreviewSpacer( + day: CalendarDayLayout, + preview: + | { + sIdx: number; + eIdx: number; + slot: number; + } + | undefined, + dayIndex: number + ): TemplateResult | typeof nothing { + const movingEntryId = this.getMovingEntryId(); + if (!preview || dayIndex < preview.sIdx || dayIndex > preview.eIdx) { + return nothing; + } + const spacerSlots = + preview.slot + 1 - getCalendarDayContentSlots(day, movingEntryId); + if (spacerSlots <= 0) { + return nothing; + } + return html`
`; + } + + private renderSegmentPreview( + preview: ReturnType + ): TemplateResult | typeof nothing { + if (!preview) return nothing; + + return html`
+ ${preview.title} +
`; + } + + private renderEmptyMonthHint( + showHint: boolean + ): TemplateResult | typeof nothing { + if (!showHint) { + return nothing; + } + return html`
+
+ Nothing here yet + + Add a row to any date, it'll appear here on the calendar. + +
+
+ ${this.logic.view.readonly$.value + ? nothing + : html``} + +
+
`; + } + + private renderCalendar(skeleton = false) { + const entries = skeleton ? [] : this.logic.view.entries$.value; + const layout = createCalendarMonthLayout({ + month: this.logic.currentMonth, + entries, + }); + const weekdays = layout.weeks[0] ?? []; + const today = startOfDay(Date.now()); + const currentMonthEmpty = layout.days + .filter(day => day.inMonth) + .every(day => day.entries.length === 0 && day.segments.length === 0); + const showEmptyMonthHint = + currentMonthEmpty && + !skeleton && + !this.logic.view.emptyMonthHintDismissed$.value; + return html` +
+
+
+ ${monthFormatter.format(new Date(this.logic.currentMonth))} +
+
+ ${this.logic.isCurrentMonth() + ? nothing + : html``} + + +
+
+ ${this.renderEmptyMonthHint(showEmptyMonthHint)} +
+ ${repeat( + weekdays, + day => day.date, + day => + html`
+ ${weekdayFormatter.format(new Date(day.date))} +
` + )} +
+
this.logic.bindCalendarDropTarget(element))} + class="calendar-grid" + > + ${repeat( + layout.weeks, + week => week[0]?.date ?? 0, + week => { + const weekSegments = layout.segments.filter( + segment => segment.weekIndex === layout.weeks.indexOf(week) + ); + const preview = this.getSegmentPreviewLayout(week); + const movingEntryId = this.getMovingEntryId(); + return html` +
+ ${repeat( + week, + day => day.date, + (day, dayIndex) => { + const canReserveNewRow = + !this.logic.view.readonly$.value && day.inMonth; + return html` +
+
+ ${dateFormatter.format(new Date(day.date))} +
+
+ ${day.entries + .filter(e => !this.logic.isEntryBeingMoved(e.id)) + .map(entry => this.renderEntry(entry))} + ${this.renderDayPreview(day.date)} + ${this.renderPreviewSpacer(day, preview, dayIndex)} +
+ ${canReserveNewRow + ? html`` + : nothing} +
+ `; + } + )} +
+ ${weekSegments + .filter(s => !this.logic.isEntryBeingMoved(s.entry.id)) + .map(segment => + this.renderEntry( + segment.entry, + `grid-column:${segment.startIndex + 1} / span ${segment.span};grid-row:${segment.slot + 1};`, + segment + ) + )} + ${this.renderSegmentPreview(preview)} +
+
+ `; + } + )} +
+
+ `; + } + + override render(): TemplateResult { + const setup = this.logic.view.dateMapping$.value.status === 'setup'; + return html` + ${this.logic.headerWidget + ? renderUniLit(this.logic.headerWidget, { + dataViewLogic: this.logic, + }) + : nothing} +
+ ${this.renderCalendar(setup)} + ${setup + ? html`
+ +
` + : nothing} +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-data-view-calendar': CalendarViewUI; + } +} diff --git a/blocksuite/affine/data-view/src/view-presets/calendar/renderer.ts b/blocksuite/affine/data-view/src/view-presets/calendar/renderer.ts new file mode 100644 index 0000000000000..813c295486b49 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/calendar/renderer.ts @@ -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, +}); diff --git a/blocksuite/affine/data-view/src/view-presets/calendar/source.ts b/blocksuite/affine/data-view/src/view-presets/calendar/source.ts new file mode 100644 index 0000000000000..98542e3e655a6 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/calendar/source.ts @@ -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('calendar-external-source'); + +export const getCalendarExternalSources = ( + dataSource: DataSource, + viewData: CalendarStoredViewData +) => + Array.from( + dataSource.provider.getAll(CalendarExternalSourceProvider).values() + ).map(source => source.create(viewData)); diff --git a/blocksuite/affine/data-view/src/view-presets/calendar/types.ts b/blocksuite/affine/data-view/src/view-presets/calendar/types.ts new file mode 100644 index 0000000000000..cdfb5edb1b43a --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/calendar/types.ts @@ -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; +}; + +export type CalendarExternalSourceSubscription = { + id: string; + name: string; + color?: string; +}; diff --git a/blocksuite/affine/data-view/src/view-presets/convert.ts b/blocksuite/affine/data-view/src/view-presets/convert.ts index 21498a397d8be..5ce51d84fdc48 100644 --- a/blocksuite/affine/data-view/src/view-presets/convert.ts +++ b/blocksuite/affine/data-view/src/view-presets/convert.ts @@ -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), + })), ]; diff --git a/blocksuite/affine/data-view/src/view-presets/effect.ts b/blocksuite/affine/data-view/src/view-presets/effect.ts index 697accdde089a..b263a5af710a3 100644 --- a/blocksuite/affine/data-view/src/view-presets/effect.ts +++ b/blocksuite/affine/data-view/src/view-presets/effect.ts @@ -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(); } diff --git a/blocksuite/affine/data-view/src/view-presets/index.ts b/blocksuite/affine/data-view/src/view-presets/index.ts index b4b46ce58b80f..923d2d04fde82 100644 --- a/blocksuite/affine/data-view/src/view-presets/index.ts +++ b/blocksuite/affine/data-view/src/view-presets/index.ts @@ -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, }; diff --git a/blocksuite/affine/data-view/src/widget-presets/tools/presets/view-options/view-options.ts b/blocksuite/affine/data-view/src/widget-presets/tools/presets/view-options/view-options.ts index 79a81d642eac3..c0eba517e3852 100644 --- a/blocksuite/affine/data-view/src/widget-presets/tools/presets/view-options/view-options.ts +++ b/blocksuite/affine/data-view/src/widget-presets/tools/presets/view-options/view-options.ts @@ -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 = ( +type Page = + | 'main' + | 'properties' + | 'filter' + | 'sort' + | 'group' + | 'group-select' + | 'custom'; + +const pageTitles: Record, string> = { + main: 'View settings', + properties: 'Properties', + filter: 'Filter', + sort: 'Sort', + group: 'Group', + 'group-select': 'Group by', +}; + +export const popViewOptions = ( target: PopupTarget, dataViewLogic: DataViewUILogicBase, - reopen: () => void, - closeMenu: () => void + onClose?: () => void ) => { const view = dataViewLogic.view; - const settingItems: MenuConfig[] = []; - settingItems.push( - menu.action({ - name: 'Properties', - prefix: InfoIcon(), - closeOnSelect: false, - postfix: html`
- ${view.properties$.value.length} shown -
- ${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`
- ${filterCount === 0 - ? '' - : filterCount === 1 - ? '1 filter' - : `${filterCount} filters`} -
- ${ArrowRightSmallIcon()}`, - select: () => { - if (!filterTrait.filter$.value.conditions.length) { - popCreateFilter( - target, - { + + const currentPage = signal('main'); + const pageStack: Page[] = ['main']; + + let menuHandler!: ReturnType; + 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] ?? + '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`
+ ${isAllShowed ? 'Hide All' : 'Show All'} +
`; + }, + get onClose() { + return () => menuHandler?.menu.close(); + }, + }; + + const getPropertiesPageItems = (): MenuConfig[] => [ + menu.group({ + items: [ + () => + html``, + ], + }), + ]; + + const getFilterPageItems = (): MenuConfig[] => { + const filterTrait = view.traitGet(filterTraitKey); + if (!filterTrait) return getMainPageItems(); + return [ + menu.group({ + items: [ + () => + html``, + ], + }), + menu.group({ + items: [ + menu.action({ + name: 'Add', + prefix: PlusIcon(), + select: ele => { + const value = filterTrait.filter$.value; + popCreateFilter(popupTargetFromElement(ele), { vars: view.vars$, - onBack: reopen, - onClose: closeMenu, onSelect: filter => { filterTrait.filterSet({ - ...(filterTrait.filter$.value ?? emptyFilterGroup), - conditions: [ - ...filterTrait.filter$.value.conditions, - filter, - ], + ...value, + conditions: [...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 }), - ] - ); - } + }); + return false; + }, + }), + ], + }), + ]; + }; + + const getSortPageItems = (): MenuConfig[] => { + const sortTrait = view.traitGet(sortTraitKey); + if (!sortTrait) return getMainPageItems(); + const sortUtils = createSortUtils(sortTrait, dataViewLogic.eventTrace); + return [ + () => html``, + menu.action({ + name: 'Add sort', + prefix: PlusIcon(), + select: ele => { + popCreateSort(popupTargetFromElement(ele), { sortUtils }); + 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`
- ${sortCount === 0 - ? '' - : sortCount === 1 - ? '1 sort' - : `${sortCount} sorts`} -
- ${ArrowRightSmallIcon()}`, + name: 'Delete', + class: { 'delete-item': true }, + prefix: DeleteIcon(), 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 }), - ] - ); - } + 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 groupTrait = view.traitGet(groupTraitKey); - if (groupTrait) { - settingItems.push( - menu.action({ - name: 'Group', - prefix: GroupingIcon(), - closeOnSelect: false, - postfix: html`
- ${groupTrait.property$.value?.name$.value ?? ''} -
- ${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 }), - ]); - } + }; + + 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); }, }) ); - } - return settingItems; -}; -export const popViewOptions = ( - target: PopupTarget, - dataViewLogic: DataViewUILogicBase, - onClose?: () => void -) => { - const view = dataViewLogic.view; - const reopen = () => { - popViewOptions(target, dataViewLogic); - }; - let handler: ReturnType; - 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(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` -
-
- ${renderUniLit(meta.renderer.icon)} + + items.push( + menu.group({ + items: [ + menuObj => { + const viewTypeItems = menuObj.renderItems( + view.manager.viewMetas.map(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` +
+
+ ${renderUniLit(meta.renderer.icon)} +
+
+ ${meta.model.defaultName} +
-
${meta.model.defaultName}
-
- `, - select: () => { - const id = view.manager.currentViewId$.value; - if (!id || meta.type === view.type) { - return; - } - view.manager.viewChangeType(id, meta.type); - dataViewLogic.clearSelection(); - }, - class: {}, + `, + 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``; }; - const containerStyle = styleMap({ - flex: '1', - }); - return html``; - }; - }) - ); - if (!viewTypeItems.length) { - return html``; - } - return html` -
+ }) + ); + if (!viewTypeItems.length) { + return html``; + } + return html`
- ${LayoutIcon()} +
+ ${LayoutIcon()} +
+
+ Layout +
-
- Layout +
+ ${viewTypeItems}
-
-
- ${viewTypeItems} -
- `; + `; + }, + ], + }) + ); + + const settingItems: MenuConfig[] = []; + + settingItems.push( + menu.action({ + name: 'Properties', + prefix: InfoIcon(), + closeOnSelect: false, + postfix: html` +
+ ${view.properties$.value.length} shown +
+ ${ArrowRightSmallIcon()} + `, + select: () => { + navigate('properties'); + return false; }, - ], - }) - ); + }) + ); - items.push( - menu.group({ - items: createSettingMenus(target, dataViewLogic, reopen, () => - handler.close() - ), - }) - ); - items.push( - menu.group({ - items: [ + const filterTrait = view.traitGet(filterTraitKey); + if (filterTrait) { + const filterCount = filterTrait.filter$.value.conditions.length; + settingItems.push( menu.action({ - name: 'Duplicate', - prefix: DuplicateIcon(), + name: 'Filter', + prefix: FilterIcon(), closeOnSelect: false, + postfix: html` +
+ ${filterCount === 0 + ? '' + : filterCount === 1 + ? '1 active' + : `${filterCount} active`} +
+ ${ArrowRightSmallIcon()} + `, select: () => { - view.duplicate(); + navigate('filter'); + return false; }, - }), + }) + ); + } + + const sortTrait = view.traitGet(sortTraitKey); + if (sortTrait) { + const sortCount = sortTrait.sortList$.value.length; + settingItems.push( menu.action({ - name: 'Delete', - prefix: DeleteIcon(), + name: 'Sort', + prefix: SortIcon(), closeOnSelect: false, + postfix: html` +
+ ${sortCount === 0 + ? '' + : sortCount === 1 + ? '1 active' + : `${sortCount} active`} +
+ ${ArrowRightSmallIcon()} + `, select: () => { - view.delete(); + navigate('sort'); + return false; }, - class: { 'delete-item': true }, - }), - ], - }) - ); - handler = popMenu(target, { + }) + ); + } + + const groupTrait = view.traitGet(groupTraitKey); + if (groupTrait) { + settingItems.push( + menu.action({ + name: 'Group', + prefix: GroupingIcon(), + closeOnSelect: false, + postfix: html` +
+ ${groupTrait.property$.value?.name$.value ?? ''} +
+ ${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( + '.affine-menu-body' + ); + if (bodyEl) { + bodyEl.style.overflowY = 'auto'; + bodyEl.style.flex = '1'; + bodyEl.style.minHeight = '0'; + } + }); + } + return menuHandler; }; diff --git a/packages/frontend/core/package.json b/packages/frontend/core/package.json index 7d33e098b0f06..b26f7998edcb4 100644 --- a/packages/frontend/core/package.json +++ b/packages/frontend/core/package.json @@ -24,6 +24,7 @@ "@blocksuite/affine-block-root": "workspace:*", "@blocksuite/affine-components": "workspace:*", "@blocksuite/affine-shared": "workspace:*", + "@blocksuite/data-view": "workspace:*", "@blocksuite/global": "workspace:*", "@blocksuite/icons": "^2.2.17", "@blocksuite/std": "workspace:*", diff --git a/packages/frontend/core/src/blocksuite/database-block/calendar/workspace-calendar-source.spec.ts b/packages/frontend/core/src/blocksuite/database-block/calendar/workspace-calendar-source.spec.ts new file mode 100644 index 0000000000000..d150aceaef87f --- /dev/null +++ b/packages/frontend/core/src/blocksuite/database-block/calendar/workspace-calendar-source.spec.ts @@ -0,0 +1,319 @@ +/** @vitest-environment happy-dom */ + +import { describe, expect, it, vi } from 'vitest'; + +import { + calendarEventToExternalEntry, + selectWorkspaceCalendarSubscriptionIds, + WorkspaceCalendarExternalSource, +} from './workspace-calendar-source'; + +const accountCalendarsState = (value: unknown) => ({ + ['accountCalendars$']: { value }, +}); + +const workspaceCalendarsState = (value: unknown) => ({ + ['workspaceCalendars$']: { value }, +}); + +describe('workspace calendar source', () => { + it('intersects workspace enabled items with view subscription ids', () => { + const workspaceItems = [ + { subscriptionId: 'a', enabled: true }, + { subscriptionId: 'b', enabled: false }, + { subscriptionId: 'c', enabled: true }, + ]; + const cases = [ + { + viewConfig: { + enabled: true, + subscriptionIds: ['b', 'c'], + }, + expected: ['c'], + }, + { + viewConfig: { + enabled: false, + }, + expected: [], + }, + ]; + + for (const { viewConfig, expected } of cases) { + const ids = selectWorkspaceCalendarSubscriptionIds( + workspaceItems, + viewConfig + ); + + expect([...ids]).toEqual(expected); + } + }); + + it('maps event range to an external entry', () => { + const entry = calendarEventToExternalEntry( + { + id: 'event-1', + subscriptionId: 'sub-1', + externalEventId: 'external-1', + title: 'Planning', + description: 'Discuss roadmap', + location: 'Room A', + startAtUtc: '2026-05-15T01:00:00.000Z', + endAtUtc: '2026-05-16T01:00:00.000Z', + allDay: false, + } as any, + { + color: '#2f7d32', + calendarName: 'Work', + } + ); + + expect(entry).toMatchObject({ + kind: 'external', + id: 'workspace-calendar:event-1', + externalId: 'external-1', + title: 'Planning', + color: '#2f7d32', + calendarName: 'Work', + description: 'Discuss roadmap', + location: 'Room A', + }); + expect(entry.endAt).toBeGreaterThan(entry.startAt); + }); + + it('falls back to stable visible colors for muted calendar colors', () => { + const event = { + id: 'event-1', + subscriptionId: 'sub-1', + title: 'Planning', + startAtUtc: '2026-05-15T01:00:00.000Z', + endAtUtc: '2026-05-16T01:00:00.000Z', + allDay: false, + } as any; + + expect(calendarEventToExternalEntry(event, { color: '#00f' }).color).toBe( + '#6f6b2f' + ); + expect(calendarEventToExternalEntry(event, { color: '#eee' }).color).toBe( + '#6f6b2f' + ); + expect(calendarEventToExternalEntry(event).color).toBe('#6f6b2f'); + }); + + it('uses workspace color override before account calendar color', async () => { + const source = new WorkspaceCalendarExternalSource( + { + ...accountCalendarsState( + new Map([['account-1', [{ id: 'sub-1', color: '#111' }]]]) + ), + ...workspaceCalendarsState([ + { + items: [ + { + subscriptionId: 'sub-1', + enabled: true, + colorOverride: '#ad3b69', + }, + ], + }, + ]), + revalidateEventsRange: vi.fn().mockResolvedValue([ + { + id: 'event-1', + subscriptionId: 'sub-1', + title: 'Planning', + startAtUtc: '2026-05-15T01:00:00.000Z', + endAtUtc: '2026-05-15T02:00:00.000Z', + allDay: false, + }, + ]), + } as any, + () => true, + { + sources: { + workspaceCalendar: { + enabled: true, + }, + }, + } as any + ); + + await expect( + source.getEntries({ from: Date.now(), to: Date.now() }) + ).resolves.toMatchObject([{ color: '#ad3b69' }]); + expect(source.getSubscriptionOptions()).toEqual([ + { + id: 'sub-1', + name: 'sub-1', + color: '#ad3b69', + }, + ]); + }); + + it('returns empty entries without server', async () => { + const revalidateEventsRange = vi.fn(); + const source = new WorkspaceCalendarExternalSource( + { + ...accountCalendarsState(new Map()), + ...workspaceCalendarsState([ + { + items: [{ subscriptionId: 'sub-1', enabled: true }], + }, + ]), + revalidateEventsRange, + } as any, + () => false, + { + sources: { + workspaceCalendar: { + enabled: true, + }, + }, + } as any + ); + + await expect( + source.getEntries({ from: Date.now(), to: Date.now() }) + ).resolves.toEqual([]); + expect(revalidateEventsRange).not.toHaveBeenCalled(); + }); + + it('opens workspace integration settings from connect entry', () => { + const openSettings = vi.fn(); + const source = new WorkspaceCalendarExternalSource( + undefined, + () => false, + { + sources: { + workspaceCalendar: { + enabled: true, + }, + }, + } as any, + openSettings + ); + + source.openConnectSettings(); + + expect(openSettings).toHaveBeenCalled(); + }); + + it('loads empty calendar caches before fetching entries', async () => { + const loadAccountCalendars = vi.fn().mockResolvedValue( + new Map([ + [ + 'account-1', + [ + { + id: 'sub-1', + displayName: 'Work', + color: '#111', + }, + ], + ], + ]) + ); + const revalidateWorkspaceCalendars = vi.fn().mockResolvedValue([ + { + items: [{ subscriptionId: 'sub-1', enabled: true }], + }, + ]); + const source = new WorkspaceCalendarExternalSource( + { + ...accountCalendarsState(new Map()), + ...workspaceCalendarsState([]), + loadAccountCalendars, + revalidateWorkspaceCalendars, + revalidateEventsRange: vi.fn().mockResolvedValue([ + { + id: 'event-1', + subscriptionId: 'sub-1', + title: 'Planning', + startAtUtc: '2026-05-15T01:00:00.000Z', + endAtUtc: '2026-05-15T02:00:00.000Z', + allDay: false, + }, + ]), + } as any, + () => true, + { + sources: { + workspaceCalendar: { + enabled: true, + }, + }, + } as any + ); + + await expect( + source.getEntries({ from: Date.now(), to: Date.now() }) + ).resolves.toMatchObject([{ title: 'Planning' }]); + expect(loadAccountCalendars).toHaveBeenCalled(); + expect(revalidateWorkspaceCalendars).toHaveBeenCalled(); + }); + + it('returns empty entries when calendar requests fail', async () => { + const source = new WorkspaceCalendarExternalSource( + { + ...accountCalendarsState(new Map()), + ...workspaceCalendarsState([ + { + items: [{ subscriptionId: 'sub-1', enabled: true }], + }, + ]), + loadAccountCalendars: vi.fn().mockResolvedValue(new Map()), + revalidateEventsRange: vi.fn().mockRejectedValue(new Error('denied')), + } as any, + () => true, + { + sources: { + workspaceCalendar: { + enabled: true, + }, + }, + } as any + ); + + await expect( + source.getEntries({ from: Date.now(), to: Date.now() }) + ).resolves.toEqual([]); + }); + + it('returns empty entries when account calendar loading fails', async () => { + const revalidateEventsRange = vi.fn().mockResolvedValue([ + { + id: 'event-1', + subscriptionId: 'sub-1', + title: 'Planning', + startAtUtc: '2026-05-15T01:00:00.000Z', + endAtUtc: '2026-05-15T02:00:00.000Z', + allDay: false, + }, + ]); + const source = new WorkspaceCalendarExternalSource( + { + ...accountCalendarsState(new Map()), + ...workspaceCalendarsState([ + { + items: [{ subscriptionId: 'sub-1', enabled: true }], + }, + ]), + loadAccountCalendars: vi.fn().mockRejectedValue(new Error('denied')), + revalidateEventsRange, + } as any, + () => true, + { + sources: { + workspaceCalendar: { + enabled: true, + }, + }, + } as any + ); + + await expect( + source.getEntries({ from: Date.now(), to: Date.now() }) + ).resolves.toEqual([]); + expect(revalidateEventsRange).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/frontend/core/src/blocksuite/database-block/calendar/workspace-calendar-source.ts b/packages/frontend/core/src/blocksuite/database-block/calendar/workspace-calendar-source.ts new file mode 100644 index 0000000000000..a17d1aa689f95 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/database-block/calendar/workspace-calendar-source.ts @@ -0,0 +1,294 @@ +import { CALENDAR_INTEGRATION_SCROLL_ANCHOR } from '@affine/core/desktop/dialogs/setting/navigation-constants'; +import { WorkspaceServerService } from '@affine/core/modules/cloud'; +import { WorkspaceDialogService } from '@affine/core/modules/dialogs'; +import { IntegrationService } from '@affine/core/modules/integration'; +import type { + CalendarEntryRange, + CalendarExternalEntry, + CalendarViewData, +} from '@blocksuite/data-view/view-presets'; +import type { FrameworkProvider } from '@toeverything/infra'; +import dayjs from 'dayjs'; + +type CalendarIntegrationLike = IntegrationService['calendar']; + +type CalendarEventPayload = Awaited< + ReturnType +>[number]; + +const calendarColorPalette = [ + '#2f7d32', + '#b45309', + '#ad3b69', + '#8f6a00', + '#6f6b2f', + '#9f4f1a', +] as const; + +const hashString = (value: string) => { + let hash = 0; + for (let index = 0; index < value.length; index++) { + hash = (hash * 31 + value.charCodeAt(index)) >>> 0; + } + return hash; +}; + +const parseHexColor = (color: string) => { + const hex = color.trim(); + const match = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex); + if (!match) { + return; + } + const value = + match[1].length === 3 + ? match[1] + .split('') + .map(char => char + char) + .join('') + : match[1]; + return { + r: Number.parseInt(value.slice(0, 2), 16), + g: Number.parseInt(value.slice(2, 4), 16), + b: Number.parseInt(value.slice(4, 6), 16), + }; +}; + +const getColorHue = ({ r, g, b }: { r: number; g: number; b: number }) => { + const red = r / 255; + const green = g / 255; + const blue = b / 255; + const max = Math.max(red, green, blue); + const min = Math.min(red, green, blue); + const delta = max - min; + if (delta === 0) { + return 0; + } + if (max === red) { + return ((green - blue) / delta + (green < blue ? 6 : 0)) * 60; + } + if (max === green) { + return ((blue - red) / delta + 2) * 60; + } + return ((red - green) / delta + 4) * 60; +}; + +const isMutedCalendarColor = (color: string) => { + const rgb = parseHexColor(color); + if (!rgb) { + return true; + } + const { r, g, b } = rgb; + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const lightness = (max + min) / 510; + const saturation = + max === min ? 0 : (max - min) / (255 - Math.abs(max + min - 255)); + const hue = getColorHue(rgb); + return ( + lightness > 0.9 || + saturation < 0.18 || + (hue >= 190 && hue <= 260 && saturation > 0.18) + ); +}; + +const getCalendarColor = (subscriptionId: string, color?: string | null) => { + if (color && !isMutedCalendarColor(color)) { + return color; + } + return calendarColorPalette[ + hashString(subscriptionId) % calendarColorPalette.length + ]; +}; + +export const selectWorkspaceCalendarSubscriptionIds = ( + workspaceItems: Array<{ + subscriptionId: string; + enabled: boolean; + }>, + viewConfig?: CalendarViewData['sources']['workspaceCalendar'] +) => { + if (!viewConfig?.enabled) { + return new Set(); + } + const viewIds = viewConfig.subscriptionIds + ? new Set(viewConfig.subscriptionIds) + : undefined; + return new Set( + workspaceItems + .filter(item => item.enabled) + .filter(item => !viewIds || viewIds.has(item.subscriptionId)) + .map(item => item.subscriptionId) + ); +}; + +export const calendarEventToExternalEntry = ( + event: CalendarEventPayload, + options?: { + color?: string | null; + calendarName?: string; + } +) => + ({ + kind: 'external', + id: `workspace-calendar:${event.id}`, + sourceId: 'workspace-calendar', + externalId: event.externalEventId ?? event.id, + title: event.title ?? '', + color: + options?.color !== undefined + ? getCalendarColor(event.subscriptionId, options.color) + : getCalendarColor(event.subscriptionId), + calendarName: options?.calendarName, + location: event.location ?? undefined, + description: event.description ?? undefined, + startAt: dayjs(event.startAtUtc).valueOf(), + endAt: dayjs(event.endAtUtc).valueOf(), + allDay: event.allDay, + canResizeRange: false, + }) as CalendarExternalEntry; + +export class WorkspaceCalendarExternalSource { + id = 'workspace-calendar'; + + constructor( + private readonly calendar: CalendarIntegrationLike | undefined, + private readonly hasServer: () => boolean, + private readonly viewData: CalendarViewData, + private readonly openSettings?: () => void + ) {} + + openConnectSettings() { + this.openSettings?.(); + } + + async getEntries(range: CalendarEntryRange) { + const calendar = this.calendar; + if (!calendar || !this.hasServer()) { + return []; + } + + const workspaceCalendars = + calendar.workspaceCalendars$.value.length > 0 + ? calendar.workspaceCalendars$.value + : await calendar.revalidateWorkspaceCalendars().catch(() => []); + if (calendar.accountCalendars$.value.size === 0) { + const accountCalendars = await calendar + .loadAccountCalendars() + .catch(() => undefined); + if (!accountCalendars) { + return []; + } + } + + const workspaceCalendar = workspaceCalendars[0]; + const workspaceItems = workspaceCalendar?.items ?? []; + const subscriptionIds = selectWorkspaceCalendarSubscriptionIds( + workspaceItems, + this.viewData.sources?.workspaceCalendar + ); + if (subscriptionIds.size === 0) { + return []; + } + + const events = await calendar + .revalidateEventsRange(dayjs(range.from), dayjs(range.to)) + .catch(() => []); + const infoBySubscriptionId = this.getSubscriptionInfo(); + const colorBySubscriptionId = new Map(); + for (const calendars of calendar.accountCalendars$.value.values()) { + for (const subscription of calendars) { + colorBySubscriptionId.set(subscription.id, subscription.color); + } + } + for (const item of workspaceItems) { + if (item.colorOverride) { + colorBySubscriptionId.set(item.subscriptionId, item.colorOverride); + } + } + + return events + .filter(event => subscriptionIds.has(event.subscriptionId)) + .map(event => + calendarEventToExternalEntry(event, { + color: getCalendarColor( + event.subscriptionId, + colorBySubscriptionId.get(event.subscriptionId) + ), + calendarName: infoBySubscriptionId.get(event.subscriptionId)?.name, + }) + ); + } + + getSubscriptionOptions() { + const workspaceItems = + this.calendar?.workspaceCalendars$.value[0]?.items ?? []; + const enabledIds = new Set( + workspaceItems + .filter(item => item.enabled) + .map(item => item.subscriptionId) + ); + return [...this.getSubscriptionInfo()] + .filter(([id]) => enabledIds.has(id)) + .map(([id, info]) => ({ + id, + name: info.name, + color: getCalendarColor( + id, + workspaceItems.find(item => item.subscriptionId === id) + ?.colorOverride ?? info.color + ), + })); + } + + private getSubscriptionInfo() { + const infoBySubscriptionId = new Map< + string, + { + name: string; + color?: string | null; + } + >(); + for (const calendars of this.calendar?.accountCalendars$.value.values() ?? + []) { + for (const subscription of calendars) { + infoBySubscriptionId.set(subscription.id, { + name: + subscription.displayName ?? + subscription.externalCalendarId ?? + subscription.id, + color: subscription.color, + }); + } + } + return infoBySubscriptionId; + } +} + +export const createWorkspaceCalendarExternalSource = ( + framework?: FrameworkProvider +) => { + if (!framework) { + return { + id: 'workspace-calendar', + create: (viewData: CalendarViewData) => + new WorkspaceCalendarExternalSource(undefined, () => false, viewData), + }; + } + const integration = framework.get(IntegrationService); + const server = framework.get(WorkspaceServerService); + const dialog = framework.get(WorkspaceDialogService); + return { + id: 'workspace-calendar', + create: (viewData: CalendarViewData) => + new WorkspaceCalendarExternalSource( + integration.calendar, + () => !!server.server, + viewData, + () => + dialog.open('setting', { + activeTab: 'workspace:integrations', + scrollAnchor: CALENDAR_INTEGRATION_SCROLL_ANCHOR, + }) + ), + }; +}; diff --git a/packages/frontend/core/src/blocksuite/manager/view.ts b/packages/frontend/core/src/blocksuite/manager/view.ts index 3e5d19199e4b6..36fce743f1ca9 100644 --- a/packages/frontend/core/src/blocksuite/manager/view.ts +++ b/packages/frontend/core/src/blocksuite/manager/view.ts @@ -224,6 +224,7 @@ class ViewProvider { }; private readonly _configureDatabase = (framework?: FrameworkProvider) => { + this._manager.configure(AffineDatabaseViewExtension, { framework }); if (framework) { this._manager.configure( DatabaseViewExtension, diff --git a/packages/frontend/core/src/blocksuite/view-extensions/database/database-block-config-service.ts b/packages/frontend/core/src/blocksuite/view-extensions/database/database-block-config-service.ts index 9758feec13933..95c2b5cc8b289 100644 --- a/packages/frontend/core/src/blocksuite/view-extensions/database/database-block-config-service.ts +++ b/packages/frontend/core/src/blocksuite/view-extensions/database/database-block-config-service.ts @@ -3,11 +3,16 @@ import { ExternalGroupByConfigProvider, } from '@blocksuite/affine/blocks/database'; import type { ExtensionType } from '@blocksuite/affine/store'; +import { CalendarExternalSourceProvider } from '@blocksuite/data-view/view-presets'; +import type { FrameworkProvider } from '@toeverything/infra'; +import { createWorkspaceCalendarExternalSource } from '../../database-block/calendar/workspace-calendar-source'; import { groupByConfigList } from '../../database-block/group-by'; import { propertiesPresets } from '../../database-block/properties'; -export function patchDatabaseBlockConfigService(): ExtensionType { +export function patchDatabaseBlockConfigService( + framework?: FrameworkProvider +): ExtensionType { //TODO use service DatabaseBlockDataSource.externalProperties.value = propertiesPresets; return { @@ -15,6 +20,8 @@ export function patchDatabaseBlockConfigService(): ExtensionType { groupByConfigList.forEach(config => { di.addValue(ExternalGroupByConfigProvider(config.name), config); }); + const source = createWorkspaceCalendarExternalSource(framework); + di.addValue(CalendarExternalSourceProvider(source.id), source); }, }; } diff --git a/packages/frontend/core/src/blocksuite/view-extensions/database/index.ts b/packages/frontend/core/src/blocksuite/view-extensions/database/index.ts index 3bfa235d990c6..0a0028bc78148 100644 --- a/packages/frontend/core/src/blocksuite/view-extensions/database/index.ts +++ b/packages/frontend/core/src/blocksuite/view-extensions/database/index.ts @@ -2,11 +2,14 @@ import { type ViewExtensionContext, ViewExtensionProvider, } from '@blocksuite/affine/ext-loader'; +import { FrameworkProvider } from '@toeverything/infra'; import { z } from 'zod'; import { patchDatabaseBlockConfigService } from './database-block-config-service'; -const optionsSchema = z.object({}); +const optionsSchema = z.object({ + framework: z.instanceof(FrameworkProvider).optional(), +}); export type AffineDatabaseViewOptions = z.infer; @@ -21,6 +24,6 @@ export class AffineDatabaseViewExtension extends ViewExtensionProvider { + return page.evaluate( + ({ + withDateColumn, + mapDateColumn, + readonly, + rowCount, + linkedDocTitle, + withEndDateColumn, + }) => { + const { doc } = window; + const rows = rowCount ?? 1; + const linkedDocId = linkedDocTitle ? `doc:${linkedDocTitle}` : undefined; + doc.captureSync(); + const rootId = doc.addBlock('affine:page', { + title: new window.$blocksuite.store.Text(), + }); + const noteId = doc.addBlock('affine:note', {}, rootId); + const databaseId = doc.addBlock( + 'affine:database', + { + title: new window.$blocksuite.store.Text('Calendar database'), + }, + noteId + ); + if (linkedDocId && linkedDocTitle) { + const linkedDoc = window.collection.createDoc(linkedDocId).getStore(); + linkedDoc.load(); + const linkedRootId = linkedDoc.addBlock('affine:page', { + title: new window.$blocksuite.store.Text(linkedDocTitle), + }); + linkedDoc.addBlock('affine:surface', {}, linkedRootId); + linkedDoc.addBlock('affine:note', {}, linkedRootId); + } + const rowIds = Array.from({ length: rows }, (_, index) => { + const text = + index === 0 && linkedDocId + ? new window.$blocksuite.store.Text([ + { + insert: ' ', + attributes: { + reference: { + type: 'LinkedPage', + pageId: linkedDocId, + }, + }, + }, + ] as unknown as DeltaInsert[]) + : new window.$blocksuite.store.Text(`Task ${index + 1}`); + return doc.addBlock( + 'affine:paragraph', + { + type: 'text', + text, + }, + databaseId + ); + }); + const rowId = rowIds[0]; + const model = doc.getModelById(databaseId) as DatabaseBlockModel; + const datasource = + new window.$blocksuite.blocks.database.DatabaseBlockDataSource(model); + const dateColumnId = withDateColumn + ? datasource.propertyAdd('end', { + type: 'date', + name: 'Date', + }) + : undefined; + const endDateColumnId = withEndDateColumn + ? datasource.propertyAdd('end', { + type: 'date', + name: 'End Date', + }) + : undefined; + if (dateColumnId) { + for (const id of rowIds) { + datasource.cellValueChange( + id, + dateColumnId, + new Date('2026-05-15T00:00:00').getTime() + ); + if (endDateColumnId) { + datasource.cellValueChange( + id, + endDateColumnId, + new Date('2026-05-17T00:00:00').getTime() + ); + } + } + } + const viewId = datasource.viewManager.viewAdd('calendar'); + datasource.viewManager.setCurrentView(viewId); + if (dateColumnId && mapDateColumn) { + const view = datasource.viewManager.viewGet(viewId) as + | { + setDateColumn: (propertyId: string) => void; + setEndDateColumn: (propertyId: string) => void; + } + | undefined; + view?.setDateColumn(dateColumnId); + if (endDateColumnId) { + view?.setEndDateColumn(endDateColumnId); + } + } + if (readonly) { + doc.readonly = true; + } + doc.captureSync(); + return { + databaseId, + rowId, + rowIds, + dateColumnId, + endDateColumnId, + viewId, + linkedDocId, + }; + }, + options ?? {} + ); +}; + +test(scoped`database calendar setup and row interactions`, async ({ page }) => { + await enterPlaygroundRoom(page); + const ids = await createCalendarDatabase(page, { + withDateColumn: true, + mapDateColumn: false, + }); + + const editorHost = getEditorHostLocator(page); + await expect(editorHost.getByTestId('dv-calendar-view')).toBeVisible(); + await expect( + page.getByRole('button', { name: 'Select or create date property' }) + ).toBeVisible(); + + await page + .getByRole('button', { name: 'Select or create date property' }) + .click(); + await page.getByText('Date', { exact: true }).click(); + await expect( + page.locator('.calendar-entry').filter({ hasText: 'Task 1' }) + ).toBeVisible(); + + await page.locator('.calendar-entry').filter({ hasText: 'Task 1' }).click(); + await expect(page.locator('affine-data-view-record-detail')).toBeVisible(); + + await page.evaluate(({ databaseId, dateColumnId }) => { + if (!dateColumnId) { + throw new Error('dateColumnId is required'); + } + const model = window.doc.getModelById(databaseId) as DatabaseBlockModel; + const datasource = + new window.$blocksuite.blocks.database.DatabaseBlockDataSource(model); + datasource.propertyDelete(dateColumnId); + }, ids); + await expect( + page.getByRole('button', { name: 'Select or create date property' }) + ).toBeVisible(); +}); + +test( + scoped`database calendar creates date property from setup`, + async ({ page }) => { + await enterPlaygroundRoom(page); + await createCalendarDatabase(page); + + await page + .getByRole('button', { name: 'Select or create date property' }) + .click(); + await page.getByText('Create date property', { exact: true }).click(); + + await expect( + page.getByRole('button', { name: 'Select or create date property' }) + ).toBeHidden(); + } +); + +test(scoped`database calendar creates row from empty day`, async ({ page }) => { + await enterPlaygroundRoom(page); + const ids = await createCalendarDatabase(page, { + withDateColumn: true, + mapDateColumn: true, + }); + + const targetDay = page + .locator('.calendar-day') + .filter({ has: page.locator('.calendar-day-number', { hasText: '20' }) }) + .first(); + await targetDay.hover(); + await targetDay.getByRole('button', { name: '+ New row' }).click(); + + await expect(page.locator('affine-data-view-record-detail')).toBeVisible(); + + const expectedDate = await page.evaluate(() => + new Date('2026-05-20T00:00:00').getTime() + ); + await expect + .poll(() => + page.evaluate( + ({ databaseId, dateColumnId, expectedDate }) => { + if (!dateColumnId) { + throw new Error('dateColumnId is required'); + } + const model = window.doc.getModelById( + databaseId + ) as DatabaseBlockModel; + const datasource = + new window.$blocksuite.blocks.database.DatabaseBlockDataSource( + model + ); + return datasource.rows$.value.some( + rowId => + datasource.cellValueGet(rowId, dateColumnId) === expectedDate + ); + }, + { ...ids, expectedDate } + ) + ) + .toBe(true); +}); + +test( + scoped`database calendar opens linked doc row via row detail`, + async ({ page }) => { + await enterPlaygroundRoom(page); + const ids = await createCalendarDatabase(page, { + withDateColumn: true, + mapDateColumn: true, + linkedDocTitle: 'Calendar linked doc', + }); + + await page.evaluate(({ databaseId }) => { + const database = document.querySelector( + `affine-database[data-block-id="${databaseId}"]` + ) as any; + database.dataViewRootLogic.value.openDetailPanel = ({ + rowId, + }: { + rowId: string; + }) => { + (window as any).__calendarRowOpen = rowId; + }; + }, ids); + + const entry = page.locator('.calendar-entry.row').first(); + await expect(entry).toContainText('Calendar linked doc'); + await entry.click(); + + await expect + .poll(() => page.evaluate(() => (window as any).__calendarRowOpen)) + .toBe(ids.rowId); + } +); + +test( + scoped`database calendar hides setup create action in readonly`, + async ({ page }) => { + await enterPlaygroundRoom(page); + await createCalendarDatabase(page, { readonly: true }); + + await page + .getByRole('button', { name: 'Select or create date property' }) + .click(); + + await expect( + page.getByText('Create date property', { exact: true }) + ).toBeHidden(); + + await page.evaluate(() => { + window.doc.readonly = false; + }); + } +); + +test(scoped`database calendar shows all day entries`, async ({ page }) => { + await enterPlaygroundRoom(page); + await createCalendarDatabase(page, { + withDateColumn: true, + mapDateColumn: true, + rowCount: 4, + }); + + const may15 = page + .locator('.calendar-day') + .filter({ has: page.locator('.calendar-day-number', { hasText: '15' }) }) + .first(); + + await expect(may15.locator('.calendar-entry')).toHaveCount(4); + await expect(may15.locator('.calendar-overflow')).toHaveCount(0); +}); + +test( + scoped`database calendar drag updates date cell and readonly disables drag`, + async ({ page }) => { + await enterPlaygroundRoom(page); + const ids = await createCalendarDatabase(page, { + withDateColumn: true, + mapDateColumn: true, + }); + await waitNextFrame(page); + + const entry = page.locator('.calendar-entry').filter({ hasText: 'Task 1' }); + const targetDay = page + .locator('.calendar-day') + .filter({ has: page.locator('.calendar-day-number', { hasText: '20' }) }) + .first(); + await entry.dragTo(targetDay); + + const expectedDate = await page.evaluate(() => + new Date('2026-05-20T00:00:00').getTime() + ); + await expect + .poll(async () => + page.evaluate(({ databaseId, rowId, dateColumnId }) => { + if (!dateColumnId) { + throw new Error('dateColumnId is required'); + } + const model = window.doc.getModelById( + databaseId + ) as DatabaseBlockModel; + const datasource = + new window.$blocksuite.blocks.database.DatabaseBlockDataSource( + model + ); + return datasource.cellValueGet(rowId, dateColumnId); + }, ids) + ) + .toBe(expectedDate); + + const readonlyIds = await createCalendarDatabase(page, { + withDateColumn: true, + mapDateColumn: true, + readonly: true, + }); + await waitNextFrame(page); + const readonlyCalendar = page.getByTestId('dv-calendar-view').last(); + await expect( + readonlyCalendar + .locator('.calendar-entry') + .filter({ hasText: 'Task 1' }) + .first() + ).toHaveAttribute('draggable', 'false'); + + await page.evaluate(() => { + window.doc.readonly = false; + }); + expect(readonlyIds.databaseId).toBeTruthy(); + } +); + +test( + scoped`database calendar drags row range preserving duration`, + async ({ page }) => { + await enterPlaygroundRoom(page); + const ids = await createCalendarDatabase(page, { + withDateColumn: true, + withEndDateColumn: true, + mapDateColumn: true, + }); + await waitNextFrame(page); + + const entry = page + .locator('.calendar-entry.row') + .filter({ hasText: 'Task 1' }) + .first(); + const targetDay = page + .locator('.calendar-day') + .filter({ has: page.locator('.calendar-day-number', { hasText: '20' }) }) + .first(); + await entry.dragTo(targetDay); + + const expectedStart = await page.evaluate(() => + new Date('2026-05-20T00:00:00').getTime() + ); + const expectedEnd = await page.evaluate(() => + new Date('2026-05-22T00:00:00').getTime() + ); + await expect + .poll(() => + page.evaluate( + ({ databaseId, rowId, dateColumnId, endDateColumnId }) => { + if (!dateColumnId || !endDateColumnId) { + throw new Error('date columns are required'); + } + const model = window.doc.getModelById( + databaseId + ) as DatabaseBlockModel; + const datasource = + new window.$blocksuite.blocks.database.DatabaseBlockDataSource( + model + ); + return [ + datasource.cellValueGet(rowId, dateColumnId), + datasource.cellValueGet(rowId, endDateColumnId), + ]; + }, + ids + ) + ) + .toEqual([expectedStart, expectedEnd]); + } +); + +test(scoped`database calendar resizes row range end date`, async ({ page }) => { + await enterPlaygroundRoom(page); + const ids = await createCalendarDatabase(page, { + withDateColumn: true, + withEndDateColumn: true, + mapDateColumn: true, + }); + await waitNextFrame(page); + + const entry = page + .locator('.calendar-entry.row') + .filter({ hasText: 'Task 1' }) + .filter({ has: page.locator('.calendar-resize-handle.right') }) + .first(); + await entry.hover(); + const handle = entry.locator('.calendar-resize-handle.right'); + const targetDay = page + .locator('.calendar-day') + .filter({ has: page.locator('.calendar-day-number', { hasText: '20' }) }) + .first(); + const handleBox = await handle.boundingBox(); + const targetBox = await targetDay.boundingBox(); + if (!handleBox || !targetBox) { + throw new Error('resize target is not visible'); + } + + await page.mouse.move( + handleBox.x + handleBox.width / 2, + handleBox.y + handleBox.height / 2 + ); + await page.mouse.down(); + await page.mouse.move(targetBox.x + targetBox.width / 2, targetBox.y + 16); + await page.mouse.up(); + + const expectedEnd = await page.evaluate(() => + new Date('2026-05-20T00:00:00').getTime() + ); + await expect + .poll(() => + page.evaluate(({ databaseId, rowId, endDateColumnId }) => { + if (!endDateColumnId) { + throw new Error('endDateColumnId is required'); + } + const model = window.doc.getModelById(databaseId) as DatabaseBlockModel; + const datasource = + new window.$blocksuite.blocks.database.DatabaseBlockDataSource(model); + return datasource.cellValueGet(rowId, endDateColumnId); + }, ids) + ) + .toBe(expectedEnd); +}); diff --git a/tests/blocksuite/e2e/slash-menu.spec.ts b/tests/blocksuite/e2e/slash-menu.spec.ts index b23f7f1322773..938f0dc1e6030 100644 --- a/tests/blocksuite/e2e/slash-menu.spec.ts +++ b/tests/blocksuite/e2e/slash-menu.spec.ts @@ -608,7 +608,7 @@ test.describe('slash search', () => { await expect(slashMenu).toBeVisible(); await type(page, 'c'); - await expect(slashItems).toHaveCount(11); + await expect(slashItems).toHaveCount(12); await expect(slashItems.nth(0).locator('.text')).toHaveText(['Copy']); await expect(slashItems.nth(1).locator('.text')).toHaveText(['Italic']); await expect(slashItems.nth(2).locator('.text')).toHaveText(['Callout']); @@ -632,11 +632,14 @@ test.describe('slash search', () => { const slashItems = slashMenu.locator('icon-button'); await type(page, 'database'); - await expect(slashItems).toHaveCount(2); + await expect(slashItems).toHaveCount(3); await expect(slashItems.nth(0).locator('.text')).toHaveText(['Table View']); await expect(slashItems.nth(1).locator('.text')).toHaveText([ 'Kanban View', ]); + await expect(slashItems.nth(2).locator('.text')).toHaveText([ + 'Calendar View', + ]); await type(page, 'v'); await expect(slashItems).toHaveCount(0); }); diff --git a/tools/utils/src/workspace.gen.ts b/tools/utils/src/workspace.gen.ts index 25f042cf0fe16..52b793759581d 100644 --- a/tools/utils/src/workspace.gen.ts +++ b/tools/utils/src/workspace.gen.ts @@ -1302,6 +1302,7 @@ export const PackageList = [ 'blocksuite/affine/blocks/root', 'blocksuite/affine/components', 'blocksuite/affine/shared', + 'blocksuite/affine/data-view', 'blocksuite/framework/global', 'blocksuite/framework/std', 'packages/common/infra', diff --git a/yarn.lock b/yarn.lock index c89c3dcbebaff..688d120477cac 100644 --- a/yarn.lock +++ b/yarn.lock @@ -390,6 +390,7 @@ __metadata: "@blocksuite/affine-components": "workspace:*" "@blocksuite/affine-ext-loader": "workspace:*" "@blocksuite/affine-shared": "workspace:*" + "@blocksuite/data-view": "workspace:*" "@blocksuite/global": "workspace:*" "@blocksuite/icons": "npm:^2.2.17" "@blocksuite/std": "workspace:*"