From e0e8ac68bb610d3d14486a56b294ebcbdf684d02 Mon Sep 17 00:00:00 2001 From: Nathan Date: Sun, 13 Sep 2026 12:58:55 +0000 Subject: [PATCH 01/21] feat(database): add Notion-style timeline view Add a Timeline database layout (DatabaseViewLayout.Timeline = 8, ViewLayout.Timeline = 10) that plots rows as bars on a horizontally scrolling, infinitely growing canvas next to a docked table. Renderer ported from frappe/gantt (MIT, attributed in file headers) and restyled with the calendar's toolbar, gutters, today pill and event cards: - Hours / Day / Week / Bi-week / Month / Quarter / Year scales - Move, resize-start / resize-end and progress drags with column snapping, auto-scroll near the edges, Escape to cancel, undo / redo groups - Dependency arrows from a self-relation field; dependents follow the dragged bar and a bar cannot start before its dependencies - Progress fill from a number field - Hover card, selection, keyboard open, off-screen pills, Today / steppers - Undated rows in the table and the No Date list; click on the canvas dates them The layout setting is stored like the calendar's under layout_settings['8'] (layout_ty, first_day_of_week_v2 with the legacy and user-metadata fallbacks, field_id, show_table, dependency_field_id, progress_field_id); read-only viewers keep a local override. Tests: 29 jest cases (geometry, dependencies, layout setting), a Playwright e2e spec and a 22-scenario BDD feature covering every interaction. Requires the matching server change in AppFlowy-Cloud-Premium (feat/timeline-layout) so the new layout ids are accepted. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NoEH4HcEiojEcioVSLE3QM --- .../bdd/features/database/timeline.feature | 160 +++++ playwright/bdd/steps/timeline.steps.ts | 574 ++++++++++++++++++ playwright/e2e/database/timeline.spec.ts | 224 +++++++ playwright/support/selectors.ts | 43 ++ playwright/support/timeline-test-helpers.ts | 227 +++++++ src/@types/translations/en.json | 36 ++ src/application/constants.ts | 9 + .../__tests__/timeline-layout.test.ts | 182 ++++++ src/application/database-yjs/database.type.ts | 28 + src/application/database-yjs/dispatch.ts | 43 +- src/application/database-yjs/dispatch/cell.ts | 68 ++- src/application/database-yjs/selector.ts | 33 +- .../database-yjs/timeline-layout.ts | 179 ++++++ src/application/types.ts | 28 + src/assets/icons/timeline.svg | 5 + src/components/_shared/view-icon/PageIcon.tsx | 3 + src/components/_shared/view-icon/ViewIcon.tsx | 3 + src/components/database/DatabaseViews.tsx | 3 + .../components/conditions/DatabaseActions.tsx | 2 + .../database/components/settings/Layout.tsx | 9 + .../database/components/settings/Settings.tsx | 2 + .../settings/TimelineLayoutSettings.tsx | 187 ++++++ .../components/settings/TimelineSettings.tsx | 36 ++ .../components/tabs/AddViewButton.tsx | 14 +- .../components/tabs/DatabaseTabItem.tsx | 4 + src/components/database/timeline/Timeline.tsx | 21 + .../database/timeline/TimelineArrows.tsx | 94 +++ .../database/timeline/TimelineBar.tsx | 282 +++++++++ .../database/timeline/TimelineGrid.tsx | 46 ++ .../database/timeline/TimelineHeader.tsx | 86 +++ .../database/timeline/TimelineRow.tsx | 224 +++++++ .../database/timeline/TimelineToolbar.tsx | 141 +++++ .../database/timeline/TimelineUnsupported.tsx | 24 + .../database/timeline/TimelineView.tsx | 558 +++++++++++++++++ .../timeline/__tests__/dependencies.test.ts | 158 +++++ .../timeline/__tests__/geometry.test.ts | 161 +++++ src/components/database/timeline/constants.ts | 18 + .../timeline/hooks/useScrollWindow.ts | 57 ++ .../timeline/hooks/useTimelineDrag.ts | 301 +++++++++ .../timeline/hooks/useTimelineFieldValues.ts | 73 +++ .../timeline/hooks/useTimelinePermissions.ts | 25 + .../timeline/hooks/useTimelineRange.ts | 195 ++++++ .../timeline/hooks/useTimelineRows.ts | 59 ++ src/components/database/timeline/index.ts | 1 + .../database/timeline/scale/dependencies.ts | 133 ++++ .../database/timeline/scale/geometry.ts | 282 +++++++++ .../database/timeline/scale/presets.ts | 188 ++++++ 47 files changed, 5220 insertions(+), 9 deletions(-) create mode 100644 playwright/bdd/features/database/timeline.feature create mode 100644 playwright/bdd/steps/timeline.steps.ts create mode 100644 playwright/e2e/database/timeline.spec.ts create mode 100644 playwright/support/timeline-test-helpers.ts create mode 100644 src/application/database-yjs/__tests__/timeline-layout.test.ts create mode 100644 src/application/database-yjs/timeline-layout.ts create mode 100644 src/assets/icons/timeline.svg create mode 100644 src/components/database/components/settings/TimelineLayoutSettings.tsx create mode 100644 src/components/database/components/settings/TimelineSettings.tsx create mode 100644 src/components/database/timeline/Timeline.tsx create mode 100644 src/components/database/timeline/TimelineArrows.tsx create mode 100644 src/components/database/timeline/TimelineBar.tsx create mode 100644 src/components/database/timeline/TimelineGrid.tsx create mode 100644 src/components/database/timeline/TimelineHeader.tsx create mode 100644 src/components/database/timeline/TimelineRow.tsx create mode 100644 src/components/database/timeline/TimelineToolbar.tsx create mode 100644 src/components/database/timeline/TimelineUnsupported.tsx create mode 100644 src/components/database/timeline/TimelineView.tsx create mode 100644 src/components/database/timeline/__tests__/dependencies.test.ts create mode 100644 src/components/database/timeline/__tests__/geometry.test.ts create mode 100644 src/components/database/timeline/constants.ts create mode 100644 src/components/database/timeline/hooks/useScrollWindow.ts create mode 100644 src/components/database/timeline/hooks/useTimelineDrag.ts create mode 100644 src/components/database/timeline/hooks/useTimelineFieldValues.ts create mode 100644 src/components/database/timeline/hooks/useTimelinePermissions.ts create mode 100644 src/components/database/timeline/hooks/useTimelineRange.ts create mode 100644 src/components/database/timeline/hooks/useTimelineRows.ts create mode 100644 src/components/database/timeline/index.ts create mode 100644 src/components/database/timeline/scale/dependencies.ts create mode 100644 src/components/database/timeline/scale/geometry.ts create mode 100644 src/components/database/timeline/scale/presets.ts diff --git a/playwright/bdd/features/database/timeline.feature b/playwright/bdd/features/database/timeline.feature new file mode 100644 index 000000000..d374b0100 --- /dev/null +++ b/playwright/bdd/features/database/timeline.feature @@ -0,0 +1,160 @@ +@timeline @cloud +Feature: Timeline view interactions + A Timeline view plots each row's date property as a bar on a horizontally + infinite canvas, the way Notion's timeline does, and supports the editing + interactions frappe-gantt offers: move, resize, snap, dependencies and + progress. Every scenario starts from a fresh cloud calendar with two dated + rows and a Timeline view added through the view tabs. + + Background: + Given a cloud calendar with "Design" today and "Build" in 2 days + And a Timeline view is added from the view menu + + Scenario: Dated rows render as bars under a month header with today marked + Then the timeline shows bars for "Design" and "Build" + And the timeline header marks today and draws the today line + And the timeline title shows the current month + And the timeline scale reads "Month" + And the timeline table lists "Design" and "Build" + + Scenario: Changing the scale relabels the header and persists on the view + When I choose the "Week" timeline scale + Then the timeline scale reads "Week" + And the timeline header cells show weekday names + And the timeline still shows 2 bars + When I reload the timeline + Then the timeline scale reads "Week" + When I choose the "Month" timeline scale + Then the timeline scale reads "Month" + + Scenario: Steppers, off-screen pills and Today move the viewport + When I step the timeline later 2 times + Then the "Design" bar is off screen to the left with a left pill + When I click the left off-screen pill + Then the "Design" bar is visible + When I step the timeline earlier 3 times + Then the "Design" bar is off screen to the right with a right pill + When I click the timeline Today button + Then the "Design" bar is visible + And the timeline header marks today and draws the today line + + Scenario: Dragging a bar snaps it to whole columns and undo restores it + When I drag the "Build" bar 2 columns later + Then the "Build" bar moved 2 columns later + When I press undo + Then the "Build" bar is back where it started + + Scenario: The end handle resizes a bar and Escape cancels a drag in progress + When I drag the end handle of "Design" 2 columns later + Then the "Design" bar grew by 2 columns + And the header highlights nothing once the drag ends + When I start dragging the "Design" bar and press Escape + Then the "Design" bar is back where it started + + Scenario: Hovering shows the card; table rows select and open + When I hover the "Design" bar + Then the timeline hover card shows "Design" with a one day duration + When I click the table row "Build" + Then the "Build" row and bar are selected + When I click the empty canvas of the "Build" row + Then no timeline row is selected + When I open the table row "Build" + Then the row detail for "Build" opens + + Scenario: Undated rows sit in the table until a click dates them, and the table can be hidden + When I add a new timeline row + Then the timeline table lists 3 rows and the No Date button reads "(1)" + When I click the undated row's canvas + Then the timeline shows 3 bars and no No Date button + When I hide the timeline table + Then the timeline table is hidden and 3 bars remain + When I show the timeline table + Then the timeline table lists 3 rows + + Scenario: Dependencies draw arrows, dependents follow the dragged bar, and a bar cannot start before its dependency + Given "Build" depends on "Design" through a relation field + Then the timeline draws 1 dependency arrow + When I drag the "Design" bar 2 columns later + Then the "Build" bar moved 2 columns later + When I press undo + Then the "Build" bar is back where it started + And the "Design" bar is back where it started + When I drag the "Build" bar 6 columns earlier + Then the "Build" bar starts where the "Design" bar starts + + Scenario: A progress field renders a fill that the progress handle drags + Given "Design" has a progress field at 40 percent + When I drag the end handle of "Design" 3 columns later + Then the "Design" bar shows 40 percent progress + And the timeline hover card for "Design" mentions "40% complete" + When I drag the progress handle of "Design" halfway across the bar + Then the "Design" bar shows more than 80 percent progress + + Scenario Outline: Every scale renders a labelled header and keeps the bars + When I choose the "" timeline scale + Then the timeline scale reads "" + And the timeline header has labels + And the timeline still shows 2 bars + + Examples: + | scale | + | Hours | + | Day | + | Week | + | Bi-week | + | Month | + | Quarter | + | Year | + + Scenario: Redo re-applies an undone move and the right pill scrolls to a far bar + When I drag the "Build" bar 2 columns later + And I press undo + Then the "Build" bar is back where it started + When I press redo + Then the "Build" bar moved 2 columns later + When I drag the "Build" bar 40 columns later + Then the "Build" bar is off screen to the right with a right pill + When I click the right off-screen pill + Then the "Build" bar is visible + + Scenario: The No Date list, keyboard open, and double-clicking a table row + When I add a new timeline row + And I open the No Date list + Then the No Date list shows 1 undated row + When I close the No Date list + And I focus the "Design" bar and press Enter + Then the row detail for "Design" opens + When I double-click the table row "Build" + Then the row detail for "Build" opens + + Scenario: Timeline settings switch the plotted date field, the table, and the week start + Given "Build" also has a "Ship date" field 5 days later + When I choose "Ship date" as the timeline date field + Then the "Build" bar sits 5 days after the "Design" bar + When I toggle the table from the timeline settings + Then the timeline table is hidden and 2 bars remain + When I toggle the table from the timeline settings + Then the timeline table lists 2 rows + When I choose Monday as the timeline week start + And I choose the "Quarter" timeline scale + Then the timeline quarter labels fall on Mondays + + Scenario: The Layout menu converts a view to a timeline and back + When I switch to the "Calendar" view tab + And I change the view layout to "Timeline" + Then the timeline shows bars for "Design" and "Build" + When I change the view layout to "Calendar" + Then the calendar view is shown + + Scenario: Removing the plotted date field shows the empty state until another is chosen + Given "Build" also has a "Ship date" field 5 days later + When the timeline's date field is deleted from the database + Then the timeline explains that it has no date property + When I choose "Ship date" as the timeline date field + Then the timeline still shows 2 bars + + Scenario: Extending a bar's end pushes its dependents along + Given "Build" depends on "Design" through a relation field + When I drag the end handle of "Design" 3 columns later + Then the "Design" bar grew by 3 columns + And the "Build" bar moved 3 columns later diff --git a/playwright/bdd/steps/timeline.steps.ts b/playwright/bdd/steps/timeline.steps.ts new file mode 100644 index 000000000..eac4f6311 --- /dev/null +++ b/playwright/bdd/steps/timeline.steps.ts @@ -0,0 +1,574 @@ +import { expect, type Page } from '@playwright/test'; +import { createBdd } from 'playwright-bdd'; + +import { DatabaseViewLayout } from '../../../src/application/types'; +import { FieldType } from '../../../src/application/database-yjs/database.type'; +import { getCurrentDatabaseInfo, setRelationCellDirect, waitForDatabaseTestContext } from '../../support/relation-test-helpers'; +import { closeRowDetailWithEscape } from '../../support/row-detail-helpers'; +import { CalendarSelectors, DatabaseViewSelectors, RowDetailSelectors, TimelineSelectors } from '../../support/selectors'; +import { generateRandomEmail } from '../../support/test-config'; +import { + activeViewRowIds, + addTimelineView, + barBox, + chooseTimelineSettingsOption, + chooseTimelineZoom, + clickRowCanvas, + dragBarBy, + dragHandleBy, + expectBarWidth, + expectBarX, + injectFieldDirect, + loginAndCreateCalendarWithRows, + MONTH_COLUMN_WIDTH, + readProgressPercent, + setTextCellDirect, + TIMELINE_SIDEBAR_WIDTH, + TimelineLayout, + type BarBox, +} from '../../support/timeline-test-helpers'; + +const { Given, When, Then } = createBdd(); + +const ZOOM_BY_NAME: Record = { + Hours: TimelineLayout.Hours, + Day: TimelineLayout.Day, + Week: TimelineLayout.Week, + 'Bi-week': TimelineLayout.BiWeek, + Month: TimelineLayout.Month, + Quarter: TimelineLayout.Quarter, + Year: TimelineLayout.Year, +}; + +interface TimelineScenario { + /** Row ids in view order; index 0 is the first Background row. */ + rowIds: string[]; + rowIdByTitle: Map; + /** Bar boxes captured right before the last drag, keyed by title. */ + before: Map; +} + +const scenarios = new WeakMap(); + +function scenario(page: Page): TimelineScenario { + const state = scenarios.get(page); + + if (!state) throw new Error('Add a Timeline view before using timeline steps'); + return state; +} + +function rowId(page: Page, title: string): string { + const id = scenario(page).rowIdByTitle.get(title); + + if (!id) throw new Error(`Unknown timeline row "${title}"`); + return id; +} + +async function remember(page: Page, ...titles: string[]) { + const state = scenario(page); + + for (const title of titles) state.before.set(title, await barBox(page, title)); +} + +function before(page: Page, title: string): BarBox { + const box = scenario(page).before.get(title); + + if (!box) throw new Error(`No remembered position for "${title}"`); + return box; +} + +async function visibleCanvas(page: Page) { + const view = await TimelineSelectors.view(page).boundingBox(); + + if (!view) throw new Error('Timeline view is not visible'); + return { left: view.x + TIMELINE_SIDEBAR_WIDTH, right: view.x + view.width }; +} + +Given('a cloud calendar with {string} today and {string} in {int} days', async ({ page, request, $testInfo }, first, second, offset) => { + $testInfo.setTimeout(240_000); + await loginAndCreateCalendarWithRows(page, request, generateRandomEmail(), [ + { title: first, offsetDays: 0 }, + { title: second, offsetDays: offset }, + ]); + scenarios.set(page, { rowIds: [], rowIdByTitle: new Map(), before: new Map() }); +}); + +Given('a Timeline view is added from the view menu', async ({ page }) => { + await addTimelineView(page, 2); + await waitForDatabaseTestContext(page); + const state = scenario(page); + + state.rowIds = await activeViewRowIds(page); + // Background rows were created in order, so they map onto the view order. + const titles = await TimelineSelectors.sidebarRows(page).allTextContents(); + + titles.forEach((title, index) => state.rowIdByTitle.set(title.trim(), state.rowIds[index])); +}); + +Then('the timeline shows bars for {string} and {string}', async ({ page }, first, second) => { + await expect(TimelineSelectors.bars(page)).toHaveCount(2); + await expect(TimelineSelectors.barByTitle(page, first)).toBeVisible(); + await expect(TimelineSelectors.barByTitle(page, second)).toBeVisible(); +}); + +Then('the timeline header marks today and draws the today line', async ({ page }) => { + await expect(TimelineSelectors.headerToday(page)).toBeVisible(); + await expect(TimelineSelectors.todayLine(page)).toBeVisible(); +}); + +Then('the timeline title shows the current month', async ({ page }) => { + const month = new Date().toLocaleString('en-US', { month: 'long', year: 'numeric' }); + + await expect(TimelineSelectors.title(page)).toHaveText(month); +}); + +Then('the timeline scale reads {string}', async ({ page }, scale) => { + await expect(TimelineSelectors.zoomTrigger(page)).toHaveText(new RegExp(scale)); +}); + +Then('the timeline table lists {string} and {string}', async ({ page }, first, second) => { + await expect(TimelineSelectors.sidebarRows(page)).toHaveCount(2); + await expect(TimelineSelectors.sidebarRows(page).filter({ hasText: first })).toBeVisible(); + await expect(TimelineSelectors.sidebarRows(page).filter({ hasText: second })).toBeVisible(); +}); + +When('I choose the {string} timeline scale', async ({ page }, scale) => { + const layout = ZOOM_BY_NAME[scale]; + + if (layout === undefined) throw new Error(`Unknown scale "${scale}"`); + await chooseTimelineZoom(page, layout); +}); + +Then('the timeline header cells show weekday names', async ({ page }) => { + // Calendar-style week header: "Mon 15", or "Thu Oct 1" on the first of a month. + await expect(TimelineSelectors.headerToday(page)).toHaveText(/^[A-Z][a-z]{2} (?:[A-Z][a-z]{2} )?\d{1,2}$/); +}); + +Then('the timeline still shows {int} bars', async ({ page }, count) => { + await expect(TimelineSelectors.bars(page)).toHaveCount(count); +}); + +When('I reload the timeline', async ({ page }) => { + await page.reload({ waitUntil: 'domcontentloaded' }); + await expect(TimelineSelectors.view(page)).toBeVisible({ timeout: 30_000 }); + await expect(TimelineSelectors.bars(page)).toHaveCount(2, { timeout: 15_000 }); +}); + +When('I step the timeline later {int} times', async ({ page }, times) => { + for (let i = 0; i < times; i += 1) { + await TimelineSelectors.stepNext(page).click(); + await page.waitForTimeout(400); + } +}); + +When('I step the timeline earlier {int} times', async ({ page }, times) => { + for (let i = 0; i < times; i += 1) { + await TimelineSelectors.stepPrevious(page).click(); + await page.waitForTimeout(400); + } +}); + +Then('the {string} bar is off screen to the left with a left pill', async ({ page }, title) => { + const canvas = await visibleCanvas(page); + + await expect.poll(async () => (await barBox(page, title)).x + (await barBox(page, title)).width, { timeout: 10_000 }).toBeLessThan(canvas.left); + await expect(TimelineSelectors.row(page, rowId(page, title)).locator('[data-testid="timeline-offscreen-left"]')).toBeVisible(); +}); + +Then('the {string} bar is off screen to the right with a right pill', async ({ page }, title) => { + const canvas = await visibleCanvas(page); + + await expect.poll(async () => (await barBox(page, title)).x, { timeout: 10_000 }).toBeGreaterThan(canvas.right); + await expect(TimelineSelectors.row(page, rowId(page, title)).locator('[data-testid="timeline-offscreen-right"]')).toBeVisible(); +}); + +When('I click the left off-screen pill', async ({ page }) => { + await TimelineSelectors.offscreenLeft(page).first().click(); +}); + +Then('the {string} bar is visible', async ({ page }, title) => { + await expect + .poll( + async () => { + const canvas = await visibleCanvas(page); + const box = await barBox(page, title); + + return box.x >= canvas.left && box.x + box.width <= canvas.right; + }, + { timeout: 10_000 } + ) + .toBe(true); +}); + +When('I click the timeline Today button', async ({ page }) => { + await TimelineSelectors.today(page).click(); +}); + +When('I drag the {string} bar {int} columns later', async ({ page }, title, columns) => { + await remember(page, 'Design', 'Build'); + await dragBarBy(page, title, columns * MONTH_COLUMN_WIDTH); +}); + +When('I drag the {string} bar {int} columns earlier', async ({ page }, title, columns) => { + await remember(page, 'Design', 'Build'); + await dragBarBy(page, title, -columns * MONTH_COLUMN_WIDTH); +}); + +Then('the {string} bar moved {int} columns later', async ({ page }, title, columns) => { + await expectBarX(page, title, before(page, title).x + columns * MONTH_COLUMN_WIDTH); +}); + +When('I press undo', async ({ page }) => { + await page.keyboard.press('Control+z'); +}); + +Then('the {string} bar is back where it started', async ({ page }, title) => { + const box = before(page, title); + + await expectBarX(page, title, box.x); + await expectBarWidth(page, title, box.width); +}); + +When('I drag the end handle of {string} {int} columns later', async ({ page }, title, columns) => { + await remember(page, 'Design', 'Build'); + await dragHandleBy(page, TimelineSelectors.handleEnd(page, rowId(page, title)), columns * MONTH_COLUMN_WIDTH); +}); + +Then('the {string} bar grew by {int} columns', async ({ page }, title, columns) => { + await expectBarWidth(page, title, before(page, title).width + columns * MONTH_COLUMN_WIDTH); +}); + +Then('the header highlights nothing once the drag ends', async ({ page }) => { + await expect(TimelineSelectors.headerHighlight(page)).toHaveCount(0); + await expect(TimelineSelectors.dragLabel(page)).toHaveCount(0); +}); + +When('I start dragging the {string} bar and press Escape', async ({ page }, title) => { + await remember(page, title); + const box = before(page, title); + const x = box.x + box.width / 2; + const y = box.y + box.height / 2; + + await page.mouse.move(x, y); + await page.mouse.down(); + for (let step = 1; step <= 4; step += 1) await page.mouse.move(x + step * MONTH_COLUMN_WIDTH, y); + // Mid-drag the header echoes the span and the bar shows its date label. + await expect(TimelineSelectors.headerHighlight(page)).toBeVisible(); + await expect(TimelineSelectors.dragLabel(page)).toBeVisible(); + await page.keyboard.press('Escape'); + await page.mouse.up(); +}); + +When('I hover the {string} bar', async ({ page }, title) => { + const box = await barBox(page, title); + + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); +}); + +Then('the timeline hover card shows {string} with a one day duration', async ({ page }, title) => { + const card = TimelineSelectors.hoverCard(page); + + await expect(card).toBeVisible({ timeout: 5_000 }); + await expect(card).toContainText(title); + await expect(card).toContainText('1 day'); +}); + +When('I click the table row {string}', async ({ page }, title) => { + await TimelineSelectors.sidebarRow(page, rowId(page, title)).click(); +}); + +Then('the {string} row and bar are selected', async ({ page }, title) => { + const id = rowId(page, title); + + await expect(TimelineSelectors.row(page, id)).toHaveAttribute('data-selected', 'true'); + await expect(TimelineSelectors.bar(page, id)).toHaveAttribute('data-selected', 'true'); +}); + +When('I click the empty canvas of the {string} row', async ({ page }, title) => { + // Far right of the visible canvas, well clear of any bar. + const view = await TimelineSelectors.view(page).boundingBox(); + const rowBox = await TimelineSelectors.row(page, rowId(page, title)).boundingBox(); + + if (!view || !rowBox) throw new Error('Timeline row is not visible'); + await page.mouse.click(view.x + view.width - 60, rowBox.y + rowBox.height / 2); +}); + +Then('no timeline row is selected', async ({ page }) => { + await expect(page.locator('[data-testid^="timeline-row-"][data-selected="true"]')).toHaveCount(0); +}); + +When('I open the table row {string}', async ({ page }, title) => { + const id = rowId(page, title); + + await TimelineSelectors.sidebarRow(page, id).hover(); + await TimelineSelectors.openRow(page, id).click(); +}); + +Then('the row detail for {string} opens', async ({ page }, title) => { + await expect(RowDetailSelectors.titleInput(page)).toBeVisible(); + await expect(RowDetailSelectors.titleInput(page)).toHaveText(title); + await closeRowDetailWithEscape(page); +}); + +When('I add a new timeline row', async ({ page }) => { + await TimelineSelectors.newRow(page).click(); + await expect(TimelineSelectors.sidebarRows(page)).toHaveCount(3, { timeout: 15_000 }); + await closeRowDetailWithEscape(page); + const state = scenario(page); + + state.rowIds = await activeViewRowIds(page); +}); + +Then('the timeline table lists {int} rows and the No Date button reads {string}', async ({ page }, count, text) => { + await expect(TimelineSelectors.sidebarRows(page)).toHaveCount(count); + await expect(TimelineSelectors.noDateButton(page)).toContainText(text); +}); + +When("I click the undated row's canvas", async ({ page }) => { + const emptyRow = TimelineSelectors.emptyRows(page).first(); + const testId = await emptyRow.getAttribute('data-testid'); + const id = testId?.replace('timeline-row-empty-', ''); + + if (!id) throw new Error('No undated row to date'); + await clickRowCanvas(page, id); +}); + +Then('the timeline shows {int} bars and no No Date button', async ({ page }, count) => { + await expect(TimelineSelectors.bars(page)).toHaveCount(count, { timeout: 10_000 }); + await expect(TimelineSelectors.noDateButton(page)).toHaveCount(0); +}); + +When('I hide the timeline table', async ({ page }) => { + await TimelineSelectors.toggleTable(page).click(); +}); + +When('I show the timeline table', async ({ page }) => { + await TimelineSelectors.toggleTable(page).click(); +}); + +Then('the timeline table is hidden and {int} bars remain', async ({ page }, count) => { + await expect(TimelineSelectors.sidebarRows(page)).toHaveCount(0); + await expect(TimelineSelectors.bars(page)).toHaveCount(count); +}); + +Then('the timeline table lists {int} rows', async ({ page }, count) => { + await expect(TimelineSelectors.sidebarRows(page)).toHaveCount(count); +}); + +Given('{string} depends on {string} through a relation field', async ({ page }, dependent, dependency) => { + const { databaseId } = await getCurrentDatabaseInfo(page); + const state = scenario(page); + const dependentIndex = state.rowIds.indexOf(rowId(page, dependent)); + + await injectFieldDirect(page, { + fieldId: 'rel-deps', + name: 'Blocked by', + fieldType: FieldType.Relation, + typeOption: { database_id: databaseId, is_two_way: false, source_limit: 0, target_limit: 0 }, + }); + await setRelationCellDirect(page, 'rel-deps', dependentIndex, [rowId(page, dependency)]); + await chooseTimelineSettingsOption(page, 'timeline-dependency-field-rel-deps'); +}); + +Then('the timeline draws {int} dependency arrow', async ({ page }, count) => { + await expect(TimelineSelectors.arrows(page)).toHaveCount(count, { timeout: 15_000 }); +}); + +Then('the {string} bar starts where the {string} bar starts', async ({ page }, title, other) => { + await expectBarX(page, title, (await barBox(page, other)).x); +}); + +Given('{string} has a progress field at {int} percent', async ({ page }, title, percent) => { + await injectFieldDirect(page, { + fieldId: 'num-progress', + name: 'Progress', + fieldType: FieldType.Number, + typeOption: { format: 0 }, + }); + await setTextCellDirect(page, rowId(page, title), 'num-progress', FieldType.Number, String(percent)); + await chooseTimelineSettingsOption(page, 'timeline-progress-field-num-progress'); +}); + +Then('the {string} bar shows {int} percent progress', async ({ page }, title, percent) => { + await expect.poll(() => readProgressPercent(page, rowId(page, title)), { timeout: 15_000 }).toBe(percent); +}); + +Then('the timeline hover card for {string} mentions {string}', async ({ page }, title, text) => { + const box = await barBox(page, title); + + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await expect(TimelineSelectors.hoverCard(page)).toContainText(text, { timeout: 5_000 }); +}); + +When('I drag the progress handle of {string} halfway across the bar', async ({ page }, title) => { + const box = await barBox(page, title); + + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await dragHandleBy(page, TimelineSelectors.handleProgress(page, rowId(page, title)), box.width / 2); +}); + +Then('the {string} bar shows more than {int} percent progress', async ({ page }, title, percent) => { + await expect.poll(() => readProgressPercent(page, rowId(page, title)), { timeout: 10_000 }).toBeGreaterThan(percent); +}); + +// --------------------------------------------------------------------------- +// Scales, redo, pills, No Date list, keyboard, settings, layout, empty state +// --------------------------------------------------------------------------- + +const LAYOUT_BY_NAME: Record = { + Grid: DatabaseViewLayout.Grid, + Board: DatabaseViewLayout.Board, + Calendar: DatabaseViewLayout.Calendar, + Timeline: DatabaseViewLayout.Timeline, +}; + +Then('the timeline header has labels', async ({ page }) => { + // Cell presets label columns; the Year preset labels month segments instead. + const labelled = TimelineSelectors.header(page).locator('span').filter({ hasText: /\S/ }); + + await expect(labelled.first()).toBeVisible(); + expect(await labelled.count()).toBeGreaterThan(0); +}); + +When('I press redo', async ({ page }) => { + await page.keyboard.press('Control+Shift+z'); +}); + +When('I click the right off-screen pill', async ({ page }) => { + await TimelineSelectors.offscreenRight(page).first().click(); +}); + +When('I open the No Date list', async ({ page }) => { + await TimelineSelectors.noDateButton(page).click(); +}); + +Then('the No Date list shows {int} undated row', async ({ page }, count) => { + await expect(page.getByTestId('no-date-row')).toHaveCount(count); +}); + +When('I close the No Date list', async ({ page }) => { + await page.keyboard.press('Escape'); + await expect(page.getByTestId('no-date-row')).toHaveCount(0); +}); + +When('I focus the {string} bar and press Enter', async ({ page }, title) => { + await TimelineSelectors.barButton(page, title).focus(); + await page.keyboard.press('Enter'); +}); + +When('I double-click the table row {string}', async ({ page }, title) => { + await TimelineSelectors.sidebarRow(page, rowId(page, title)).dblclick(); +}); + +Given('{string} also has a {string} field {int} days later', async ({ page }, title, fieldName, days) => { + const state = scenario(page); + + await injectFieldDirect(page, { + fieldId: 'date-ship', + name: fieldName, + fieldType: FieldType.DateTime, + typeOption: { date_format: 0, time_format: 0, timezone_id: '' }, + }); + const design = await barBox(page, 'Design'); + const other = rowId(page, title); + const timestamp = await page.evaluate((offsetDays) => { + const date = new Date(); + + date.setHours(0, 0, 0, 0); + date.setDate(date.getDate() + offsetDays); + return String(Math.floor(date.getTime() / 1000)); + }, days); + + // Every row needs a value on the new field so the rows stay dated; Design keeps today. + await setTextCellDirect(page, rowId(page, 'Design'), 'date-ship', FieldType.DateTime, await page.evaluate(() => { + const date = new Date(); + + date.setHours(0, 0, 0, 0); + return String(Math.floor(date.getTime() / 1000)); + })); + await setTextCellDirect(page, other, 'date-ship', FieldType.DateTime, timestamp); + state.before.set('Design', design); +}); + +When('I choose {string} as the timeline date field', async ({ page }, fieldName) => { + await page.getByTestId('database-actions-settings').click(); + await TimelineSelectors.settingsTrigger(page).click(); + await page.locator('[data-testid^="timeline-date-field-"]').filter({ hasText: fieldName }).click(); + await page.keyboard.press('Escape'); + await page.keyboard.press('Escape'); +}); + +Then('the {string} bar sits {int} days after the {string} bar', async ({ page }, title, days, other) => { + await expectBarX(page, title, (await barBox(page, other)).x + days * MONTH_COLUMN_WIDTH); +}); + +When('I toggle the table from the timeline settings', async ({ page }) => { + await chooseTimelineSettingsOption(page, 'timeline-show-table'); +}); + +When('I choose Monday as the timeline week start', async ({ page }) => { + await chooseTimelineSettingsOption(page, 'timeline-first-day-1'); +}); + +Then('the timeline quarter labels fall on Mondays', async ({ page }) => { + // Quarter labels sit on week starts; the first week of each month carries the + // month name ("Oct 5"), which is enough to resolve the weekday unambiguously. + const labels = await TimelineSelectors.header(page).locator('span').filter({ hasText: /^[A-Z][a-z]{2} \d{1,2}$/ }).allTextContents(); + const year = new Date().getFullYear(); + + expect(labels.length).toBeGreaterThan(0); + for (const label of labels) { + const [monthName, day] = label.trim().split(' '); + const month = new Date(`${monthName} 1, ${year}`).getMonth(); + + expect(new Date(year, month, Number(day)).getDay()).toBe(1); + } +}); + +When('I switch to the {string} view tab', async ({ page }, name) => { + await DatabaseViewSelectors.viewTab(page).filter({ hasText: name }).first().click(); +}); + +When('I change the view layout to {string}', async ({ page }, name) => { + const layout = LAYOUT_BY_NAME[name]; + + if (layout === undefined) throw new Error(`Unknown layout "${name}"`); + await page.getByTestId('database-actions-settings').click(); + await DatabaseViewSelectors.layoutSettingsTrigger(page).hover(); + await expect(DatabaseViewSelectors.layoutOption(page, layout)).toBeVisible({ timeout: 10_000 }); + await DatabaseViewSelectors.layoutOption(page, layout).click(); + if (layout === DatabaseViewLayout.Timeline) { + await expect(TimelineSelectors.view(page)).toBeVisible({ timeout: 30_000 }); + await waitForDatabaseTestContext(page); + } +}); + +Then('the calendar view is shown', async ({ page }) => { + await expect(CalendarSelectors.calendarContainer(page).first()).toBeVisible({ timeout: 30_000 }); + await expect(TimelineSelectors.view(page)).toHaveCount(0); +}); + +When("the timeline's date field is deleted from the database", async ({ page }) => { + await page.evaluate(() => { + const ctx = (window as unknown as { __TEST_DATABASE_CONTEXT__: any }).__TEST_DATABASE_CONTEXT__; + const doc = ctx.databaseDoc; + const database = doc.getMap('data').get('database'); + const view = database.get('views').get(ctx.activeViewId); + const fieldId = view.get('layout_settings').get('8').get('field_id'); + + doc.transact(() => { + database.get('fields').delete(fieldId); + database.get('views').forEach((candidate: any) => { + const orders = candidate.get('field_orders'); + const index = orders.toArray().findIndex((order: { id: string }) => order.id === fieldId); + + if (index >= 0) orders.delete(index, 1); + candidate.get('field_settings').delete(fieldId); + }); + }); + }); +}); + +Then('the timeline explains that it has no date property', async ({ page }) => { + await expect(page.getByTestId('timeline-unsupported')).toBeVisible({ timeout: 15_000 }); +}); diff --git a/playwright/e2e/database/timeline.spec.ts b/playwright/e2e/database/timeline.spec.ts new file mode 100644 index 000000000..09f076d05 --- /dev/null +++ b/playwright/e2e/database/timeline.spec.ts @@ -0,0 +1,224 @@ +import { expect, test, type Page } from '@playwright/test'; + +import { getCurrentDatabaseInfo, setRelationCellDirect, waitForDatabaseTestContext } from '../../support/relation-test-helpers'; +import { closeRowDetailWithEscape } from '../../support/row-detail-helpers'; +import { TimelineSelectors } from '../../support/selectors'; +import { generateRandomEmail } from '../../support/test-config'; +import { + activeViewRowIds, + addTimelineView, + barBox as sharedBarBox, + chooseTimelineSettingsOption, + clickRowCanvas, + dragBy, + injectFieldDirect, + loginAndCreateCalendarWithRows, + MONTH_COLUMN_WIDTH, + setTextCellDirect, + TimelineLayout, +} from '../../support/timeline-test-helpers'; + +const SCREENSHOT_DIR = process.env.TIMELINE_SCREENSHOT_DIR; +const BARS = '[data-testid^="timeline-bar-"]'; +const SIDEBAR_ROWS = '[data-testid^="timeline-sidebar-row-"]'; + +async function barBox(page: Page, title: string) { + return { bar: TimelineSelectors.barByTitle(page, title), box: await sharedBarBox(page, title) }; +} + +test.describe('Timeline view', () => { + test('a Timeline view plots calendar rows as bars and switches scale', async ({ page, request }) => { + test.setTimeout(240_000); + await loginAndCreateCalendarWithRows(page, request, generateRandomEmail(), [ + { title: 'Design review', offsetDays: 0 }, + { title: 'Launch', offsetDays: 3 }, + ]); + await addTimelineView(page, 2); + + const view = page.getByTestId('timeline-view'); + + await expect(page.getByTestId('timeline-header-today')).toBeVisible(); + await expect(page.getByTestId('timeline-today-line')).toBeVisible(); + await expect(page.locator(BARS)).toHaveCount(2, { timeout: 15_000 }); + await expect(page.locator(SIDEBAR_ROWS)).toHaveCount(2); + await expect(view).toContainText('Design review'); + await expect(view).toContainText('Launch'); + await expect(page.getByTestId('timeline-zoom-trigger')).toHaveText(/Month/); + await expect(page.getByTestId('timeline-title')).toHaveText(/\d{4}$/); + + if (SCREENSHOT_DIR) await page.screenshot({ path: `${SCREENSHOT_DIR}/timeline-month.png`, fullPage: false }); + + // Drag "Launch" two columns later: the bar moves by exactly two column widths. + const columnWidth = MONTH_COLUMN_WIDTH; + const before = await barBox(page, 'Launch'); + + await dragBy(page, before.box.x + before.box.width / 2, before.box.y + before.box.height / 2, columnWidth * 2); + await expect + .poll(async () => (await barBox(page, 'Launch')).box.x, { timeout: 10_000 }) + .toBeCloseTo(before.box.x + columnWidth * 2, 0); + + // Resize "Design review" from its right edge to span three days. + const design = await barBox(page, 'Design review'); + const handle = page.getByTestId(/^timeline-handle-end-/).first(); + const handleBox = await handle.boundingBox(); + + if (!handleBox) throw new Error('Resize handle missing'); + await dragBy(page, handleBox.x + handleBox.width / 2, handleBox.y + handleBox.height / 2, columnWidth * 2); + await expect + .poll(async () => (await barBox(page, 'Design review')).box.width, { timeout: 10_000 }) + .toBeCloseTo(design.box.width + columnWidth * 2, 0); + + // Undo restores the original length through the database history scope. + await page.keyboard.press('Control+z'); + await expect + .poll(async () => (await barBox(page, 'Design review')).box.width, { timeout: 10_000 }) + .toBeCloseTo(design.box.width, 0); + + if (SCREENSHOT_DIR) await page.screenshot({ path: `${SCREENSHOT_DIR}/timeline-after-drag.png`, fullPage: false }); + + await page.getByTestId('timeline-zoom-trigger').click(); + await page.getByTestId(`timeline-zoom-${TimelineLayout.Week}`).click(); + await expect(page.getByTestId('timeline-zoom-trigger')).toHaveText(/Week/); + // Calendar-style week header: weekday name and day number ("Mon 15", "Thu Oct 1"). + await expect(page.getByTestId('timeline-header-today')).toHaveText(/^[A-Z][a-z]{2} (?:[A-Z][a-z]{2} )?\d{1,2}$/); + await expect(page.locator(BARS)).toHaveCount(2); + + if (SCREENSHOT_DIR) await page.screenshot({ path: `${SCREENSHOT_DIR}/timeline-week.png`, fullPage: false }); + + // The scale persists on the view: reloading reopens at Week. + await page.reload({ waitUntil: 'domcontentloaded' }); + await expect(page.getByTestId('timeline-view')).toBeVisible({ timeout: 30_000 }); + await expect(page.getByTestId('timeline-zoom-trigger')).toHaveText(/Week/); + + // A new row is undated: it sits in the table, in "No date", and a click on its + // canvas assigns the clicked day. + await page.getByTestId('timeline-new-row').click(); + await expect(page.locator(SIDEBAR_ROWS)).toHaveCount(3, { timeout: 15_000 }); + await closeRowDetailWithEscape(page); + await expect(page.locator('.no-date-button')).toContainText('(1)'); + // The undated row's canvas spans the whole scrolled range, so click inside + // the visible part of it, just right of the docked table. + const emptyId = (await page.locator('[data-testid^="timeline-row-empty-"]').first().getAttribute('data-testid')) + ?.replace('timeline-row-empty-', ''); + + if (!emptyId) throw new Error('Undated row canvas missing'); + await clickRowCanvas(page, emptyId); + await expect(page.locator(BARS)).toHaveCount(3, { timeout: 10_000 }); + await expect(page.locator('.no-date-button')).toHaveCount(0); + + // Hiding the table keeps every bar. + await page.getByTestId('timeline-toggle-table').click(); + await expect(page.locator(SIDEBAR_ROWS)).toHaveCount(0); + await expect(page.locator(BARS)).toHaveCount(3); + + if (SCREENSHOT_DIR) await page.screenshot({ path: `${SCREENSHOT_DIR}/timeline-no-table.png`, fullPage: false }); + }); +}); + +test.describe('Timeline dependencies and progress', () => { + test('arrows, move-with-dependents, progress handle, hover card and selection', async ({ page, request }) => { + test.setTimeout(240_000); + await loginAndCreateCalendarWithRows(page, request, generateRandomEmail(), [ + { title: 'Design', offsetDays: 0 }, + { title: 'Build', offsetDays: 2 }, + ]); + await addTimelineView(page, 2); + const view = page.getByTestId('timeline-view'); + + await waitForDatabaseTestContext(page); + const { databaseId } = await getCurrentDatabaseInfo(page); + const rowIds = await activeViewRowIds(page); + const [designId, buildId] = rowIds; + + await injectFieldDirect(page, { + fieldId: 'rel-deps', + name: 'Blocked by', + fieldType: 10, + typeOption: { database_id: databaseId, is_two_way: false, source_limit: 0, target_limit: 0 }, + }); + await injectFieldDirect(page, { fieldId: 'num-progress', name: 'Progress', fieldType: 1, typeOption: { format: 0 } }); + // Build depends on Design; Design is 40% done. + await setRelationCellDirect(page, 'rel-deps', 1, [designId]); + await setTextCellDirect(page, designId, 'num-progress', 1, '40'); + + await chooseTimelineSettingsOption(page, 'timeline-dependency-field-rel-deps'); + await expect(page.locator('[data-testid="timeline-arrow"]')).toHaveCount(1, { timeout: 15_000 }); + + await chooseTimelineSettingsOption(page, 'timeline-progress-field-num-progress'); + await expect(page.getByTestId(`timeline-progress-${designId}`)).toHaveAttribute('style', /width: 40%/, { + timeout: 15_000, + }); + + // Moving the predecessor drags its dependent along by the same distance. + const columnWidth = MONTH_COLUMN_WIDTH; + const designBefore = await barBox(page, 'Design'); + const buildBefore = await barBox(page, 'Build'); + + await dragBy(page, designBefore.box.x + designBefore.box.width / 2, designBefore.box.y + designBefore.box.height / 2, columnWidth * 2); + await expect + .poll(async () => (await barBox(page, 'Design')).box.x, { timeout: 10_000 }) + .toBeCloseTo(designBefore.box.x + columnWidth * 2, 0); + await expect + .poll(async () => (await barBox(page, 'Build')).box.x, { timeout: 10_000 }) + .toBeCloseTo(buildBefore.box.x + columnWidth * 2, 0); + // One undo reverts both bars together. + await page.keyboard.press('Control+z'); + await expect.poll(async () => (await barBox(page, 'Build')).box.x, { timeout: 10_000 }).toBeCloseTo(buildBefore.box.x, 0); + await expect.poll(async () => (await barBox(page, 'Design')).box.x, { timeout: 10_000 }).toBeCloseTo(designBefore.box.x, 0); + await expect(page.locator('[data-testid="timeline-arrow"]')).toHaveCount(1); + + // The dependent cannot be dragged before its dependency's start. + await dragBy(page, buildBefore.box.x + buildBefore.box.width / 2, buildBefore.box.y + buildBefore.box.height / 2, -columnWidth * 6); + // Compare against Design's live position: the drag may auto-scroll the canvas. + await expect + .poll(async () => (await barBox(page, 'Build')).box.x - (await barBox(page, 'Design')).box.x, { timeout: 10_000 }) + .toBeCloseTo(0, 0); + + // Resize Design to three days so the progress handle has room, then drag it. + const endHandle = page.getByTestId(`timeline-handle-end-${designId}`); + const endBox = await endHandle.boundingBox(); + + if (!endBox) throw new Error('Resize handle missing'); + await dragBy(page, endBox.x + endBox.width / 2, endBox.y + endBox.height / 2, columnWidth * 3); + await expect + .poll(async () => (await barBox(page, 'Design')).box.width, { timeout: 10_000 }) + .toBeCloseTo(designBefore.box.width + columnWidth * 3, 0); + + const designBar = await barBox(page, 'Design'); + + await page.mouse.move(designBar.box.x + designBar.box.width / 2, designBar.box.y + designBar.box.height / 2); + // Radix also mounts a visually hidden copy for screen readers; assert on the tooltip role. + const hoverCard = page.getByRole('tooltip').getByTestId('timeline-bar-hover-card'); + + await expect(hoverCard).toBeVisible({ timeout: 5_000 }); + await expect(hoverCard).toContainText('Design'); + await expect(hoverCard).toContainText('40% complete'); + + const progressHandle = page.getByTestId(`timeline-handle-progress-${designId}`); + const handleBox = await progressHandle.boundingBox(); + + if (!handleBox) throw new Error('Progress handle missing'); + await dragBy(page, handleBox.x + handleBox.width / 2, handleBox.y + handleBox.height / 2, designBar.box.width / 2); + await expect + .poll(async () => { + const style = (await page.getByTestId(`timeline-progress-${designId}`).getAttribute('style')) ?? ''; + const match = /width: (\d+)%/.exec(style); + + return match ? Number(match[1]) : -1; + }, { timeout: 10_000 }) + .toBeGreaterThan(80); + + // Selecting from the table highlights the bar; clicking the empty grid clears it. + await page.getByTestId(`timeline-sidebar-row-${buildId}`).click(); + await expect(page.getByTestId(`timeline-row-${buildId}`)).toHaveAttribute('data-selected', 'true'); + await expect(page.getByTestId(`timeline-bar-${buildId}`)).toHaveAttribute('data-selected', 'true'); + const buildRowCanvas = await page.getByTestId(`timeline-row-${buildId}`).boundingBox(); + const viewBox = await view.boundingBox(); + + if (!buildRowCanvas || !viewBox) throw new Error('Row missing'); + await page.mouse.click(viewBox.x + viewBox.width - 60, buildRowCanvas.y + buildRowCanvas.height / 2); + await expect(page.getByTestId(`timeline-row-${buildId}`)).not.toHaveAttribute('data-selected', 'true'); + + if (SCREENSHOT_DIR) await page.screenshot({ path: `${SCREENSHOT_DIR}/timeline-dependencies.png`, fullPage: false }); + }); +}); diff --git a/playwright/support/selectors.ts b/playwright/support/selectors.ts index c2867efed..1d302890a 100644 --- a/playwright/support/selectors.ts +++ b/playwright/support/selectors.ts @@ -866,3 +866,46 @@ export const RevertedDialogSelectors = { dialog: (page: Page) => page.getByTestId('reverted-dialog'), confirmButton: (page: Page) => page.getByTestId('reverted-dialog-confirm'), }; + +/** + * Timeline view selectors + */ +export const TimelineSelectors = { + view: (page: Page) => page.getByTestId('timeline-view'), + header: (page: Page) => page.getByTestId('timeline-header'), + toolbar: (page: Page) => page.getByTestId('timeline-toolbar'), + title: (page: Page) => page.getByTestId('timeline-title'), + headerToday: (page: Page) => page.getByTestId('timeline-header-today'), + headerSegments: (page: Page) => page.getByTestId('timeline-header-segment'), + headerHighlight: (page: Page) => page.getByTestId('timeline-header-highlight'), + todayLine: (page: Page) => page.getByTestId('timeline-today-line'), + bars: (page: Page) => page.locator('[data-testid^="timeline-bar-"]'), + bar: (page: Page, rowId: string) => page.getByTestId(`timeline-bar-${rowId}`), + barByTitle: (page: Page, title: string) => page.locator('[data-testid^="timeline-bar-"]').filter({ hasText: title }).first(), + barButton: (page: Page, title: string) => + page.locator('[data-testid^="timeline-bar-"]').filter({ hasText: title }).first().locator('[role="button"]'), + handleStart: (page: Page, rowId: string) => page.getByTestId(`timeline-handle-start-${rowId}`), + handleEnd: (page: Page, rowId: string) => page.getByTestId(`timeline-handle-end-${rowId}`), + handleProgress: (page: Page, rowId: string) => page.getByTestId(`timeline-handle-progress-${rowId}`), + progressFill: (page: Page, rowId: string) => page.getByTestId(`timeline-progress-${rowId}`), + dragLabel: (page: Page) => page.getByTestId('timeline-drag-label'), + hoverCard: (page: Page) => page.getByRole('tooltip').getByTestId('timeline-bar-hover-card'), + row: (page: Page, rowId: string) => page.getByTestId(`timeline-row-${rowId}`), + sidebarRows: (page: Page) => page.locator('[data-testid^="timeline-sidebar-row-"]'), + sidebarRow: (page: Page, rowId: string) => page.getByTestId(`timeline-sidebar-row-${rowId}`), + openRow: (page: Page, rowId: string) => page.getByTestId(`timeline-open-row-${rowId}`), + emptyRows: (page: Page) => page.locator('[data-testid^="timeline-row-empty-"]'), + offscreenLeft: (page: Page) => page.getByTestId('timeline-offscreen-left'), + offscreenRight: (page: Page) => page.getByTestId('timeline-offscreen-right'), + arrows: (page: Page) => page.locator('[data-testid="timeline-arrow"]'), + zoomTrigger: (page: Page) => page.getByTestId('timeline-zoom-trigger'), + zoomOption: (page: Page, zoom: number) => page.getByTestId(`timeline-zoom-${zoom}`), + today: (page: Page) => page.getByTestId('timeline-today'), + stepPrevious: (page: Page) => page.getByTestId('timeline-step-previous'), + stepNext: (page: Page) => page.getByTestId('timeline-step-next'), + toggleTable: (page: Page) => page.getByTestId('timeline-toggle-table'), + newRow: (page: Page) => page.getByTestId('timeline-new-row'), + noDateButton: (page: Page) => page.locator('.no-date-button'), + settingsTrigger: (page: Page) => page.getByTestId('timeline-settings-trigger'), + addViewOption: (page: Page) => page.getByTestId('add-timeline-view-button'), +}; diff --git a/playwright/support/timeline-test-helpers.ts b/playwright/support/timeline-test-helpers.ts new file mode 100644 index 000000000..6291b86a6 --- /dev/null +++ b/playwright/support/timeline-test-helpers.ts @@ -0,0 +1,227 @@ +import { expect, type APIRequestContext, type Page } from '@playwright/test'; + +import { TimelineLayout } from '../../src/application/database-yjs/database.type'; + +import { calendarDraftEditor, calendarDraftTitle } from './calendar-placeholder-helpers'; +import { loginAndCreateCalendar } from './calendar-test-helpers'; +import { DatabaseViewSelectors, TimelineSelectors } from './selectors'; + +/** Column width of the Month preset (`TIMELINE_SCALE_PRESETS[Month].columnWidth`). */ +export const MONTH_COLUMN_WIDTH = 36; +/** Docked table width (`TIMELINE_SIDEBAR_WIDTH`). */ +export const TIMELINE_SIDEBAR_WIDTH = 280; + +export { TimelineLayout }; + +export interface BarBox { + x: number; + y: number; + width: number; + height: number; +} + +function isoDate(offsetDays: number) { + const date = new Date(); + + date.setHours(0, 0, 0, 0); + date.setDate(date.getDate() + offsetDays); + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; +} + +/** Create an all-day row through the calendar's placeholder editor. */ +export async function createCalendarEvent(page: Page, offsetDays: number, title: string) { + const cell = page.locator(`.fc-daygrid-day[data-date="${isoDate(offsetDays)}"]`); + + await cell.scrollIntoViewIfNeeded(); + const box = await cell.boundingBox(); + + if (!box) throw new Error(`No calendar day cell for offset ${offsetDays}`); + await page.mouse.click(box.x + box.width / 2, box.y + 10); + await expect(calendarDraftEditor(page)).toBeVisible(); + await calendarDraftTitle(page).fill(title); + await calendarDraftTitle(page).press('Enter'); + await expect(calendarDraftEditor(page)).toHaveCount(0); +} + +/** Sign in, create a calendar database, and add the given all-day rows. */ +export async function loginAndCreateCalendarWithRows( + page: Page, + request: APIRequestContext, + email: string, + rows: { title: string; offsetDays: number }[] +) { + await loginAndCreateCalendar(page, request, email); + for (const row of rows) { + await createCalendarEvent(page, row.offsetDays, row.title); + } + + await page.waitForTimeout(1500); +} + +/** Add a Timeline view from the view tabs' + menu and wait for it to render. */ +export async function addTimelineView(page: Page, expectedBars: number) { + await DatabaseViewSelectors.addViewButton(page).click(); + await TimelineSelectors.addViewOption(page).click(); + await expect(TimelineSelectors.view(page)).toBeVisible({ timeout: 30_000 }); + await expect(TimelineSelectors.bars(page)).toHaveCount(expectedBars, { timeout: 15_000 }); +} + +export async function barBox(page: Page, title: string): Promise { + const box = await TimelineSelectors.barButton(page, title).boundingBox(); + + if (!box) throw new Error(`Bar "${title}" is not visible`); + return box; +} + +/** Press, travel `dx` pixels in small steps (crossing the drag threshold), release. */ +export async function dragBy(page: Page, x: number, y: number, dx: number) { + await page.mouse.move(x, y); + await page.mouse.down(); + for (let step = 1; step <= 6; step += 1) { + await page.mouse.move(x + (dx * step) / 6, y); + } + + await page.mouse.up(); +} + +export async function dragBarBy(page: Page, title: string, dx: number) { + const box = await barBox(page, title); + + await dragBy(page, box.x + box.width / 2, box.y + box.height / 2, dx); +} + +export async function dragHandleBy(page: Page, handle: ReturnType, dx: number) { + const box = await handle.boundingBox(); + + if (!box) throw new Error('Handle is not visible'); + await dragBy(page, box.x + box.width / 2, box.y + box.height / 2, dx); +} + +export async function expectBarX(page: Page, title: string, x: number) { + await expect.poll(async () => (await barBox(page, title)).x, { timeout: 10_000 }).toBeCloseTo(x, 0); +} + +export async function expectBarWidth(page: Page, title: string, width: number) { + await expect.poll(async () => (await barBox(page, title)).width, { timeout: 10_000 }).toBeCloseTo(width, 0); +} + +/** Row ids of the active view, in view order. */ +export async function activeViewRowIds(page: Page): Promise { + return page.evaluate(() => { + const ctx = (window as unknown as { __TEST_DATABASE_CONTEXT__: any }).__TEST_DATABASE_CONTEXT__; + const database = ctx.databaseDoc.getMap('data').get('database'); + + return database.get('views').get(ctx.activeViewId).get('row_orders').toArray().map((row: { id: string }) => row.id); + }); +} + +/** Test-only: inject a field straight into the database doc (no UI). */ +export async function injectFieldDirect( + page: Page, + options: { fieldId: string; name: string; fieldType: number; typeOption?: Record } +) { + await page.evaluate((options) => { + const win = window as unknown as { __TEST_DATABASE_CONTEXT__: any; Y: any }; + const ctx = win.__TEST_DATABASE_CONTEXT__; + const Y = win.Y; + const doc = ctx.databaseDoc; + const database = doc.getMap('data').get('database'); + const now = String(Math.floor(Date.now() / 1000)); + const field = new Y.Map(); + const typeOptionMap = new Y.Map(); + const typeOption = new Y.Map(); + + field.set('name', options.name); + field.set('id', options.fieldId); + field.set('ty', options.fieldType); + field.set('created_at', now); + field.set('last_modified', now); + field.set('is_primary', false); + field.set('icon', ''); + Object.entries(options.typeOption ?? {}).forEach(([key, value]) => typeOption.set(key, value)); + typeOptionMap.set(String(options.fieldType), typeOption); + field.set('type_option', typeOptionMap); + + doc.transact(() => { + database.get('fields').set(options.fieldId, field); + database.get('views').forEach((view: any) => { + const fieldOrders = view.get('field_orders'); + + if (!fieldOrders.toArray().some((order: { id: string }) => order.id === options.fieldId)) { + fieldOrders.push([{ id: options.fieldId }]); + } + + const fieldSettings = view.get('field_settings'); + + if (!fieldSettings.get(options.fieldId)) { + const setting = new Y.Map(); + + setting.set('visibility', 0); + fieldSettings.set(options.fieldId, setting); + } + }); + }); + }, options); +} + +/** Test-only: write a plain-text cell (e.g. a Number) straight into a row doc. */ +export async function setTextCellDirect(page: Page, rowId: string, fieldId: string, fieldType: number, data: string) { + await page.evaluate( + async ({ rowId, fieldId, fieldType, data }) => { + const win = window as unknown as { __TEST_DATABASE_CONTEXT__: any; Y: any }; + const ctx = win.__TEST_DATABASE_CONTEXT__; + const Y = win.Y; + const rowDoc = ctx.rowMap?.[rowId] ?? (await ctx.ensureRow(rowId)); + const now = String(Math.floor(Date.now() / 1000)); + + rowDoc.transact(() => { + const row = rowDoc.getMap('data').get('data'); + const cells = row.get('cells'); + let cell = cells.get(fieldId); + + if (!cell) { + cell = new Y.Map(); + cells.set(fieldId, cell); + } + + cell.set('created_at', cell.get('created_at') || now); + cell.set('last_modified', now); + cell.set('field_type', fieldType); + cell.set('data', data); + row.set('last_modified', now); + }); + }, + { rowId, fieldId, fieldType, data } + ); +} + +/** Open ⚙ → Timeline settings → pick one option, then close the menus. */ +export async function chooseTimelineSettingsOption(page: Page, optionTestId: string) { + await page.getByTestId('database-actions-settings').click(); + await TimelineSelectors.settingsTrigger(page).click(); + await page.getByTestId(optionTestId).click(); + await page.keyboard.press('Escape'); + await page.keyboard.press('Escape'); +} + +export async function chooseTimelineZoom(page: Page, layout: TimelineLayout) { + await TimelineSelectors.zoomTrigger(page).click(); + await TimelineSelectors.zoomOption(page, layout).click(); +} + +/** Progress percentage rendered by the bar's fill, or -1 when absent. */ +export async function readProgressPercent(page: Page, rowId: string): Promise { + const style = (await TimelineSelectors.progressFill(page, rowId).getAttribute('style')) ?? ''; + const match = /width:\s*(\d+)%/.exec(style); + + return match ? Number(match[1]) : -1; +} + +/** Click inside the visible canvas of a row, `offset` px right of the docked table. */ +export async function clickRowCanvas(page: Page, rowId: string, offset = 120) { + const rowBox = await TimelineSelectors.row(page, rowId).boundingBox(); + const viewBox = await TimelineSelectors.view(page).boundingBox(); + + if (!rowBox || !viewBox) throw new Error('Timeline row is not visible'); + await page.mouse.click(viewBox.x + TIMELINE_SIDEBAR_WIDTH + offset, rowBox.y + rowBox.height / 2); +} diff --git a/src/@types/translations/en.json b/src/@types/translations/en.json index d5c738a79..de3fb1ee6 100644 --- a/src/@types/translations/en.json +++ b/src/@types/translations/en.json @@ -4345,5 +4345,41 @@ "revertedDialogTitle": "Page Restored", "revertedDialogDescription": "This page was restored to a previous version from another device.", "revertedDismiss": "Got it" + }, + "timeline": { + "menuName": "Timeline", + "settings": { + "name": "Timeline settings", + "layoutDateField": "Timeline by", + "firstDayOfWeek": "Start week on", + "showTable": "Show table", + "noDateTitle": "No date", + "noDatePopoverTitle": "Drag or click to assign a date", + "unsupportedTitle": "This timeline has no date property", + "unsupportedHint": "Add a date property to the database, or pick one in the timeline settings.", + "dependencies": "Dependencies", + "progress": "Progress" + }, + "zoom": { + "hours": "Hours", + "day": "Day", + "week": "Week", + "biWeek": "Bi-week", + "month": "Month", + "quarter": "Quarter", + "year": "Year" + }, + "today": "Today", + "previous": "Earlier", + "next": "Later", + "scrollToBar": "Scroll to item", + "newRow": "New", + "popup": { + "duration_one": "{{count}} day", + "duration_other": "{{count}} days", + "progress": "{{percent}}% complete" + }, + "dependencyBlocked": "Cannot start before its dependencies", + "openRow": "Open" } } diff --git a/src/application/constants.ts b/src/application/constants.ts index d14039338..ad7687543 100644 --- a/src/application/constants.ts +++ b/src/application/constants.ts @@ -12,6 +12,15 @@ export const HEADER_HEIGHT = 48; */ export const FORM_VIEW_CREATION_ENABLED = false; +/** + * Gate for offering the Timeline layout in the add-view and layout menus. + * + * Creating a Timeline view goes through the cloud (`ViewLayout::Timeline = 10`, + * `DatabaseLayout::Timeline = 8`), so this must stay off against a server that + * predates those enum values. Existing Timeline views render regardless. + */ +export const TIMELINE_VIEW_ENABLED = true; + /** * Server error codes from AppFlowy Cloud ErrorCode enum. * See: libs/app-error/src/lib.rs in AppFlowy-Cloud diff --git a/src/application/database-yjs/__tests__/timeline-layout.test.ts b/src/application/database-yjs/__tests__/timeline-layout.test.ts new file mode 100644 index 000000000..d764d97db --- /dev/null +++ b/src/application/database-yjs/__tests__/timeline-layout.test.ts @@ -0,0 +1,182 @@ +import * as Y from 'yjs'; + +import { YDatabase, YDatabaseView, YjsDatabaseKey, YjsEditorKey } from '@/application/types'; + +import { TimelineLayout } from '../database.type'; +import { + createTimelineLayoutStore, + initializeTimelineLayoutSetting, + readTimelineLayoutSetting, + TIMELINE_LAYOUT_KEY, + updateTimelineLayoutSetting, +} from '../timeline-layout'; + +function createFixture() { + const doc = new Y.Doc(); + const database = new Y.Map() as YDatabase; + + doc.getMap(YjsEditorKey.data_section).set(YjsEditorKey.database, database); + const views = new Y.Map(); + const view = new Y.Map() as YDatabaseView; + + database.set(YjsDatabaseKey.views, views); + views.set('timeline', view); + return { doc, database, view, views }; +} + +function sync(source: Y.Doc, target: Y.Doc) { + Y.applyUpdate(target, Y.encodeStateAsUpdate(source, Y.encodeStateVector(target)), 'remote'); +} + +test('missing setting falls back to month scale, docked table, and the user week start', () => { + const { database } = createFixture(); + + expect(readTimelineLayoutSetting(database, 'timeline', 1, false)).toEqual({ + fieldId: '', + layout: TimelineLayout.Month, + showTable: true, + firstDayOfWeek: 1, + dependencyFieldId: '', + progressFieldId: '', + use24Hour: false, + }); +}); + +test('initialize seeds the field once and repairs a stale field without touching the scale', () => { + const { doc, view, database } = createFixture(); + + doc.transact(() => initializeTimelineLayoutSetting(view, 'date')); + doc.transact(() => updateTimelineLayoutSetting(view, { layout: TimelineLayout.Week, showTable: false })); + doc.transact(() => initializeTimelineLayoutSetting(view, 'date')); + expect(readTimelineLayoutSetting(database, 'timeline', 0, false)).toMatchObject({ + fieldId: 'date', + layout: TimelineLayout.Week, + showTable: false, + }); + + doc.transact(() => initializeTimelineLayoutSetting(view, 'other-date')); + expect(readTimelineLayoutSetting(database, 'timeline', 0, false)).toMatchObject({ + fieldId: 'other-date', + layout: TimelineLayout.Week, + showTable: false, + }); +}); + +test('integers written by the server as BigInt decode like web numbers', () => { + // Yjs cannot author BigInt values, so stub the map chain the reader walks. + const values: Record = { + [YjsDatabaseKey.field_id]: 'date', + [YjsDatabaseKey.layout_ty]: BigInt(TimelineLayout.Quarter), + [YjsDatabaseKey.show_table]: false, + [YjsDatabaseKey.first_day_of_week_v2]: BigInt(1), + }; + const setting = { get: (key: string) => values[key] }; + const layouts = { get: (key: string) => (key === TIMELINE_LAYOUT_KEY ? setting : undefined) }; + const view = { get: (key: string) => (key === YjsDatabaseKey.layout_settings ? layouts : undefined) }; + const views = { get: (viewId: string) => (viewId === 'timeline' ? view : undefined) }; + const database = { get: (key: string) => (key === YjsDatabaseKey.views ? views : undefined) } as unknown as YDatabase; + + expect(readTimelineLayoutSetting(database, 'timeline', 0, false)).toEqual({ + fieldId: 'date', + layout: TimelineLayout.Quarter, + showTable: false, + firstDayOfWeek: 1, + dependencyFieldId: '', + progressFieldId: '', + use24Hour: false, + }); +}); + +test('dependency and progress bindings are optional keys that an empty id removes', () => { + const { doc, view, database } = createFixture(); + + doc.transact(() => + updateTimelineLayoutSetting(view, { fieldId: 'date', dependencyFieldId: 'rel', progressFieldId: 'num' }) + ); + expect(readTimelineLayoutSetting(database, 'timeline', 0, false)).toMatchObject({ + dependencyFieldId: 'rel', + progressFieldId: 'num', + }); + doc.transact(() => updateTimelineLayoutSetting(view, { dependencyFieldId: '' })); + const setting = view.get(YjsDatabaseKey.layout_settings).get(TIMELINE_LAYOUT_KEY); + + expect(setting.has(YjsDatabaseKey.dependency_field_id)).toBe(false); + expect(setting.get(YjsDatabaseKey.progress_field_id)).toBe('num'); +}); + +test('the store notifies on remote changes only for this view and tolerates bad values', () => { + const desktop = createFixture(); + const webDoc = new Y.Doc(); + + desktop.doc.transact(() => + updateTimelineLayoutSetting(desktop.view, { + fieldId: 'date', + layout: TimelineLayout.Quarter, + showTable: false, + firstDayOfWeek: 1, + }) + ); + const setting = desktop.view.get(YjsDatabaseKey.layout_settings).get(TIMELINE_LAYOUT_KEY); + + sync(desktop.doc, webDoc); + + const store = createTimelineLayoutStore(webDoc, 'timeline', 0, false); + const notify = jest.fn(); + const unsubscribe = store.subscribe(notify); + + expect(store.getSnapshot()).toEqual({ + fieldId: 'date', + layout: TimelineLayout.Quarter, + showTable: false, + firstDayOfWeek: 1, + dependencyFieldId: '', + progressFieldId: '', + use24Hour: false, + }); + + desktop.doc.transact(() => setting.set(YjsDatabaseKey.layout_ty, TimelineLayout.Year)); + sync(desktop.doc, webDoc); + expect(notify).toHaveBeenCalledTimes(1); + expect(store.getSnapshot().layout).toBe(TimelineLayout.Year); + + // An unrelated layout's setting must not notify timeline consumers. + desktop.doc.transact(() => { + const calendar = new Y.Map(); + + desktop.view.get(YjsDatabaseKey.layout_settings).set('2', calendar); + calendar.set(YjsDatabaseKey.field_id, 'date'); + }); + sync(desktop.doc, webDoc); + expect(notify).toHaveBeenCalledTimes(1); + + // Out-of-range values fall back rather than crash. + desktop.doc.transact(() => setting.set(YjsDatabaseKey.layout_ty, 99)); + sync(desktop.doc, webDoc); + expect(store.getSnapshot().layout).toBe(TimelineLayout.Month); + unsubscribe(); +}); + +test('reads the week start like the calendar: v2 key, then the legacy key, then the user preference', () => { + const { doc, view, database } = createFixture(); + + expect(readTimelineLayoutSetting(database, 'timeline', 3, true)).toMatchObject({ firstDayOfWeek: 3, use24Hour: true }); + + doc.transact(() => { + updateTimelineLayoutSetting(view, { fieldId: 'date' }); + view.get(YjsDatabaseKey.layout_settings).get(TIMELINE_LAYOUT_KEY).set(YjsDatabaseKey.first_day_of_week, 1); + }); + expect(readTimelineLayoutSetting(database, 'timeline', 3, false).firstDayOfWeek).toBe(1); + + doc.transact(() => updateTimelineLayoutSetting(view, { firstDayOfWeek: 0 })); + expect(readTimelineLayoutSetting(database, 'timeline', 3, false).firstDayOfWeek).toBe(0); +}); + +test('the scale is stored under layout_ty, the key the calendar uses for its mode', () => { + const { doc, view } = createFixture(); + + doc.transact(() => updateTimelineLayoutSetting(view, { fieldId: 'date', layout: TimelineLayout.Quarter })); + const setting = view.get(YjsDatabaseKey.layout_settings).get(TIMELINE_LAYOUT_KEY); + + expect(setting.get(YjsDatabaseKey.layout_ty)).toBe(TimelineLayout.Quarter); + expect(setting.has('zoom')).toBe(false); +}); diff --git a/src/application/database-yjs/database.type.ts b/src/application/database-yjs/database.type.ts index 68fc1e59d..1022ff1c9 100644 --- a/src/application/database-yjs/database.type.ts +++ b/src/application/database-yjs/database.type.ts @@ -104,6 +104,34 @@ export enum CalendarLayout { DayLayout = 2, } +/// Timeline scale presets, finest to coarsest, stored under `layout_ty` like +/// `CalendarLayout`. Wire values match `TimelineLayout` in +/// `libs/collab/src/database/views/layout_settings.rs`. +export enum TimelineLayout { + Hours = 0, + Day = 1, + Week = 2, + BiWeek = 3, + Month = 4, + Quarter = 5, + Year = 6, +} + +export interface TimelineLayoutSetting { + /// DateTime field plotted on the timeline. + fieldId: string; + layout: TimelineLayout; + /// Whether the property table is docked to the left of the canvas. + showTable: boolean; + firstDayOfWeek: number; + /** User preference, read like the calendar does so hour labels match. */ + use24Hour: boolean; + /// Relation field (pointing at this database) whose linked rows are the row's dependencies. + dependencyFieldId: string; + /// Number field holding 0–100 progress drawn as a fill inside the bar. + progressFieldId: string; +} + export interface CalendarLayoutSetting { fieldId: string; firstDayOfWeek: number; diff --git a/src/application/database-yjs/dispatch.ts b/src/application/database-yjs/dispatch.ts index bd699ee43..fef6bcb86 100644 --- a/src/application/database-yjs/dispatch.ts +++ b/src/application/database-yjs/dispatch.ts @@ -6,6 +6,11 @@ import * as Y from 'yjs'; import { resolveUserAttributionUid, touchRowAttribution } from '@/application/database-yjs/attribution'; import { calculateFieldValue } from '@/application/database-yjs/calculation'; import { CalendarLayoutUpdate, updateCalendarLayoutSetting } from '@/application/database-yjs/calendar-layout'; +import { + initializeTimelineLayoutSetting, + TimelineLayoutUpdate, + updateTimelineLayoutSetting, +} from '@/application/database-yjs/timeline-layout'; import { cloneDatabaseCell } from '@/application/database-yjs/cell.clone'; import { normalizeLegacyCellFieldType } from '@/application/database-yjs/cell.field-type'; import { parseYDatabaseCellToCell } from '@/application/database-yjs/cell.parse'; @@ -35,10 +40,6 @@ import { import { deleteReciprocalRelationField } from '@/application/database-yjs/dispatch/relation'; import { useNewRowDispatch } from '@/application/database-yjs/dispatch/row'; import { normalizeCreatedDatabaseFeedView, updateCreatesExactFeedView } from '@/application/database-yjs/feed-layout'; -import { - normalizeCreatedDatabaseFeedView, - updateCreatesExactFeedView, -} from '@/application/database-yjs/feed-layout'; import { getFieldName, NumberFormat, @@ -2748,6 +2749,7 @@ export function useAddDatabaseView() { [DatabaseViewLayout.Gallery]: ViewLayout.Gallery, [DatabaseViewLayout.Feed]: ViewLayout.Feed, [DatabaseViewLayout.Form]: ViewLayout.Form, + [DatabaseViewLayout.Timeline]: ViewLayout.Timeline, }; const layoutToName: Record = { [DatabaseViewLayout.Grid]: 'Grid', @@ -2758,6 +2760,7 @@ export function useAddDatabaseView() { [DatabaseViewLayout.Gallery]: 'Gallery', [DatabaseViewLayout.Feed]: 'Feed', [DatabaseViewLayout.Form]: 'Form builder', + [DatabaseViewLayout.Timeline]: 'Timeline', }; const viewLayout = layoutToViewLayout[layout]; const name = layoutToName[layout]; @@ -3211,6 +3214,21 @@ export function useUpdateDatabaseLayout(viewId: string) { initializeCalendarLayoutSetting(view, fieldId); } + if (layout === DatabaseViewLayout.Timeline) { + const timelineSetting = view.get(YjsDatabaseKey.layout_settings)?.get('8'); + const configuredFieldId = timelineSetting?.get(YjsDatabaseKey.field_id); + const configuredField = getValidCalendarField(database, fieldOrders, configuredFieldId); + const dateField: YDatabaseField | undefined = + configuredField ?? enhanceCalendarLayoutByFieldExists(fieldOrders); + const fieldId = dateField?.get(YjsDatabaseKey.id); + + if (!fieldId) { + throw new Error(`Date field not found`); + } + + initializeTimelineLayoutSetting(view, fieldId); + } + if (layout === DatabaseViewLayout.List) { const groups = view.get(YjsDatabaseKey.groups); @@ -4960,6 +4978,23 @@ export function useUpdateCalendarSetting() { ); } +export function useUpdateTimelineSetting() { + const viewId = useDatabaseViewId(); + const readOnly = useReadOnly(); + const sharedRoot = useSharedRoot(); + + return useCallback( + (settings: TimelineLayoutUpdate) => { + const database = sharedRoot.get(YjsEditorKey.database); + const view = database?.get(YjsDatabaseKey.views)?.get(viewId); + + if (readOnly || !view) return; + executeOperations(sharedRoot, [() => updateTimelineLayoutSetting(view, settings)], 'updateTimelineSetting'); + }, + [sharedRoot, viewId, readOnly] + ); +} + // Re-export advanced filter hooks from modular dispatch export { useEnterAdvancedMode, diff --git a/src/application/database-yjs/dispatch/cell.ts b/src/application/database-yjs/dispatch/cell.ts index e88dd17f0..1a37ba754 100644 --- a/src/application/database-yjs/dispatch/cell.ts +++ b/src/application/database-yjs/dispatch/cell.ts @@ -12,7 +12,7 @@ import * as Y from 'yjs'; import { AttributionUid, resolveUserAttributionUid, touchRowAttribution } from '@/application/database-yjs/attribution'; import { setCellStoredType } from '@/application/database-yjs/cell.field-type'; -import { useDatabaseContext } from '@/application/database-yjs/context'; +import { useDatabase, useDatabaseContext } from '@/application/database-yjs/context'; import { FieldType } from '@/application/database-yjs/database.type'; import { getOrCreateDatabaseHistoryManager, runDatabaseRowAction } from '@/application/database-yjs/history'; import type { DatabaseHistoryPolicy } from '@/application/database-yjs/history'; @@ -270,13 +270,75 @@ export function useUpdateCellDispatch(rowId: string, fieldId: string) { ); } +/** + * Like `useUpdateCellDispatch`, but the row and field are chosen per call so one + * hook instance can write several rows (e.g. shifting dependent timeline bars). + */ +export function useUpdateAnyCellDispatch() { + const { databaseDoc, rowMap, ensureRow, markCellLocalMutation } = useDatabaseContext(); + const database = useDatabase(); + const currentUser = useCurrentUserOptional(); + const actorUid = resolveUserAttributionUid(currentUser); + + return useCallback( + (rowId: string, fieldId: string, data: CellUpdateData, historyOptions?: CellHistoryOptions) => { + void (async () => { + const field = database?.get(YjsDatabaseKey.fields)?.get(fieldId); + + if (!field) { + Log.warn('[useUpdateAnyCellDispatch] Field not found', { rowId, fieldId }); + return; + } + + let rowDoc = rowMap?.[rowId]; + let target = rowDoc ? getWritableRowTarget(rowDoc) : null; + + if (!target && ensureRow) { + rowDoc = (await ensureRow(rowId)) ?? rowDoc; + target = rowDoc ? await waitForWritableRowTarget(rowDoc) : null; + } + + if (!rowDoc || !target) { + Log.warn('[useUpdateAnyCellDispatch] Row doc not ready for cell update', { rowId, fieldId }); + return; + } + + getOrCreateDatabaseHistoryManager(databaseDoc).registerRowDoc(rowId, rowDoc); + + writeCellToRow({ + rowDoc, + row: target.row, + cells: target.cells, + fieldId, + fieldType: Number(field.get(YjsDatabaseKey.type)) as FieldType, + rowId, + data, + historyOptions, + actorUid, + }); + markCellLocalMutation?.(rowId, fieldId); + })().catch((error: unknown) => { + Log.error('[useUpdateAnyCellDispatch] failed to update cell', { rowId, fieldId, error }); + }); + }, + [actorUid, database, databaseDoc, ensureRow, markCellLocalMutation, rowMap] + ); +} + export function useUpdateStartEndTimeCell() { const { databaseDoc, rowMap, ensureRow, markCellLocalMutation } = useDatabaseContext(); const currentUser = useCurrentUserOptional(); const actorUid = resolveUserAttributionUid(currentUser); return useCallback( - (rowId: string, fieldId: string, startTimestamp: string, endTimestamp?: string, isAllDay?: boolean) => { + ( + rowId: string, + fieldId: string, + startTimestamp: string, + endTimestamp?: string, + isAllDay?: boolean, + historyOptions?: CellHistoryOptions + ) => { void (async () => { let rowDoc = rowMap?.[rowId]; let target = rowDoc ? getWritableRowTarget(rowDoc) : null; @@ -297,7 +359,7 @@ export function useUpdateStartEndTimeCell() { runDatabaseRowAction( rowDoc, - { type: 'cell.update-date-range', rowId, fieldId, fieldType: FieldType.DateTime }, + { type: 'cell.update-date-range', rowId, fieldId, fieldType: FieldType.DateTime, ...historyOptions }, () => { let cell = writableTarget.cells.get(fieldId); diff --git a/src/application/database-yjs/selector.ts b/src/application/database-yjs/selector.ts index 77bc32eec..b8db40da2 100644 --- a/src/application/database-yjs/selector.ts +++ b/src/application/database-yjs/selector.ts @@ -13,6 +13,7 @@ import { import { isUngroupedColumnHidden, resolveBoardColumnVisibility } from '@/application/database-yjs/board-visibility'; import { createCalendarLayoutStore } from '@/application/database-yjs/calendar-layout'; +import { createTimelineLayoutStore } from '@/application/database-yjs/timeline-layout'; import { parseYDatabaseCellToCell } from '@/application/database-yjs/cell.parse'; import { DateTimeCell, RollupCell } from '@/application/database-yjs/cell.type'; import { hasRowConditionData, invalidateRowConditionCache } from '@/application/database-yjs/condition-value-cache'; @@ -3114,7 +3115,22 @@ export interface CalendarEvent { export function useCalendarEventsSelector() { const setting = useCalendarLayoutSetting(); - const fieldId = setting?.fieldId || ''; + + return useDateFieldEventsSelector(setting?.fieldId || ''); +} + +export function useTimelineEventsSelector() { + const setting = useTimelineLayoutSetting(); + + return useDateFieldEventsSelector(setting?.fieldId || ''); +} + +/** + * Rows plotted on a date-typed field. Rows without a value (or not yet loaded) + * land in `emptyEvents`; ranges keep `isRange` so consumers can tell a real end + * date from the synthetic 30-minute one. + */ +export function useDateFieldEventsSelector(fieldId: string) { const { field, clock: fieldClock } = useFieldSelector(fieldId); const primaryFieldId = usePrimaryFieldId(); const { field: primaryField, clock: primaryFieldClock } = useFieldSelector(primaryFieldId || ''); @@ -3273,6 +3289,21 @@ export function useCalendarLayoutSetting() { return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot); } +export function useTimelineLayoutSetting() { + const currentUser = useCurrentUser(); + const startWeekOn = Number(currentUser?.metadata?.[MetadataKey.StartWeekOn] || 0); + const timeFormat = currentUser?.metadata?.[MetadataKey.TimeFormat] || TimeFormat.TwelveHour; + const { databaseDoc } = useDatabaseContext(); + + const viewId = useDatabaseViewId(); + const store = useMemo( + () => createTimelineLayoutStore(databaseDoc, viewId, startWeekOn, timeFormat === TimeFormat.TwentyFourHour), + [databaseDoc, viewId, startWeekOn, timeFormat] + ); + + return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot); +} + export function getPrimaryFieldId(database: YDatabase) { const fields = database?.get(YjsDatabaseKey.fields); diff --git a/src/application/database-yjs/timeline-layout.ts b/src/application/database-yjs/timeline-layout.ts new file mode 100644 index 000000000..b38faae1f --- /dev/null +++ b/src/application/database-yjs/timeline-layout.ts @@ -0,0 +1,179 @@ +import * as Y from 'yjs'; + +import { + YDatabase, + YDatabaseLayoutSettings, + YDatabaseTimelineLayoutSetting, + YDatabaseView, + YjsDatabaseKey, + YjsEditorKey, +} from '@/application/types'; + +import { TimelineLayout, TimelineLayoutSetting } from './database.type'; + +/** Layout-settings key for `DatabaseViewLayout.Timeline`. */ +export const TIMELINE_LAYOUT_KEY = '8'; + +export const DEFAULT_TIMELINE_LAYOUT = TimelineLayout.Month; +export const DEFAULT_TIMELINE_SHOW_TABLE = true; + +function integer(value: unknown, min: number, max: number): number | undefined { + if (typeof value !== 'number' && typeof value !== 'bigint') return undefined; + const number = Number(value); + + return Number.isSafeInteger(number) && number >= min && number <= max ? number : undefined; +} + +/** + * Mirrors `readCalendarLayoutSetting`: Yrs integers arrive as BigInt while Yjs + * clients encode numbers, the week start falls back from `first_day_of_week_v2` + * to the legacy `first_day_of_week` and then to the user's preference. + */ +export function readTimelineLayoutSetting( + database: YDatabase | undefined, + viewId: string, + firstDayOfWeek: number, + use24Hour: boolean +): TimelineLayoutSetting { + const setting = database + ?.get(YjsDatabaseKey.views) + ?.get(viewId) + ?.get(YjsDatabaseKey.layout_settings) + ?.get(TIMELINE_LAYOUT_KEY); + const layout = integer(setting?.get(YjsDatabaseKey.layout_ty), TimelineLayout.Hours, TimelineLayout.Year); + const showTable = setting?.get(YjsDatabaseKey.show_table); + const weekday = + integer(setting?.get(YjsDatabaseKey.first_day_of_week_v2), 0, 6) ?? + integer(setting?.get(YjsDatabaseKey.first_day_of_week), 0, 6); + + return { + fieldId: setting?.get(YjsDatabaseKey.field_id) ?? '', + layout: layout ?? DEFAULT_TIMELINE_LAYOUT, + showTable: typeof showTable === 'boolean' ? showTable : DEFAULT_TIMELINE_SHOW_TABLE, + firstDayOfWeek: weekday ?? firstDayOfWeek, + use24Hour, + dependencyFieldId: setting?.get(YjsDatabaseKey.dependency_field_id) ?? '', + progressFieldId: setting?.get(YjsDatabaseKey.progress_field_id) ?? '', + }; +} + +export type TimelineLayoutUpdate = Partial>; + +export function createTimelineLayoutSetting(fieldId: string) { + const setting = new Y.Map() as YDatabaseTimelineLayoutSetting; + + setting.set(YjsDatabaseKey.field_id, fieldId); + setting.set(YjsDatabaseKey.layout_ty, DEFAULT_TIMELINE_LAYOUT); + setting.set(YjsDatabaseKey.show_table, DEFAULT_TIMELINE_SHOW_TABLE); + return setting; +} + +/** Patch the existing map so unrelated layout options survive concurrent edits. */ +export function updateTimelineLayoutSetting(view: YDatabaseView, settings: TimelineLayoutUpdate) { + let layouts = view.get(YjsDatabaseKey.layout_settings); + + if (!layouts) { + layouts = new Y.Map() as YDatabaseLayoutSettings; + view.set(YjsDatabaseKey.layout_settings, layouts); + } + + let setting = layouts.get(TIMELINE_LAYOUT_KEY); + + if (!setting) { + setting = new Y.Map() as YDatabaseTimelineLayoutSetting; + layouts.set(TIMELINE_LAYOUT_KEY, setting); + } + + if (settings.fieldId !== undefined) setting.set(YjsDatabaseKey.field_id, settings.fieldId); + if (settings.layout !== undefined) setting.set(YjsDatabaseKey.layout_ty, settings.layout); + if (settings.showTable !== undefined) setting.set(YjsDatabaseKey.show_table, settings.showTable); + if (settings.firstDayOfWeek !== undefined) setting.set(YjsDatabaseKey.first_day_of_week_v2, settings.firstDayOfWeek); + // An empty id unbinds; the server treats a missing key as "none". + if (settings.dependencyFieldId !== undefined) { + if (settings.dependencyFieldId) setting.set(YjsDatabaseKey.dependency_field_id, settings.dependencyFieldId); + else setting.delete(YjsDatabaseKey.dependency_field_id); + } + + if (settings.progressFieldId !== undefined) { + if (settings.progressFieldId) setting.set(YjsDatabaseKey.progress_field_id, settings.progressFieldId); + else setting.delete(YjsDatabaseKey.progress_field_id); + } +} + +/** + * Ensure the view has a timeline layout setting pointing at `fieldId`, without + * touching zoom/table options a collaborator may have set. + */ +export function initializeTimelineLayoutSetting(view: YDatabaseView, fieldId: string) { + let layoutSettings = view.get(YjsDatabaseKey.layout_settings); + + if (!layoutSettings) { + layoutSettings = new Y.Map() as YDatabaseLayoutSettings; + view.set(YjsDatabaseKey.layout_settings, layoutSettings); + } + + const setting = layoutSettings.get(TIMELINE_LAYOUT_KEY); + + if (!setting) { + layoutSettings.set(TIMELINE_LAYOUT_KEY, createTimelineLayoutSetting(fieldId)); + } else if (setting.get(YjsDatabaseKey.field_id) !== fieldId) { + updateTimelineLayoutSetting(view, { fieldId }); + } +} + +/** Stable snapshots keep row updates from rebuilding timeline configuration consumers. */ +export function createTimelineLayoutStore( + databaseDoc: Y.Doc, + viewId: string, + firstDayOfWeek: number, + use24Hour: boolean +) { + const root = databaseDoc.getMap(YjsEditorKey.data_section); + const read = () => + readTimelineLayoutSetting( + root.get(YjsEditorKey.database) as YDatabase | undefined, + viewId, + firstDayOfWeek, + use24Hour + ); + let snapshot = read(); + const getSnapshot = () => { + const next = read(); + + if ((Object.keys(next) as (keyof TimelineLayoutSetting)[]).some((key) => next[key] !== snapshot[key])) + snapshot = next; + return snapshot; + }; + + const subscribe = (notify: () => void) => { + const observer: Parameters[0] = (events) => { + // Observe ancestors too: an incoming update can insert or replace the view, + // layout_settings, or timeline map instead of changing an existing key. + const relevant = events.some((event) => { + if (event.path.length === 0) return event.changes.keys.has(YjsEditorKey.database); + if (event.path[0] !== YjsEditorKey.database) return false; + const path = event.path.slice(1); + + if (path.length === 0) return event.changes.keys.has(YjsDatabaseKey.views); + if (path[0] !== YjsDatabaseKey.views) return false; + if (path.length === 1) return event.changes.keys.has(viewId); + if (path[1] !== viewId) return false; + if (path.length === 2) return event.changes.keys.has(YjsDatabaseKey.layout_settings); + if (path[2] !== YjsDatabaseKey.layout_settings) return false; + return path.length === 3 ? event.changes.keys.has(TIMELINE_LAYOUT_KEY) : path[3] === TIMELINE_LAYOUT_KEY; + }); + + if (relevant && getSnapshot() !== snapshotBeforeChange) { + snapshotBeforeChange = snapshot; + notify(); + } + }; + + let snapshotBeforeChange = getSnapshot(); + + root.observeDeep(observer); + return () => root.unobserveDeep(observer); + }; + + return { getSnapshot, subscribe }; +} diff --git a/src/application/types.ts b/src/application/types.ts index 0cca5cf0e..f0ccb1aab 100644 --- a/src/application/types.ts +++ b/src/application/types.ts @@ -506,6 +506,9 @@ export enum ViewLayout { /// value (7) — they're distinct enums and the mapping between them /// lives in `dispatch.ts`. Form = 9, + /// Folder-side layout value for timeline views. Matches + /// `ViewLayout::Timeline = 10` in `libs/collab/src/folder/view.rs`. + Timeline = 10, } export enum YjsEditorKey { @@ -658,6 +661,12 @@ export enum YjsDatabaseKey { show_weekends = 'show_weekends', layout_ty = 'layout_ty', day_count = 'day_count', + /// Timeline layout setting: whether the property table is docked on the left. + show_table = 'show_table', + /// Timeline layout setting: Relation field (to this database) drawn as dependency arrows. + dependency_field_id = 'dependency_field_id', + /// Timeline layout setting: Number field (0–100) drawn as a progress fill. + progress_field_id = 'progress_field_id', icon = 'icon', is_inline = 'is_inline', embedded = 'embedded', @@ -891,6 +900,9 @@ export enum DatabaseViewLayout { /// Matches `DatabaseLayout::Form = 7` in /// `libs/collab/src/database/views/layout.rs`. Form = 7, + /// Matches `DatabaseLayout::Timeline = 8` in + /// `libs/collab/src/database/views/layout.rs`. + Timeline = 8, } export interface YDatabaseView extends Y.Map { @@ -972,6 +984,9 @@ export interface YDatabaseLayoutSettings extends Y.Map { // DatabaseViewLayout.Gallery get(key: '5'): YDatabaseGalleryLayoutSetting; + + // DatabaseViewLayout.Timeline + get(key: '8'): YDatabaseTimelineLayoutSetting; } export interface YDatabaseGridLayoutSetting extends Y.Map { @@ -994,6 +1009,17 @@ export interface YDatabaseCalendarLayoutSetting extends Y.Map { get(key: YjsDatabaseKey.show_week_numbers | YjsDatabaseKey.show_weekends): boolean; } +/// Same keys as the calendar setting (`layout_ty`, `first_day_of_week_v2`) plus +/// the timeline-only `show_table` and optional field bindings. +export interface YDatabaseTimelineLayoutSetting extends Y.Map { + get(key: YjsDatabaseKey.field_id): string; + get(key: YjsDatabaseKey.dependency_field_id | YjsDatabaseKey.progress_field_id): string | undefined; + get( + key: YjsDatabaseKey.layout_ty | YjsDatabaseKey.first_day_of_week | YjsDatabaseKey.first_day_of_week_v2 + ): number | bigint | null | undefined; + get(key: YjsDatabaseKey.show_table): boolean | undefined; +} + export interface YDatabaseChartLayoutSetting extends Y.Map { get(key: 'chartType' | 'aggregationType' | 'dateCondition'): string; get(key: 'xFieldId' | 'yFieldId'): string | undefined; @@ -1262,6 +1288,7 @@ export const layoutMap = { [ViewLayout.Gallery]: 'gallery', [ViewLayout.Feed]: 'feed', [ViewLayout.Form]: 'form', + [ViewLayout.Timeline]: 'timeline', }; export const databaseLayoutMap = { @@ -1273,6 +1300,7 @@ export const databaseLayoutMap = { [DatabaseViewLayout.Gallery]: 'gallery', [DatabaseViewLayout.Feed]: 'feed', [DatabaseViewLayout.Form]: 'form', + [DatabaseViewLayout.Timeline]: 'timeline', }; export enum FontLayout { diff --git a/src/assets/icons/timeline.svg b/src/assets/icons/timeline.svg new file mode 100644 index 000000000..90309b98f --- /dev/null +++ b/src/assets/icons/timeline.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/components/_shared/view-icon/PageIcon.tsx b/src/components/_shared/view-icon/PageIcon.tsx index 52104c1ff..a99420e5a 100644 --- a/src/components/_shared/view-icon/PageIcon.tsx +++ b/src/components/_shared/view-icon/PageIcon.tsx @@ -12,6 +12,7 @@ import { ReactComponent as GridSvg } from '@/assets/icons/grid.svg'; import { ReactComponent as ListSvg } from '@/assets/icons/list.svg'; import { ReactComponent as FormSvg } from '@/assets/icons/edit.svg'; import { ReactComponent as DocumentSvg } from '@/assets/icons/page.svg'; +import { ReactComponent as TimelineSvg } from '@/assets/icons/timeline.svg'; import { cn } from '@/lib/utils'; import { getImageUrl, revokeBlobUrl } from '@/utils/authenticated-image'; import { renderColor } from '@/utils/color'; @@ -161,6 +162,8 @@ function PageIcon({ return ; case ViewLayout.Form: return ; + case ViewLayout.Timeline: + return ; case ViewLayout.Document: return ; default: diff --git a/src/components/_shared/view-icon/ViewIcon.tsx b/src/components/_shared/view-icon/ViewIcon.tsx index 1d601aeb1..1d560ea72 100644 --- a/src/components/_shared/view-icon/ViewIcon.tsx +++ b/src/components/_shared/view-icon/ViewIcon.tsx @@ -14,6 +14,7 @@ import { ReactComponent as GallerySvg } from '@/assets/icons/gallery.svg'; import { ReactComponent as GridSvg } from '@/assets/icons/grid.svg'; import { ReactComponent as ListSvg } from '@/assets/icons/list.svg'; import { ReactComponent as DocumentSvg } from '@/assets/icons/page.svg'; +import { ReactComponent as TimelineSvg } from '@/assets/icons/timeline.svg'; export function ViewIcon ({ layout, size, className }: { layout: ViewLayout; @@ -65,6 +66,8 @@ export function ViewIcon ({ layout, size, className }: { return ; case ViewLayout.Form: return ; + case ViewLayout.Timeline: + return ; default: return null; } diff --git a/src/components/database/DatabaseViews.tsx b/src/components/database/DatabaseViews.tsx index 7c62acca8..f60150a08 100644 --- a/src/components/database/DatabaseViews.tsx +++ b/src/components/database/DatabaseViews.tsx @@ -43,6 +43,7 @@ import DatabaseConditionsPanel from 'src/components/database/components/conditio const List = lazy(() => import('@/components/database/list/List')); const Gallery = lazy(() => import('@/components/database/gallery')); const Feed = lazy(() => import('@/components/database/feed')); +const Timeline = lazy(() => import('@/components/database/timeline')); const FormBuilderView = lazy(() => import('@/components/database/form/FormBuilderView').then(({ FormBuilderView: Component }) => ({ default: Component, @@ -359,6 +360,8 @@ function DatabaseViews({ return ; case DatabaseViewLayout.Feed: return ; + case DatabaseViewLayout.Timeline: + return ; default: return null; } diff --git a/src/components/database/components/conditions/DatabaseActions.tsx b/src/components/database/components/conditions/DatabaseActions.tsx index 9140960d8..71a41cc3b 100644 --- a/src/components/database/components/conditions/DatabaseActions.tsx +++ b/src/components/database/components/conditions/DatabaseActions.tsx @@ -118,6 +118,7 @@ export function DatabaseActions() { DatabaseViewLayout.List, DatabaseViewLayout.Gallery, DatabaseViewLayout.Feed, + DatabaseViewLayout.Timeline, ].includes(layout); const showSearch = layout === DatabaseViewLayout.Gallery || layout === DatabaseViewLayout.Feed; const showTemplates = [ @@ -128,6 +129,7 @@ export function DatabaseActions() { DatabaseViewLayout.List, DatabaseViewLayout.Gallery, DatabaseViewLayout.Feed, + DatabaseViewLayout.Timeline, ].includes(layout); const settingsButton = ( + ); +} + +export const TimelineRow = memo( + ({ + row, + rect, + offscreenLeft, + offscreenRight, + sidebarWidth, + showSidebar, + propertyFields, + editable, + selected, + dragging, + following, + dragLabel, + progress, + progressPreview, + anyDragging, + formatTime, + onOpen, + onSelect, + onScrollTo, + onBarPointerDown, + onEmptyClick, + }: TimelineRowProps) => { + const { t } = useTranslation(); + const meta = useRowMetaSelector(row.rowId); + const icon = meta?.icon ?? ''; + const showLeftPill = rect !== null && offscreenLeft; + const showRightPill = rect !== null && offscreenRight; + const canAssignDate = editable && rect === null; + const handleBarPointerDown = useCallback( + (event: ReactPointerEvent, mode: TimelineDragMode) => { + onSelect?.(row.rowId); + onBarPointerDown?.(event, row, mode); + }, + [onBarPointerDown, onSelect, row] + ); + + const handleCanvasClick = (event: MouseEvent) => { + if (!canAssignDate) { + // Clicking the empty grid clears the selection, as in frappe. + onSelect?.(null); + return; + } + + const bounds = event.currentTarget.getBoundingClientRect(); + + onEmptyClick?.(row, event.clientX - bounds.left); + }; + + return ( +
+
+ {showSidebar ? ( + <> + + + + + + {t('timeline.openRow', { defaultValue: 'Open' })} + + + ) : null} +
+ +
+ {rect ? ( + + ) : null} + + {showLeftPill && rect ? ( +
+ onScrollTo?.(rect.left)} /> +
+ ) : null} + {showRightPill && rect ? ( +
+ onScrollTo?.(rect.left + rect.width)} /> +
+ ) : null} +
+
+ ); + } +); + +TimelineRow.displayName = 'TimelineRow'; diff --git a/src/components/database/timeline/TimelineToolbar.tsx b/src/components/database/timeline/TimelineToolbar.tsx new file mode 100644 index 000000000..f03121a4a --- /dev/null +++ b/src/components/database/timeline/TimelineToolbar.tsx @@ -0,0 +1,141 @@ +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { CalendarEvent, TimelineLayout } from '@/application/database-yjs'; +import { ReactComponent as ChevronLeft } from '@/assets/icons/alt_arrow_left.svg'; +import { ReactComponent as ChevronRight } from '@/assets/icons/alt_arrow_right.svg'; +import { ReactComponent as CheckIcon } from '@/assets/icons/tick.svg'; +import { ReactComponent as DropdownIcon } from '@/assets/icons/triangle_down.svg'; +import { NoDateButton } from '@/components/database/fullcalendar/NoDateButton'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; + +import { TIMELINE_SCALE_PRESETS, TIMELINE_LAYOUT_ORDER } from './scale/presets'; + +interface TimelineToolbarProps { + /** Month (or day, on hour scales) at the left edge of the viewport. */ + title: string; + layout: TimelineLayout; + onLayoutChange: (layout: TimelineLayout) => void; + onToday: () => void; + onStep: (direction: -1 | 1) => void; + emptyEvents: CalendarEvent[]; +} + +/** Same composition as the calendar toolbar: title left; No date, scale, ‹ Today › right. */ +export const TimelineToolbar = memo( + ({ title, layout, onLayoutChange, onToday, onStep, emptyEvents }: TimelineToolbarProps) => { + const { t } = useTranslation(); + const preset = TIMELINE_SCALE_PRESETS[layout]; + const previousLabel = t('timeline.previous', { defaultValue: 'Earlier' }); + const nextLabel = t('timeline.next', { defaultValue: 'Later' }); + const selectionMark = (selected: boolean) => ( + + ); + + return ( +
+
+

+ {title} +

+
+
+ +
+ + + + + + + {TIMELINE_LAYOUT_ORDER.map((option) => { + const optionPreset = TIMELINE_SCALE_PRESETS[option]; + + return ( + onLayoutChange(option)} + className='h-8 gap-1 !rounded-200 data-[state=checked]:!bg-transparent' + data-testid={`timeline-zoom-${option}`} + > + {selectionMark(option === layout)} + {t(optionPreset.labelKey, { defaultValue: optionPreset.label })} + + ); + })} + + + +
+ + + + + {previousLabel} + + + + + + {t('timeline.today', { defaultValue: 'Today' })} + + + + + + {nextLabel} + +
+
+
+
+ ); + } +); + +TimelineToolbar.displayName = 'TimelineToolbar'; diff --git a/src/components/database/timeline/TimelineUnsupported.tsx b/src/components/database/timeline/TimelineUnsupported.tsx new file mode 100644 index 000000000..30e807e26 --- /dev/null +++ b/src/components/database/timeline/TimelineUnsupported.tsx @@ -0,0 +1,24 @@ +import { useTranslation } from 'react-i18next'; + +import { ReactComponent as WarningLogo } from '@/assets/icons/warning_logo.svg'; + +/** Shown when the view's timeline field is missing or is not a date property. */ +export function TimelineUnsupported() { + const { t } = useTranslation(); + + return ( +
+ +

+ {t('timeline.settings.unsupportedTitle', { defaultValue: 'This timeline has no date property' })} +

+

+ {t('timeline.settings.unsupportedHint', { + defaultValue: 'Add a date property to the database, or pick one in the timeline settings.', + })} +

+
+ ); +} + +export default TimelineUnsupported; diff --git a/src/components/database/timeline/TimelineView.tsx b/src/components/database/timeline/TimelineView.tsx new file mode 100644 index 000000000..a8a58d566 --- /dev/null +++ b/src/components/database/timeline/TimelineView.tsx @@ -0,0 +1,558 @@ +import { useVirtualizer } from '@tanstack/react-virtual'; +import dayjs from 'dayjs'; +import { PointerEvent as ReactPointerEvent, useCallback, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { + FieldVisibility, + isAIFieldType, + TimelineLayoutSetting, + TimelineLayout, + useDatabaseContext, + useDatabaseViewId, + useFieldSelector, + useFieldsSelector, + useNavigateToRow, + usePrimaryFieldId, +} from '@/application/database-yjs'; +import { useUpdateAnyCellDispatch, useUpdateStartEndTimeCell } from '@/application/database-yjs/dispatch/cell'; +import { useNewRowDispatch } from '@/application/database-yjs/dispatch/row'; +import { useUpdateTimelineSetting } from '@/application/database-yjs/dispatch'; +import { YjsDatabaseKey } from '@/application/types'; +import { ReactComponent as CollapseIcon } from '@/assets/icons/double_arrow_left.svg'; +import { ReactComponent as ExpandIcon } from '@/assets/icons/double_arrow_right.svg'; +import { ReactComponent as PlusIcon } from '@/assets/icons/plus.svg'; +import { useAIEnabled } from '@/components/app/app.hooks'; +import { useTimeFormat } from '@/components/database/fullcalendar/hooks/useTimeFormat'; +import { shouldUseFixedDatabaseViewport } from '@/components/database/layout'; +import { Button } from '@/components/ui/button'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; +import { correctAllDayEndForStorage, dateToUnixTimestamp } from '@/utils/time'; + +import { + TIMELINE_BOTTOM_PADDING, + TIMELINE_COLLAPSED_SIDEBAR_WIDTH, + TIMELINE_COLUMN_OVERSCAN, + TIMELINE_HEADER_HEIGHT, + TIMELINE_ROW_HEIGHT, + TIMELINE_SIDEBAR_WIDTH, + TIMELINE_TODAY_ANCHOR, +} from './constants'; +import { useScrollWindow } from './hooks/useScrollWindow'; +import { TimelineDragMode, TimelineDragPreview, TimelineDragSpan, useTimelineDrag } from './hooks/useTimelineDrag'; +import { parseProgressPercent, parseRelationRowIds, useTimelineFieldValues } from './hooks/useTimelineFieldValues'; +import { useTimelinePermissions } from './hooks/useTimelinePermissions'; +import { useTimelineRange } from './hooks/useTimelineRange'; +import { TimelineRowModel, useTimelineRows } from './hooks/useTimelineRows'; +import { buildDependencyGraph, collectDependents } from './scale/dependencies'; +import { + buildHeaderColumns, + buildHeaderSegments, + calendarDaysBetween, + dateToX, + getBarRect, + getBarSpan, + getSpanRect, + minBarWidth, + snapDate, + totalWidth, + xToDate, +} from './scale/geometry'; +import { TimelineArrows } from './TimelineArrows'; +import { TimelineBarDragLabel } from './TimelineBar'; +import { TimelineToolbar } from './TimelineToolbar'; +import { TimelineGrid } from './TimelineGrid'; +import { TimelineHeader } from './TimelineHeader'; +import { TimelineRow } from './TimelineRow'; + +// Calendar cards carry only the title; a timeline bar adds chips solely for +// properties the user set to "always shown" in this view's Properties menu. +const CARD_FIELD_VISIBILITIES = [FieldVisibility.AlwaysShown]; + +function dragLabelFor(preview: TimelineDragPreview): TimelineBarDragLabel { + if (preview.mode === 'progress') return { side: 'end', text: `${preview.progress ?? 0}%` }; + const side = preview.mode === 'resize-end' ? 'end' : 'start'; + + if (preview.allDay) { + const date = side === 'end' ? dayjs(preview.endExclusive).subtract(1, 'day') : dayjs(preview.start); + + return { side, text: date.format('MMM D') }; + } + + const date = side === 'end' ? dayjs(preview.endExclusive) : dayjs(preview.start); + + return { side, text: date.format('MMM D, h:mm A') }; +} + +export function TimelineView({ setting }: { setting: TimelineLayoutSetting }) { + const { t } = useTranslation(); + const scrollerRef = useRef(null); + const { isDocumentBlock, variant, paddingStart, paddingEnd } = useDatabaseContext(); + const fixedViewport = shouldUseFixedDatabaseViewport({ isDocumentBlock, variant }); + const updateSetting = useUpdateTimelineSetting(); + const updateStartEnd = useUpdateStartEndTimeCell(); + const updateAnyCell = useUpdateAnyCellDispatch(); + const [selectedRowId, setSelectedRowId] = useState(null); + const newRow = useNewRowDispatch(); + const navigateToRow = useNavigateToRow(); + const aiEnabled = useAIEnabled(); + const permissions = useTimelinePermissions(setting.fieldId); + // One subscription to the user's time format, shared by every bar. + const { formatTimeDisplay } = useTimeFormat(); + const primaryFieldId = usePrimaryFieldId(); + const { field: primaryField } = useFieldSelector(primaryFieldId || ''); + const primaryFieldName = (primaryField?.get(YjsDatabaseKey.name) as string | undefined) ?? ''; + + // Read-only viewers may explore another scale or hide the table without + // writing shared data, exactly as the calendar keeps a local view mode. The + // entry is tagged with its view id and ignored once the view or the + // permission changes, so no effect is needed to reset it. + const [localSetting, setLocalSetting] = useState<{ + viewId: string; + layout?: TimelineLayout; + showTable?: boolean; + }>(); + const viewId = useDatabaseViewId(); + const localOverride = permissions.readOnly && localSetting?.viewId === viewId ? localSetting : undefined; + const layout = localOverride?.layout ?? setting.layout; + const showSidebar = localOverride?.showTable ?? setting.showTable; + const sidebarWidth = showSidebar ? TIMELINE_SIDEBAR_WIDTH : TIMELINE_COLLAPSED_SIDEBAR_WIDTH; + + const { rows, emptyEvents } = useTimelineRows(showSidebar); + const relations = useTimelineFieldValues(setting.dependencyFieldId, parseRelationRowIds); + const progressValues = useTimelineFieldValues(setting.progressFieldId, parseProgressPercent); + const rowIds = useMemo(() => rows.map((row) => row.rowId), [rows]); + const graph = useMemo(() => buildDependencyGraph(rowIds, relations), [relations, rowIds]); + const { geometry, handleScroll, scrollToDate, scrollByColumns } = useTimelineRange({ + layout, + scrollerRef, + sidebarWidth, + }); + const scroll = useScrollWindow(scrollerRef); + + const fields = useFieldsSelector(CARD_FIELD_VISIBILITIES); + const propertyFields = useMemo( + () => + fields.filter( + (field) => + !field.isPrimary && + field.fieldId !== setting.fieldId && + field.fieldId !== setting.dependencyFieldId && + field.fieldId !== setting.progressFieldId && + field.visibility === FieldVisibility.AlwaysShown && + (aiEnabled || !isAIFieldType(field.fieldType)) + ), + [aiEnabled, fields, setting.dependencyFieldId, setting.fieldId, setting.progressFieldId] + ); + + const canvasWidth = totalWidth(geometry); + const viewLeft = scroll.scrollLeft; + const viewRight = scroll.scrollLeft + Math.max(0, scroll.clientWidth - sidebarWidth); + const { columnWidth } = geometry.preset; + // Whole columns, so scrolling within a column doesn't rebuild the header. + const fromIndex = Math.floor(viewLeft / columnWidth) - TIMELINE_COLUMN_OVERSCAN; + const toIndex = Math.ceil(viewRight / columnWidth) + TIMELINE_COLUMN_OVERSCAN; + const now = useMemo(() => new Date(), []); + + const columns = useMemo( + () => buildHeaderColumns(geometry, fromIndex, toIndex, setting.firstDayOfWeek, now, setting.use24Hour), + [geometry, fromIndex, toIndex, setting.firstDayOfWeek, now, setting.use24Hour] + ); + const segments = useMemo(() => buildHeaderSegments(geometry, fromIndex, toIndex), [geometry, fromIndex, toIndex]); + const todayX = dateToX(geometry, now); + const showToday = todayX >= 0 && todayX <= canvasWidth; + // The toolbar names the month (or day) at the left edge, as the calendar's title does. + const anchorColumn = Math.max(0, Math.floor(viewLeft / columnWidth)); + const title = useMemo( + () => geometry.preset.upperText(xToDate(geometry, anchorColumn * columnWidth)), + [anchorColumn, columnWidth, geometry] + ); + + const handleOpen = useCallback((rowId: string) => navigateToRow?.(rowId), [navigateToRow]); + + const commitSpan = useCallback( + (rowId: string, start: Date, endExclusive: Date, allDay: boolean, keepSingle: boolean, historyGroup?: object) => { + const history = historyGroup ? { historyGroup } : undefined; + + if (allDay) { + const singleDay = calendarDaysBetween(start, endExclusive) <= 1; + const end = singleDay ? undefined : dateToUnixTimestamp(correctAllDayEndForStorage(endExclusive)); + + updateStartEnd(rowId, setting.fieldId, dateToUnixTimestamp(start), end, true, history); + return; + } + + updateStartEnd( + rowId, + setting.fieldId, + dateToUnixTimestamp(start), + keepSingle ? undefined : dateToUnixTimestamp(endExclusive), + false, + history + ); + }, + [setting.fieldId, updateStartEnd] + ); + + const rowsRef = useRef(rows); + + rowsRef.current = rows; + + const handleDragCommit = useCallback( + (preview: TimelineDragPreview) => { + if (preview.mode === 'progress') { + if (setting.progressFieldId && preview.progress !== undefined) { + updateAnyCell(preview.rowId, setting.progressFieldId, String(preview.progress)); + } + + return; + } + + const byId = new Map(rowsRef.current.map((candidate) => [candidate.rowId, candidate] as const)); + const row = byId.get(preview.rowId); + // A timed row without an end keeps its synthetic length only while moving. + const keepSingle = Boolean(row && !row.isRange && preview.mode === 'move'); + // The dragged bar and every follower undo together. + const historyGroup = {}; + + commitSpan(preview.rowId, preview.start, preview.endExclusive, preview.allDay, keepSingle, historyGroup); + preview.followers.forEach((follower) => { + const followerRow = byId.get(follower.rowId); + + commitSpan( + follower.rowId, + follower.start, + follower.endExclusive, + follower.allDay, + Boolean(followerRow && !followerRow.isRange), + historyGroup + ); + }); + }, + [commitSpan, setting.progressFieldId, updateAnyCell] + ); + + const { preview, dragging, startDrag } = useTimelineDrag({ + geometry, + scrollerRef, + sidebarWidth, + onCommit: handleDragCommit, + onClick: handleOpen, + }); + + const handleBarPointerDown = useCallback( + (event: ReactPointerEvent, row: TimelineRowModel, mode: TimelineDragMode) => { + if (!permissions.editable || !row.start) return; + const span = getBarSpan(row.start, row.end, row.allDay); + const byId = new Map(rowsRef.current.map((candidate) => [candidate.rowId, candidate] as const)); + // Dependents move with the bar (frappe's `move_dependencies`). + const followers: TimelineDragSpan[] = collectDependents(row.rowId, graph).flatMap((dependentId) => { + const dependent = byId.get(dependentId); + + if (!dependent?.start) return []; + return [ + { + rowId: dependent.rowId, + allDay: dependent.allDay, + ...getBarSpan(dependent.start, dependent.end, dependent.allDay), + }, + ]; + }); + // A bar cannot start before any of its dependencies start. + const minStart = (graph.predecessors.get(row.rowId) ?? []).reduce((latest, predecessorId) => { + const predecessor = byId.get(predecessorId); + + if (!predecessor?.start) return latest; + const predecessorStart = getBarSpan(predecessor.start, predecessor.end, predecessor.allDay).start; + + return !latest || predecessorStart > latest ? predecessorStart : latest; + }, undefined); + + startDrag( + event, + { + rowId: row.rowId, + allDay: row.allDay, + ...span, + followers, + minStart, + progress: progressValues.get(row.rowId) ?? 0, + }, + mode + ); + }, + [graph, permissions.editable, progressValues, startDrag] + ); + + const handleEmptyClick = useCallback( + (row: TimelineRowModel, x: number) => { + if (!permissions.editable) return; + const start = snapDate(geometry.preset, xToDate(geometry, x), 'floor'); + const endExclusive = dayjs(start).add(geometry.preset.snapMinutes, 'minute').toDate(); + const allDay = geometry.preset.unit === 'day'; + + commitSpan(row.rowId, start, endExclusive, allDay, !allDay); + }, + [commitSpan, geometry, permissions.editable] + ); + + const virtualizer = useVirtualizer({ + count: rows.length, + getScrollElement: () => scrollerRef.current, + estimateSize: () => TIMELINE_ROW_HEIGHT, + overscan: 8, + scrollMargin: TIMELINE_HEADER_HEIGHT, + getItemKey: (index) => rows[index]?.rowId ?? index, + }); + + const rowIndexById = useMemo(() => new Map(rows.map((row, index) => [row.rowId, index] as const)), [rows]); + // Base rects only change with the data or the scale; a drag overlays the few + // rows it moves so every other row keeps its rect reference (and its memo). + const baseRects = useMemo( + () => rows.map((row) => (row.start ? getBarRect(geometry, row.start, row.end, row.allDay) : null)), + [geometry, rows] + ); + const rects = useMemo(() => { + if (!preview || preview.mode === 'progress') return baseRects; + const next = baseRects.slice(); + const overlay = (span: TimelineDragSpan) => { + const index = rowIndexById.get(span.rowId); + + if (index !== undefined && next[index]) { + next[index] = getSpanRect(geometry, span, minBarWidth(geometry, span.allDay)); + } + }; + + overlay(preview); + preview.followers.forEach(overlay); + return next; + }, [baseRects, geometry, preview, rowIndexById]); + const followerIds = useMemo(() => new Set(preview?.followers.map((follower) => follower.rowId) ?? []), [preview]); + const previewRect = useMemo(() => { + if (!preview) return null; + const index = rowIndexById.get(preview.rowId); + + return index === undefined ? null : rects[index]; + }, [preview, rects, rowIndexById]); + const dragLabel = useMemo(() => (preview ? dragLabelFor(preview) : undefined), [preview]); + + const handleLayoutChange = useCallback( + (nextLayout: TimelineLayout) => { + if (permissions.readOnly) { + setLocalSetting((prev) => + prev?.viewId === viewId ? { ...prev, layout: nextLayout } : { viewId, layout: nextLayout } + ); + } else { + updateSetting({ layout: nextLayout }); + } + }, + [permissions.readOnly, updateSetting, viewId] + ); + const handleToday = useCallback(() => scrollToDate(new Date(), TIMELINE_TODAY_ANCHOR), [scrollToDate]); + const handleStep = useCallback( + (direction: -1 | 1) => scrollByColumns(direction * geometry.preset.stepColumns), + [geometry.preset.stepColumns, scrollByColumns] + ); + const handleScrollToX = useCallback( + (x: number) => { + const scroller = scrollerRef.current; + + if (!scroller) return; + const visible = Math.max(0, scroller.clientWidth - sidebarWidth); + + scroller.scrollTo({ left: Math.max(0, x - TIMELINE_TODAY_ANCHOR * visible), behavior: 'smooth' }); + }, + [sidebarWidth] + ); + const toggleSidebar = useCallback(() => { + if (permissions.readOnly) { + setLocalSetting((prev) => + prev?.viewId === viewId ? { ...prev, showTable: !showSidebar } : { viewId, showTable: !showSidebar } + ); + } else { + updateSetting({ showTable: !showSidebar }); + } + }, [permissions.readOnly, showSidebar, updateSetting, viewId]); + const handleNewRow = useCallback(() => { + void newRow({ tailing: true, openAfterCreate: true }).catch(() => undefined); + }, [newRow]); + + const bodyHeight = + virtualizer.getTotalSize() + (permissions.readOnly ? 0 : TIMELINE_ROW_HEIGHT) + TIMELINE_BOTTOM_PADDING; + const virtualItems = virtualizer.getVirtualItems(); + const firstVisibleIndex = virtualItems[0]?.index ?? 0; + const lastVisibleIndex = virtualItems[virtualItems.length - 1]?.index ?? -1; + + return ( +
+ +
+
+
+
+ {showSidebar ? {primaryFieldName} : null} + + + + + {t('timeline.settings.showTable', { defaultValue: 'Show table' })} + +
+ +
+ +
+ {/* Blank sticky column so overlays never show through below the last table cell. */} +
+ + + {graph.predecessors.size > 0 ? ( + + ) : null} + + {virtualItems.map((virtualRow) => { + const row = rows[virtualRow.index]; + + if (!row) return null; + const isDragged = preview?.rowId === row.rowId; + const rect = rects[virtualRow.index]; + // Booleans, not pixels, so a scroll frame only re-renders rows whose pill state flips. + const offscreenLeft = rect !== null && !isDragged && rect.left < viewLeft; + const offscreenRight = rect !== null && !isDragged && rect.left + rect.width > viewRight; + + return ( +
+ +
+ ); + })} + + {!permissions.readOnly ? ( +
+
{ + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + handleNewRow(); + } + }} + > + + {showSidebar ? t('grid.row.newRow', { defaultValue: 'New row' }) : null} +
+
+ ) : null} +
+
+
+
+ ); +} + +export default TimelineView; diff --git a/src/components/database/timeline/__tests__/dependencies.test.ts b/src/components/database/timeline/__tests__/dependencies.test.ts new file mode 100644 index 000000000..da8a00899 --- /dev/null +++ b/src/components/database/timeline/__tests__/dependencies.test.ts @@ -0,0 +1,158 @@ +import { TimelineLayout } from '@/application/database-yjs'; + +import { applyDragDelta } from '../hooks/useTimelineDrag'; +import { buildDependencyGraph, collectDependents, dependencyArrowPath } from '../scale/dependencies'; +import { TimelineGeometry } from '../scale/geometry'; +import { getTimelinePreset } from '../scale/presets'; + +const local = (y: number, m: number, d: number) => new Date(y, m - 1, d); +const geometry: TimelineGeometry = { + preset: getTimelinePreset(TimelineLayout.Month), + origin: local(2020, 11, 1), + columnCount: 60, +}; +const { columnWidth } = geometry.preset; + +describe('dependency graph', () => { + const rows = ['a', 'b', 'c', 'd']; + const relations = new Map([ + ['b', ['a']], + ['c', ['b', 'zzz-not-in-view']], + ['d', ['a', 'd']], + ]); + const graph = buildDependencyGraph(rows, relations); + + test('keeps only in-view links, drops self links, and indexes both directions', () => { + expect(graph.predecessors.get('b')).toEqual(['a']); + expect(graph.predecessors.get('c')).toEqual(['b']); + expect(graph.predecessors.get('d')).toEqual(['a']); + expect(graph.dependents.get('a')).toEqual(['b', 'd']); + expect(graph.dependents.get('b')).toEqual(['c']); + }); + + test('collects transitive dependents once each', () => { + expect(collectDependents('a', graph).sort()).toEqual(['b', 'c', 'd']); + expect(collectDependents('b', graph)).toEqual(['c']); + expect(collectDependents('c', graph)).toEqual([]); + }); + + test('survives cycles', () => { + const cyclic = buildDependencyGraph( + ['x', 'y'], + new Map([ + ['x', ['y']], + ['y', ['x']], + ]) + ); + + expect(collectDependents('x', cyclic)).toEqual(['y']); + }); +}); + +describe('dependency arrow path', () => { + const options = { rowHeight: 36, barInset: 4 }; + + test('a successor that starts after the predecessor gets the short two-bend route', () => { + const path = dependencyArrowPath( + { rect: { left: 0, width: 100 }, index: 0 }, + { rect: { left: 160, width: 80 }, index: 2 }, + options + ); + + expect(path.startsWith('M 40 32 V ')).toBe(true); + expect(path).toContain('L 147 90'); + expect(path.endsWith('m -5 -5 l 5 5 l -5 5')).toBe(true); + }); + + test('a successor that starts before the predecessor ends loops back with four bends', () => { + const path = dependencyArrowPath( + { rect: { left: 100, width: 100 }, index: 3 }, + { rect: { left: 60, width: 50 }, index: 1 }, + options + ); + + expect(path).toContain('H 42'); + expect((path.match(/ a /g) ?? []).length).toBe(3); + expect(path).toContain('L 47 54'); + }); +}); + +describe('applyDragDelta with dependencies and progress', () => { + const span = (rowId: string, day: number, days: number) => ({ + rowId, + allDay: true, + start: local(2020, 11, day), + endExclusive: local(2020, 11, day + days), + }); + + test('moving a bar shifts its followers by the same snapped delta', () => { + const preview = applyDragDelta( + geometry, + { ...span('a', 5, 3), mode: 'move', followers: [span('b', 9, 2), span('c', 12, 1)] }, + columnWidth * 2 + 5 + ); + + expect(preview.start).toEqual(local(2020, 11, 7)); + expect(preview.endExclusive).toEqual(local(2020, 11, 10)); + expect(preview.followers.map((follower) => [follower.start, follower.endExclusive])).toEqual([ + [local(2020, 11, 11), local(2020, 11, 13)], + [local(2020, 11, 14), local(2020, 11, 15)], + ]); + }); + + test('a bar cannot move or start before its dependencies, and followers only travel the clamped distance', () => { + const move = applyDragDelta( + geometry, + { ...span('b', 10, 2), mode: 'move', minStart: local(2020, 11, 8), followers: [span('c', 14, 1)] }, + -columnWidth * 5 + ); + + expect(move.start).toEqual(local(2020, 11, 8)); + expect(move.endExclusive).toEqual(local(2020, 11, 10)); + expect(move.followers[0].start).toEqual(local(2020, 11, 12)); + + const resize = applyDragDelta( + geometry, + { ...span('b', 10, 2), mode: 'resize-start', minStart: local(2020, 11, 8) }, + -columnWidth * 5 + ); + + expect(resize.start).toEqual(local(2020, 11, 8)); + expect(resize.followers).toEqual([]); + }); + + test('extending the end pushes followers along; shrinking pulls them back', () => { + const grow = applyDragDelta( + geometry, + { ...span('a', 5, 3), mode: 'resize-end', followers: [span('b', 9, 2)] }, + columnWidth * 2 + ); + + expect(grow.endExclusive).toEqual(local(2020, 11, 10)); + expect(grow.followers[0].start).toEqual(local(2020, 11, 11)); + + const shrink = applyDragDelta( + geometry, + { ...span('a', 5, 3), mode: 'resize-end', followers: [span('b', 9, 2)] }, + -columnWidth * 10 + ); + + // Never shorter than one snap unit; followers move by the effective delta. + expect(shrink.endExclusive).toEqual(local(2020, 11, 6)); + expect(shrink.followers[0].start).toEqual(local(2020, 11, 7)); + }); + + test('progress drags convert pixels to a clamped whole percent and never touch dates', () => { + const origin = { ...span('a', 5, 4), mode: 'progress' as const, progress: 25 }; + const width = columnWidth * 4; + + expect(applyDragDelta(geometry, origin, width / 4).progress).toBe(50); + expect(applyDragDelta(geometry, origin, -width).progress).toBe(0); + expect(applyDragDelta(geometry, origin, width * 3).progress).toBe(100); + const preview = applyDragDelta(geometry, origin, 10); + + expect(preview.start).toEqual(origin.start); + expect(preview.endExclusive).toEqual(origin.endExclusive); + expect(preview.followers).toEqual([]); + }); +}); diff --git a/src/components/database/timeline/__tests__/geometry.test.ts b/src/components/database/timeline/__tests__/geometry.test.ts new file mode 100644 index 000000000..195a21fdf --- /dev/null +++ b/src/components/database/timeline/__tests__/geometry.test.ts @@ -0,0 +1,161 @@ +import { TimelineLayout } from '@/application/database-yjs'; + +import { + addColumns, + buildHeaderColumns, + buildHeaderSegments, + dateToX, + getBarRect, + MIN_BAR_WIDTH, + snapDate, + TimelineGeometry, + totalWidth, + xToDate, +} from '../scale/geometry'; +import { getTimelinePreset, TIMELINE_SCALE_PRESETS, TIMELINE_LAYOUT_ORDER } from '../scale/presets'; + +const local = (y: number, m: number, d: number, h = 0, min = 0) => new Date(y, m - 1, d, h, min); + +function geometryFor(zoom: TimelineLayout, origin: Date, columnCount = 60): TimelineGeometry { + return { preset: getTimelinePreset(zoom), origin, columnCount }; +} + +describe('presets', () => { + test('every Notion zoom level has a preset in menu order', () => { + expect(TIMELINE_LAYOUT_ORDER).toEqual([ + TimelineLayout.Hours, + TimelineLayout.Day, + TimelineLayout.Week, + TimelineLayout.BiWeek, + TimelineLayout.Month, + TimelineLayout.Quarter, + TimelineLayout.Year, + ]); + TIMELINE_LAYOUT_ORDER.forEach((zoom) => expect(TIMELINE_SCALE_PRESETS[zoom].zoom).toBe(zoom)); + }); + + test('unknown layout falls back to month', () => { + expect(getTimelinePreset(99 as TimelineLayout).zoom).toBe(TimelineLayout.Month); + }); +}); + +describe('day-unit geometry', () => { + const origin = local(2020, 11, 1); + const geometry = geometryFor(TimelineLayout.Month, origin); + const { columnWidth } = geometry.preset; + + test('maps midnights to column edges and round-trips through xToDate', () => { + expect(dateToX(geometry, local(2020, 11, 9))).toBe(8 * columnWidth); + expect(xToDate(geometry, 8 * columnWidth)).toEqual(local(2020, 11, 9)); + expect(dateToX(geometry, local(2020, 11, 9, 12))).toBeCloseTo(8.5 * columnWidth, 5); + expect(xToDate(geometry, 8.5 * columnWidth)).toEqual(local(2020, 11, 9, 12)); + }); + + test('all-day ranges are end-inclusive and single days span one column', () => { + expect(getBarRect(geometry, local(2020, 11, 9), local(2020, 11, 12), true)).toEqual({ + left: 8 * columnWidth, + width: 4 * columnWidth, + }); + expect(getBarRect(geometry, local(2020, 11, 9), undefined, true)).toEqual({ + left: 8 * columnWidth, + width: columnWidth, + }); + // A stale end before the start still yields a one-column bar. + expect(getBarRect(geometry, local(2020, 11, 9), local(2020, 11, 2), true).width).toBe(columnWidth); + }); + + test('timed events keep a whole day column on day scales, like a calendar month cell', () => { + const start = local(2020, 11, 9, 9); + + expect(getBarRect(geometry, start, local(2020, 11, 9, 15), false).width).toBe(columnWidth); + expect(getBarRect(geometry, start, undefined, false).width).toBe(columnWidth); + // A timed span longer than a day is still true to its length. + expect(getBarRect(geometry, start, local(2020, 11, 11, 9), false).width).toBeCloseTo(columnWidth * 2, 5); + }); + + test('snaps to whole days from local midnight', () => { + expect(snapDate(geometry.preset, local(2020, 11, 9, 13), 'round')).toEqual(local(2020, 11, 10)); + expect(snapDate(geometry.preset, local(2020, 11, 9, 13), 'floor')).toEqual(local(2020, 11, 9)); + expect(snapDate(geometry.preset, local(2020, 11, 9, 1), 'ceil')).toEqual(local(2020, 11, 10)); + }); + + test('header columns are clamped to the range, flag weekends and today, and skip out-of-window indexes', () => { + const now = local(2020, 11, 9, 10); + const columns = buildHeaderColumns(geometry, -3, 10, 0, now); + + expect(columns.map((column) => column.index)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); + // The first of a month names the month so boundaries read while scrolling. + expect(columns[0].label).toBe('Nov 1'); + expect(columns[1].label).toBe('2'); + expect(columns.find((column) => column.isToday)?.index).toBe(8); + // 1 Nov 2020 is a Sunday; 7 and 8 Nov are the next Saturday and Sunday. + expect(columns.filter((column) => column.isWeekend).map((column) => column.index)).toEqual([0, 6, 7]); + expect(columns.every((column) => column.gridLine)).toBe(true); + }); + + test('month segments are clamped to the rendered range so the sticky label stays inside', () => { + const wide = geometryFor(TimelineLayout.Month, local(2020, 10, 25), 30); + const segments = buildHeaderSegments(wide, 0, 30); + + expect(segments.map((segment) => segment.label)).toEqual(['October 2020', 'November 2020']); + expect(segments[0]).toMatchObject({ x: 0, width: 7 * wide.preset.columnWidth }); + expect(segments[1].x).toBe(7 * wide.preset.columnWidth); + expect(segments[1].x + segments[1].width).toBe(totalWidth(wide)); + }); + + test('quarter and year presets only draw week or month gridlines', () => { + const quarter = buildHeaderColumns(geometryFor(TimelineLayout.Quarter, origin, 14), 0, 14, 1, local(2000, 1, 1)); + + // Mondays in Nov 2020: 2, 9; the first week of the month carries the month name. + expect(quarter.filter((column) => column.gridLine).map((column) => column.index)).toEqual([1, 8]); + expect(quarter.filter((column) => column.label).map((column) => column.label)).toEqual(['Nov 2', '9']); + + const year = buildHeaderColumns( + geometryFor(TimelineLayout.Year, local(2020, 10, 30), 5), + 0, + 5, + 0, + local(2000, 1, 1) + ); + + expect(year.filter((column) => column.gridLine).map((column) => column.index)).toEqual([2]); + expect(year.every((column) => column.label === '' && !column.isWeekend)).toBe(true); + }); +}); + +describe('hour-unit geometry', () => { + const origin = local(2020, 11, 7); + const geometry = geometryFor(TimelineLayout.Day, origin, 48); + const { columnWidth } = geometry.preset; + + test('timed events are true to their duration on hour scales, never thinner than the minimum', () => { + const start = local(2020, 11, 7, 9); + + expect(getBarRect(geometry, start, local(2020, 11, 7, 15), false).width).toBeCloseTo(columnWidth * 6, 5); + expect(getBarRect(geometry, start, undefined, false).width).toBeCloseTo(columnWidth / 2, 5); + expect(getBarRect(geometry, start, local(2020, 11, 7, 9, 1), false).width).toBe(MIN_BAR_WIDTH); + }); + + test('positions by wall-clock hours and snaps to quarter hours', () => { + expect(dateToX(geometry, local(2020, 11, 7, 14, 30))).toBeCloseTo(14.5 * columnWidth, 5); + expect(xToDate(geometry, 14.5 * columnWidth)).toEqual(local(2020, 11, 7, 14, 30)); + expect(snapDate(geometry.preset, local(2020, 11, 7, 14, 37))).toEqual(local(2020, 11, 7, 14, 30)); + expect(addColumns(geometry.preset, origin, 24)).toEqual(local(2020, 11, 8)); + }); + + test('hour labels follow the 24-hour preference like the calendar time grid', () => { + const columns = buildHeaderColumns(geometry, 13, 16, 0, local(2000, 1, 1), true); + + expect(columns.map((column) => column.label)).toEqual(['13:00', '14:00', '15:00']); + }); + + test('day segments carry the full date and hour columns are labelled', () => { + const segments = buildHeaderSegments(geometry, 0, 48); + + expect(segments.map((segment) => segment.label)).toEqual(['November 7, 2020', 'November 8, 2020']); + const columns = buildHeaderColumns(geometry, 13, 16, 0, local(2020, 11, 7, 14, 5)); + + expect(columns.map((column) => column.label)).toEqual(['1 PM', '2 PM', '3 PM']); + expect(columns.find((column) => column.isToday)?.index).toBe(14); + }); +}); diff --git a/src/components/database/timeline/constants.ts b/src/components/database/timeline/constants.ts new file mode 100644 index 000000000..5e12fad37 --- /dev/null +++ b/src/components/database/timeline/constants.ts @@ -0,0 +1,18 @@ +import { DEFAULT_ROW_HEIGHT } from '@/application/database-yjs'; + +/** Height of one timeline row; matches the grid's default row height. */ +export const TIMELINE_ROW_HEIGHT = DEFAULT_ROW_HEIGHT; +/** Single header row of column (or segment) labels, like the calendar's day header. */ +export const TIMELINE_HEADER_HEIGHT = 36; +/** Docked property table width when `show_table` is on. */ +export const TIMELINE_SIDEBAR_WIDTH = 280; +/** Width reserved for the expand toggle when the table is hidden. */ +export const TIMELINE_COLLAPSED_SIDEBAR_WIDTH = 32; +/** Vertical inset of a bar inside its row: 36px rows hold the calendar's 22px event chips. */ +export const TIMELINE_BAR_INSET = 7; +/** Extra blank rows below the last row so the canvas can be scrolled past it. */ +export const TIMELINE_BOTTOM_PADDING = 3 * TIMELINE_ROW_HEIGHT; +/** Columns kept rendered beyond each edge of the viewport. */ +export const TIMELINE_COLUMN_OVERSCAN = 6; +/** Fraction of the visible canvas Today lands at when jumping to it. */ +export const TIMELINE_TODAY_ANCHOR = 0.25; diff --git a/src/components/database/timeline/hooks/useScrollWindow.ts b/src/components/database/timeline/hooks/useScrollWindow.ts new file mode 100644 index 000000000..c462a646b --- /dev/null +++ b/src/components/database/timeline/hooks/useScrollWindow.ts @@ -0,0 +1,57 @@ +import { RefObject, useEffect, useState } from 'react'; + +export interface ScrollWindow { + scrollLeft: number; + clientWidth: number; +} + +const EMPTY: ScrollWindow = { scrollLeft: 0, clientWidth: 0 }; + +/** + * Horizontal viewport of a scroll container, throttled to animation frames. + * The vertical axis is deliberately not tracked: the row virtualizer owns it, + * and publishing it here would re-render the whole view on every scroll frame. + */ +export function useScrollWindow(scrollerRef: RefObject): ScrollWindow { + const [window, setWindow] = useState(EMPTY); + + useEffect(() => { + const element = scrollerRef.current; + + if (!element) return; + + let frame = 0; + const read = () => { + frame = 0; + const next = { scrollLeft: element.scrollLeft, clientWidth: element.clientWidth }; + + setWindow((prev) => (prev.scrollLeft === next.scrollLeft && prev.clientWidth === next.clientWidth ? prev : next)); + }; + + const schedule = () => { + if (frame) return; + frame = requestAnimationFrame(read); + }; + + read(); + element.addEventListener('scroll', schedule, { passive: true }); + + let observer: ResizeObserver | undefined; + + if (typeof ResizeObserver !== 'undefined') { + observer = new ResizeObserver(schedule); + observer.observe(element); + } else { + globalThis.addEventListener('resize', schedule); + } + + return () => { + if (frame) cancelAnimationFrame(frame); + element.removeEventListener('scroll', schedule); + observer?.disconnect(); + globalThis.removeEventListener('resize', schedule); + }; + }, [scrollerRef]); + + return window; +} diff --git a/src/components/database/timeline/hooks/useTimelineDrag.ts b/src/components/database/timeline/hooks/useTimelineDrag.ts new file mode 100644 index 000000000..9d302a1a5 --- /dev/null +++ b/src/components/database/timeline/hooks/useTimelineDrag.ts @@ -0,0 +1,301 @@ +/** + * Pointer-driven move / resize / progress editing of timeline bars. + * + * The interaction model is frappe/gantt's `bind_bar_events` and + * `bind_bar_progress` (MIT, Copyright (c) 2024 Frappe Technologies Pvt. + * Ltd.): remember the bar's origin on pointer-down, translate the pointer + * delta into a snapped date (or percent) delta on every move, and only commit + * on pointer-up when the pointer actually travelled. Dates are computed from + * the origin dates rather than from pixels so a bar never drifts across + * repeated drags. Like frappe's `move_dependencies`, rows that depend on the + * dragged one ("followers") shift with it, and a bar cannot start before its + * dependencies do. + */ +import { PointerEvent as ReactPointerEvent, useCallback, useEffect, useRef, useState } from 'react'; + +import { columnIndexOf, dateToX, snapDate, TimelineGeometry, xToDate } from '../scale/geometry'; + +export type TimelineDragMode = 'move' | 'resize-start' | 'resize-end' | 'progress'; + +export interface TimelineDragSpan { + rowId: string; + /** Bar start; a local midnight for all-day rows. */ + start: Date; + /** Exclusive bar end (the day after the last covered day for all-day rows). */ + endExclusive: Date; + allDay: boolean; +} + +export interface TimelineDragOrigin extends TimelineDragSpan { + /** Rows that shift with this one when it moves or its end moves. */ + followers?: TimelineDragSpan[]; + /** Earliest start allowed, e.g. the latest start among its dependencies. */ + minStart?: Date; + /** Current 0–100 progress, required for the progress mode. */ + progress?: number; +} + +export interface TimelineDragPreview extends TimelineDragSpan { + mode: TimelineDragMode; + followers: TimelineDragSpan[]; + /** Only set by the progress mode. */ + progress?: number; +} + +interface ActiveDrag extends TimelineDragOrigin { + mode: TimelineDragMode; + pointerId: number; + startClientX: number; + startScrollLeft: number; + moved: boolean; +} + +/** Pointer travel before a press turns into a drag instead of a click. */ +export const DRAG_THRESHOLD_PX = 4; +const AUTO_SCROLL_EDGE_PX = 40; +const AUTO_SCROLL_STEP_PX = 12; + +export interface UseTimelineDragOptions { + geometry: TimelineGeometry; + scrollerRef: React.RefObject; + /** Width of the sticky sidebar overlaying the left of the viewport. */ + sidebarWidth: number; + onCommit: (preview: TimelineDragPreview) => void; + onClick?: (rowId: string) => void; +} + +function shiftDate(geometry: TimelineGeometry, date: Date, deltaPx: number): Date { + return snapDate(geometry.preset, xToDate(geometry, dateToX(geometry, date) + deltaPx), 'round'); +} + +function shiftSpan(geometry: TimelineGeometry, span: TimelineDragSpan, deltaPx: number): TimelineDragSpan { + const { preset } = geometry; + const snapMs = preset.snapMinutes * 60_000; + const durationColumns = columnIndexOf(geometry, span.endExclusive) - columnIndexOf(geometry, span.start); + const start = shiftDate(geometry, span.start, deltaPx); + let endExclusive = snapDate( + preset, + xToDate(geometry, dateToX(geometry, start) + durationColumns * preset.columnWidth), + 'round' + ); + + if (endExclusive.getTime() - start.getTime() < snapMs) endExclusive = new Date(start.getTime() + snapMs); + return { rowId: span.rowId, allDay: span.allDay, start, endExclusive }; +} + +/** Pure delta application, exported for tests. */ +export function applyDragDelta( + geometry: TimelineGeometry, + drag: TimelineDragOrigin & { mode: TimelineDragMode }, + deltaPx: number +): TimelineDragPreview { + const { preset } = geometry; + const snapMs = preset.snapMinutes * 60_000; + const followers = drag.followers ?? []; + const base = { rowId: drag.rowId, mode: drag.mode, allDay: drag.allDay }; + + if (drag.mode === 'progress') { + const width = dateToX(geometry, drag.endExclusive) - dateToX(geometry, drag.start); + const origin = drag.progress ?? 0; + const progress = Math.min(100, Math.max(0, Math.round(origin + (deltaPx / Math.max(width, 1)) * 100))); + + return { ...base, start: drag.start, endExclusive: drag.endExclusive, followers: [], progress }; + } + + if (drag.mode === 'move') { + let moved = shiftSpan(geometry, drag, deltaPx); + let effectiveDelta = deltaPx; + + if (drag.minStart && moved.start < drag.minStart) { + // Clamp to the dependency and re-derive the pixel delta so followers + // keep the offset the bar actually travelled. + effectiveDelta = dateToX(geometry, drag.minStart) - dateToX(geometry, drag.start); + moved = shiftSpan(geometry, drag, effectiveDelta); + } + + return { + ...base, + ...moved, + followers: followers.map((follower) => shiftSpan(geometry, follower, effectiveDelta)), + }; + } + + if (drag.mode === 'resize-start') { + let start = shiftDate(geometry, drag.start, deltaPx); + + if (drag.minStart && start < drag.minStart) start = drag.minStart; + if (drag.endExclusive.getTime() - start.getTime() < snapMs) start = new Date(drag.endExclusive.getTime() - snapMs); + + return { ...base, start, endExclusive: drag.endExclusive, followers: [] }; + } + + let endExclusive = shiftDate(geometry, drag.endExclusive, deltaPx); + + if (endExclusive.getTime() - drag.start.getTime() < snapMs) endExclusive = new Date(drag.start.getTime() + snapMs); + const effectiveDelta = dateToX(geometry, endExclusive) - dateToX(geometry, drag.endExclusive); + + return { + ...base, + start: drag.start, + endExclusive, + followers: followers.map((follower) => shiftSpan(geometry, follower, effectiveDelta)), + }; +} + +function samePreview(a: TimelineDragPreview | null, b: TimelineDragPreview): boolean { + if (!a || a.rowId !== b.rowId || a.mode !== b.mode || a.progress !== b.progress) return false; + if (a.start.getTime() !== b.start.getTime() || a.endExclusive.getTime() !== b.endExclusive.getTime()) return false; + if (a.followers.length !== b.followers.length) return false; + + return a.followers.every( + (follower, index) => + follower.rowId === b.followers[index].rowId && + follower.start.getTime() === b.followers[index].start.getTime() && + follower.endExclusive.getTime() === b.followers[index].endExclusive.getTime() + ); +} + +export function useTimelineDrag({ geometry, scrollerRef, sidebarWidth, onCommit, onClick }: UseTimelineDragOptions) { + const [preview, setPreview] = useState(null); + const [active, setActive] = useState(false); + const previewRef = useRef(null); + const dragRef = useRef(null); + const geometryRef = useRef(geometry); + const lastClientXRef = useRef(0); + const autoScrollFrameRef = useRef(0); + + geometryRef.current = geometry; + + const updatePreview = useCallback(() => { + const drag = dragRef.current; + const scroller = scrollerRef.current; + + if (!drag || !scroller) return; + const deltaPx = lastClientXRef.current - drag.startClientX + (scroller.scrollLeft - drag.startScrollLeft); + + if (!drag.moved && Math.abs(deltaPx) < DRAG_THRESHOLD_PX) return; + drag.moved = true; + const next = applyDragDelta(geometryRef.current, drag, deltaPx); + + if (samePreview(previewRef.current, next)) return; + previewRef.current = next; + setPreview(next); + }, [scrollerRef]); + + const stopAutoScroll = useCallback(() => { + if (autoScrollFrameRef.current) cancelAnimationFrame(autoScrollFrameRef.current); + autoScrollFrameRef.current = 0; + }, []); + + const autoScroll = useCallback(() => { + const scroller = scrollerRef.current; + const drag = dragRef.current; + + autoScrollFrameRef.current = 0; + if (!scroller || !drag || drag.mode === 'progress') return; + const bounds = scroller.getBoundingClientRect(); + const x = lastClientXRef.current; + let step = 0; + + if (x < bounds.left + sidebarWidth + AUTO_SCROLL_EDGE_PX) step = -AUTO_SCROLL_STEP_PX; + else if (x > bounds.right - AUTO_SCROLL_EDGE_PX) step = AUTO_SCROLL_STEP_PX; + + if (step !== 0) { + scroller.scrollLeft += step; + updatePreview(); + autoScrollFrameRef.current = requestAnimationFrame(autoScroll); + } + }, [scrollerRef, sidebarWidth, updatePreview]); + + const finish = useCallback( + (commit: boolean) => { + const drag = dragRef.current; + const current = previewRef.current; + + dragRef.current = null; + previewRef.current = null; + stopAutoScroll(); + setPreview(null); + setActive(false); + if (!drag) return; + + if (!drag.moved) { + if (commit && drag.mode !== 'progress') onClick?.(drag.rowId); + return; + } + + if (commit && current && current.rowId === drag.rowId) onCommit(current); + }, + [onClick, onCommit, stopAutoScroll] + ); + + useEffect(() => { + if (!active) return; + + const handleMove = (event: PointerEvent) => { + const drag = dragRef.current; + + if (!drag || event.pointerId !== drag.pointerId) return; + lastClientXRef.current = event.clientX; + updatePreview(); + if (!autoScrollFrameRef.current) autoScrollFrameRef.current = requestAnimationFrame(autoScroll); + }; + + const handleUp = (event: PointerEvent) => { + const drag = dragRef.current; + + if (!drag || event.pointerId !== drag.pointerId) return; + finish(true); + }; + + const handleCancel = (event: PointerEvent) => { + const drag = dragRef.current; + + if (!drag || event.pointerId !== drag.pointerId) return; + finish(false); + }; + + const handleKey = (event: KeyboardEvent) => { + if (event.key === 'Escape' && dragRef.current) { + event.preventDefault(); + finish(false); + } + }; + + window.addEventListener('pointermove', handleMove, { passive: true }); + window.addEventListener('pointerup', handleUp); + window.addEventListener('pointercancel', handleCancel); + window.addEventListener('keydown', handleKey, true); + return () => { + window.removeEventListener('pointermove', handleMove); + window.removeEventListener('pointerup', handleUp); + window.removeEventListener('pointercancel', handleCancel); + window.removeEventListener('keydown', handleKey, true); + }; + }, [active, autoScroll, finish, updatePreview]); + + const startDrag = useCallback( + (event: ReactPointerEvent, origin: TimelineDragOrigin, mode: TimelineDragMode) => { + if (event.button !== 0) return; + const scroller = scrollerRef.current; + + if (!scroller) return; + event.preventDefault(); + event.stopPropagation(); + dragRef.current = { + ...origin, + mode, + pointerId: event.pointerId, + startClientX: event.clientX, + startScrollLeft: scroller.scrollLeft, + moved: false, + }; + lastClientXRef.current = event.clientX; + previewRef.current = null; + setActive(true); + }, + [scrollerRef] + ); + + return { preview, dragging: active, startDrag }; +} diff --git a/src/components/database/timeline/hooks/useTimelineFieldValues.ts b/src/components/database/timeline/hooks/useTimelineFieldValues.ts new file mode 100644 index 000000000..3ba3633ff --- /dev/null +++ b/src/components/database/timeline/hooks/useTimelineFieldValues.ts @@ -0,0 +1,73 @@ +import debounce from 'lodash-es/debounce'; +import { useEffect, useState } from 'react'; +import * as Y from 'yjs'; + +import { getCell, useFieldSelector, useRowMap, useRowOrdersSelector } from '@/application/database-yjs'; +import { YDatabaseCell, YjsDatabaseKey, YjsEditorKey } from '@/application/types'; + +const EMPTY = new Map(); + +/** + * One parsed cell value per row for `fieldId`, refreshed when any row doc + * changes. Rows without a cell (or not yet loaded) are absent from the map. + */ +export function useTimelineFieldValues( + fieldId: string, + parse: (cell: YDatabaseCell) => T | undefined +): Map { + const { field, clock } = useFieldSelector(fieldId); + const rowOrders = useRowOrdersSelector(); + const rows = useRowMap(); + const [values, setValues] = useState>(EMPTY); + + useEffect(() => { + if (!field || !fieldId || !rowOrders || !rows) { + setValues(EMPTY); + return; + } + + const read = () => { + const next = new Map(); + + rowOrders.forEach((row) => { + const cell = getCell(row.id, fieldId, rows); + + if (!cell) return; + const value = parse(cell); + + if (value !== undefined) next.set(row.id, value); + }); + setValues(next); + }; + + read(); + const debounced = debounce(read, 150); + const docs = rowOrders.map((row) => rows[row.id]).filter(Boolean); + + docs.forEach((doc) => doc.getMap(YjsEditorKey.data_section).observeDeep(debounced)); + return () => { + debounced.cancel(); + docs.forEach((doc) => doc.getMap(YjsEditorKey.data_section).unobserveDeep(debounced)); + }; + }, [field, clock, fieldId, parse, rowOrders, rows]); + + return values; +} + +/** Row ids linked through a Relation cell. */ +export function parseRelationRowIds(cell: YDatabaseCell): string[] | undefined { + const data = cell.get(YjsDatabaseKey.data); + + if (data instanceof Y.Array) return (data.toArray() as unknown[]).filter((id): id is string => typeof id === 'string'); + if (Array.isArray(data)) return data.filter((id): id is string => typeof id === 'string'); + return undefined; +} + +/** A Number cell clamped to 0–100, the way frappe-gantt reads task progress. */ +export function parseProgressPercent(cell: YDatabaseCell): number | undefined { + const raw = cell.get(YjsDatabaseKey.data); + const value = typeof raw === 'number' ? raw : typeof raw === 'string' ? Number(raw.replace(/[^0-9.-]/g, '')) : NaN; + + if (!Number.isFinite(value)) return undefined; + return Math.min(100, Math.max(0, value)); +} diff --git a/src/components/database/timeline/hooks/useTimelinePermissions.ts b/src/components/database/timeline/hooks/useTimelinePermissions.ts new file mode 100644 index 000000000..cf999e0c2 --- /dev/null +++ b/src/components/database/timeline/hooks/useTimelinePermissions.ts @@ -0,0 +1,25 @@ +import { useMemo } from 'react'; + +import { FieldType, useFieldSelector, useReadOnly } from '@/application/database-yjs'; +import { YjsDatabaseKey } from '@/application/types'; + +/** + * Whether bars can be moved, resized or created. Created/last-edited time + * fields are system-managed, so a timeline plotted on them is read-only, as + * in the calendar. + */ +export function useTimelinePermissions(fieldId: string) { + const readOnly = useReadOnly(); + const { field } = useFieldSelector(fieldId); + const fieldType = field ? (Number(field.get(YjsDatabaseKey.type)) as FieldType) : null; + const isTimeSystemField = fieldType === FieldType.CreatedTime || fieldType === FieldType.LastEditedTime; + + return useMemo( + () => ({ + readOnly, + isTimeSystemField, + editable: !readOnly && !isTimeSystemField, + }), + [isTimeSystemField, readOnly] + ); +} diff --git a/src/components/database/timeline/hooks/useTimelineRange.ts b/src/components/database/timeline/hooks/useTimelineRange.ts new file mode 100644 index 000000000..2210b0030 --- /dev/null +++ b/src/components/database/timeline/hooks/useTimelineRange.ts @@ -0,0 +1,195 @@ +/** + * Rendered date range of the timeline canvas. + * + * The range starts as a chunk of columns on either side of today and grows in + * chunks whenever the viewport nears an edge, which is how frappe/gantt's + * `infinite_padding` works (MIT, Copyright (c) 2024 Frappe Technologies Pvt. + * Ltd.). Growing to the left prepends columns, so the scroll position is + * shifted by the same width in a layout effect to keep the view still. + */ +import { RefObject, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; + +import { TimelineLayout } from '@/application/database-yjs'; + +import { addColumns, columnIndexOf, dateToX, floorToColumn, TimelineGeometry, xToDate } from '../scale/geometry'; +import { getTimelinePreset } from '../scale/presets'; + +interface RangeState { + layout: TimelineLayout; + origin: Date; + columnCount: number; +} + +interface ScrollTarget { + date: Date; + /** Where `date` lands in the visible canvas: 0 = left edge, 0.5 = centre. */ + anchor: number; + behavior: ScrollBehavior; +} + +interface PendingScroll { + /** Shift `scrollLeft` by this many pixels once prepended columns are laid out. */ + deltaX: number; + target?: ScrollTarget; +} + +function buildRange(layout: TimelineLayout, around: Date): RangeState { + const preset = getTimelinePreset(layout); + const center = floorToColumn(preset, around); + + return { + layout, + origin: addColumns(preset, center, -preset.chunkColumns), + columnCount: preset.chunkColumns * 2, + }; +} + +export interface UseTimelineRangeOptions { + layout: TimelineLayout; + scrollerRef: RefObject; + /** Width of the sticky sidebar that overlays the left of the viewport. */ + sidebarWidth: number; +} + +export function useTimelineRange({ layout, scrollerRef, sidebarWidth }: UseTimelineRangeOptions) { + const [range, setRange] = useState(() => buildRange(layout, new Date())); + const pendingRef = useRef({ deltaX: 0 }); + // Set while a growth state update is in flight so scroll events can't + // queue the same prepend twice (frappe's `extended` flag). + const growingRef = useRef(false); + const geometry = useMemo( + () => ({ preset: getTimelinePreset(range.layout), origin: range.origin, columnCount: range.columnCount }), + [range] + ); + const geometryRef = useRef(geometry); + + geometryRef.current = geometry; + + const visibleCanvasWidth = useCallback( + () => Math.max(0, (scrollerRef.current?.clientWidth ?? 0) - sidebarWidth), + [scrollerRef, sidebarWidth] + ); + + // A layout change rebuilds the range around the date at the centre of the + // viewport, so the scale changes around what the user is looking at. + const previousZoomRef = useRef(layout); + + if (previousZoomRef.current !== layout) { + previousZoomRef.current = layout; + const scroller = scrollerRef.current; + const centre = xToDate(geometryRef.current, (scroller?.scrollLeft ?? 0) + visibleCanvasWidth() / 2); + + pendingRef.current = { deltaX: 0, target: { date: centre, anchor: 0.5, behavior: 'auto' } }; + setRange(buildRange(layout, centre)); + } + + const grow = useCallback((prependChunks: number, appendChunks: number) => { + if (prependChunks === 0 && appendChunks === 0) return; + const preset = geometryRef.current.preset; + + growingRef.current = true; + if (prependChunks > 0) { + pendingRef.current.deltaX += prependChunks * preset.chunkColumns * preset.columnWidth; + } + + setRange((prev) => ({ + ...prev, + origin: addColumns(preset, prev.origin, -prependChunks * preset.chunkColumns), + columnCount: prev.columnCount + (prependChunks + appendChunks) * preset.chunkColumns, + })); + }, []); + + /** Chunks needed so `date` sits at least one chunk inside both edges. */ + const chunksToContain = useCallback((date: Date) => { + const current = geometryRef.current; + const { chunkColumns } = current.preset; + const index = columnIndexOf(current, date); + const prepend = index < chunkColumns ? Math.ceil((chunkColumns - index) / chunkColumns) : 0; + const overflow = index + chunkColumns - current.columnCount; + const append = overflow > 0 ? Math.ceil(overflow / chunkColumns) : 0; + + return { prepend, append }; + }, []); + + const scrollToDate = useCallback( + (date: Date, anchor = 0, behavior: ScrollBehavior = 'smooth') => { + const scroller = scrollerRef.current; + + if (!scroller) return; + const { prepend, append } = chunksToContain(date); + + if (prepend === 0 && append === 0) { + const x = dateToX(geometryRef.current, date) - anchor * visibleCanvasWidth(); + + scroller.scrollTo({ left: Math.max(0, x), behavior }); + return; + } + + pendingRef.current.target = { date, anchor, behavior }; + grow(prepend, append); + }, + [chunksToContain, grow, scrollerRef, visibleCanvasWidth] + ); + + const scrollByColumns = useCallback( + (columns: number) => { + const scroller = scrollerRef.current; + + if (!scroller) return; + const current = geometryRef.current; + const targetDate = xToDate(current, scroller.scrollLeft + columns * current.preset.columnWidth); + + scrollToDate(targetDate, 0, 'smooth'); + }, + [scrollToDate, scrollerRef] + ); + + /** Call from the scroller's scroll handler to grow the range near the edges. */ + const handleScroll = useCallback(() => { + const scroller = scrollerRef.current; + + if (!scroller || growingRef.current) return; + const viewport = scroller.clientWidth; + const { scrollLeft, scrollWidth } = scroller; + const prepend = scrollLeft < viewport ? 1 : 0; + const append = scrollWidth - (scrollLeft + viewport) < viewport ? 1 : 0; + + grow(prepend, append); + }, [grow, scrollerRef]); + + useLayoutEffect(() => { + const scroller = scrollerRef.current; + const pending = pendingRef.current; + + pendingRef.current = { deltaX: 0 }; + growingRef.current = false; + if (!scroller) return; + + if (pending.target) { + const { date, anchor, behavior } = pending.target; + const x = dateToX(geometry, date) - anchor * Math.max(0, scroller.clientWidth - sidebarWidth); + + scroller.scrollTo({ left: Math.max(0, x), behavior }); + return; + } + + if (pending.deltaX) { + scroller.scrollLeft += pending.deltaX; + } + }, [geometry, scrollerRef, sidebarWidth]); + + // First paint lands on today, a quarter of the way into the canvas. + const initialisedRef = useRef(false); + + useLayoutEffect(() => { + const scroller = scrollerRef.current; + + if (initialisedRef.current || !scroller) return; + initialisedRef.current = true; + const x = dateToX(geometry, new Date()) - 0.25 * Math.max(0, scroller.clientWidth - sidebarWidth); + + scroller.scrollLeft = Math.max(0, x); + }, [geometry, scrollerRef, sidebarWidth]); + + return { geometry, handleScroll, scrollToDate, scrollByColumns }; +} diff --git a/src/components/database/timeline/hooks/useTimelineRows.ts b/src/components/database/timeline/hooks/useTimelineRows.ts new file mode 100644 index 000000000..7da1338f9 --- /dev/null +++ b/src/components/database/timeline/hooks/useTimelineRows.ts @@ -0,0 +1,59 @@ +import { useMemo } from 'react'; + +import { CalendarEvent, useRowOrdersSelector, useTimelineEventsSelector } from '@/application/database-yjs'; + +export interface TimelineRowModel { + rowId: string; + title: string; + /** Present when the row has a date on the timeline field. */ + start?: Date; + end?: Date; + allDay: boolean; + isRange: boolean; +} + +/** + * Rows in view order (filters and sorts applied). Undated rows only occupy a + * row when the table is docked, matching Notion; they always stay available + * through the No-date list. + */ +export function useTimelineRows(includeUndated: boolean) { + const rowOrders = useRowOrdersSelector(); + const { events, emptyEvents } = useTimelineEventsSelector(); + + const rows = useMemo(() => { + const byRowId = new Map(); + + events.forEach((event) => byRowId.set(event.rowId, event)); + const undated = new Map(); + + emptyEvents.forEach((event) => undated.set(event.rowId, event)); + + const result: TimelineRowModel[] = []; + + (rowOrders ?? []).forEach((row) => { + const event = byRowId.get(row.id); + + if (event?.start) { + result.push({ + rowId: row.id, + title: event.title, + start: event.start, + end: event.end, + allDay: event.allDay, + isRange: Boolean(event.isRange), + }); + return; + } + + if (!includeUndated) return; + const empty = undated.get(row.id); + + result.push({ rowId: row.id, title: empty?.title ?? '', allDay: true, isRange: false }); + }); + + return result; + }, [events, emptyEvents, includeUndated, rowOrders]); + + return { rows, emptyEvents }; +} diff --git a/src/components/database/timeline/index.ts b/src/components/database/timeline/index.ts new file mode 100644 index 000000000..8fd97a546 --- /dev/null +++ b/src/components/database/timeline/index.ts @@ -0,0 +1 @@ +export { Timeline as default, Timeline } from './Timeline'; diff --git a/src/components/database/timeline/scale/dependencies.ts b/src/components/database/timeline/scale/dependencies.ts new file mode 100644 index 000000000..e2676fb3e --- /dev/null +++ b/src/components/database/timeline/scale/dependencies.ts @@ -0,0 +1,133 @@ +/** + * Dependency graph helpers and the arrow routing between bars. + * + * The arrow path is a port of frappe/gantt's `Arrow.calculate_path` (MIT, + * Copyright (c) 2024 Frappe Technologies Pvt. Ltd.): leave the predecessor + * from underneath its bar, drop to the successor's row and enter its left + * edge, looping back with two extra bends when the successor starts before + * the predecessor ends. + */ +import { BarRect } from './geometry'; + +export interface DependencyGraph { + /** rowId → rows it depends on (must finish before it). */ + predecessors: Map; + /** rowId → rows that depend on it. */ + dependents: Map; +} + +/** Build both directions of the graph, ignoring links to rows outside the view. */ +export function buildDependencyGraph(rowIds: string[], relations: Map): DependencyGraph { + const known = new Set(rowIds); + const predecessors = new Map(); + const dependents = new Map(); + + rowIds.forEach((rowId) => { + const linked = (relations.get(rowId) ?? []).filter((id) => id !== rowId && known.has(id)); + + if (linked.length === 0) return; + predecessors.set(rowId, linked); + linked.forEach((predecessor) => { + const list = dependents.get(predecessor) ?? []; + + list.push(rowId); + dependents.set(predecessor, list); + }); + }); + + return { predecessors, dependents }; +} + +/** Every row that transitively depends on `rowId` (frappe's `get_all_dependent_tasks`). */ +export function collectDependents(rowId: string, graph: DependencyGraph): string[] { + const seen = new Set(); + const queue = [rowId]; + + while (queue.length > 0) { + const current = queue.shift() as string; + + (graph.dependents.get(current) ?? []).forEach((dependent) => { + if (seen.has(dependent) || dependent === rowId) return; + seen.add(dependent); + queue.push(dependent); + }); + } + + return Array.from(seen); +} + +export interface ArrowEndpoint { + rect: BarRect; + /** Row index in the rendered list. */ + index: number; +} + +export interface ArrowGeometryOptions { + rowHeight: number; + /** Vertical inset of a bar inside its row. */ + barInset: number; + /** Horizontal clearance used for the loop-back route. */ + padding?: number; + /** Corner radius of the bends. */ + curve?: number; +} + +/** SVG path from the end of `from` to the start of `to`, including the arrow head. */ +export function dependencyArrowPath(from: ArrowEndpoint, to: ArrowEndpoint, options: ArrowGeometryOptions): string { + const { rowHeight, barInset } = options; + const padding = options.padding ?? 18; + const barHeight = rowHeight - barInset * 2; + const rowTop = (index: number) => index * rowHeight; + + let startX = from.rect.left + from.rect.width / 2; + + // Walk the exit point left until the successor's start is reachable. + while (to.rect.left < startX + padding && startX > from.rect.left + padding) { + startX -= 10; + } + + startX -= 10; + const startY = rowTop(from.index) + barInset + barHeight; + const endX = to.rect.left - 13; + const endY = rowTop(to.index) + rowHeight / 2; + const fromIsBelowTo = from.index > to.index; + const clockwise = fromIsBelowTo ? 1 : 0; + let curve = options.curve ?? 5; + let curveY = fromIsBelowTo ? -curve : curve; + + if (to.rect.left <= from.rect.left + padding) { + let down1 = padding / 2 - curve; + + if (down1 < 0) { + down1 = 0; + curve = padding / 2; + curveY = fromIsBelowTo ? -curve : curve; + } + + const down2 = rowTop(to.index) + barInset + barHeight / 2 - curveY; + const left = to.rect.left - padding; + + return [ + `M ${startX} ${startY}`, + `v ${down1}`, + `a ${curve} ${curve} 0 0 1 ${-curve} ${curve}`, + `H ${left}`, + `a ${curve} ${curve} 0 0 ${clockwise} ${-curve} ${curveY}`, + `V ${down2}`, + `a ${curve} ${curve} 0 0 ${clockwise} ${curve} ${curveY}`, + `L ${endX} ${endY}`, + 'm -5 -5 l 5 5 l -5 5', + ].join(' '); + } + + if (endX < startX + curve) curve = endX - startX; + const offset = fromIsBelowTo ? endY + curve : endY - curve; + + return [ + `M ${startX} ${startY}`, + `V ${offset}`, + `a ${curve} ${curve} 0 0 ${clockwise} ${curve} ${curve}`, + `L ${endX} ${endY}`, + 'm -5 -5 l 5 5 l -5 5', + ].join(' '); +} diff --git a/src/components/database/timeline/scale/geometry.ts b/src/components/database/timeline/scale/geometry.ts new file mode 100644 index 000000000..9dca478e7 --- /dev/null +++ b/src/components/database/timeline/scale/geometry.ts @@ -0,0 +1,282 @@ +/** + * Date ↔ pixel geometry for the timeline canvas. + * + * Day-unit presets use calendar-day arithmetic so DST transitions never shift a + * bar by an hour; hour-unit presets position by wall-clock milliseconds. The + * snapping and header-label generation follow frappe/gantt's `get_snap_position` + * and `get_date_info` (MIT, Copyright (c) 2024 Frappe Technologies Pvt. Ltd.). + */ +import dayjs from 'dayjs'; + +import { TimelineScalePreset } from './presets'; + +export const MS_PER_MINUTE = 60_000; +export const MS_PER_HOUR = 3_600_000; +export const MS_PER_DAY = 86_400_000; + +/** Narrowest a bar can render so it stays clickable. */ +export const MIN_BAR_WIDTH = 8; +/** Below this width the bar shows only the row icon and the title spills out. */ +export const ICON_ONLY_BAR_WIDTH = 56; +/** Synthetic length of a timed event without an end, matching the calendar. */ +export const DEFAULT_TIMED_DURATION_MS = 30 * MS_PER_MINUTE; + +export interface TimelineGeometry { + preset: TimelineScalePreset; + /** Start of the first rendered column: a local midnight for every preset. */ + origin: Date; + /** Number of rendered columns. */ + columnCount: number; +} + +export interface BarRect { + left: number; + width: number; +} + +export type SnapMode = 'floor' | 'round' | 'ceil'; + +export function startOfDay(date: Date): Date { + const day = new Date(date.getTime()); + + day.setHours(0, 0, 0, 0); + return day; +} + +/** Local midnight `days` calendar days after `day` (itself a local midnight). */ +function addCalendarDays(day: Date, days: number): Date { + const next = new Date(day.getTime()); + + next.setDate(next.getDate() + days); + return next; +} + +/** Start of the column that contains `date`. */ +export function floorToColumn(preset: TimelineScalePreset, date: Date): Date { + return preset.unit === 'day' ? startOfDay(date) : dayjs(date).startOf('hour').toDate(); +} + +/** `date` moved by `count` columns, DST-safe for day presets. */ +export function addColumns(preset: TimelineScalePreset, date: Date, count: number): Date { + return preset.unit === 'day' ? dayjs(date).add(count, 'day').toDate() : new Date(date.getTime() + count * MS_PER_HOUR); +} + +/** Whole calendar days from `from` to `to` (negative when `to` is earlier). */ +export function calendarDaysBetween(from: Date, to: Date): number { + // Both operands are local midnights, so the difference is a whole number of + // days give or take a DST hour; rounding recovers the exact count. + return Math.round((startOfDay(to).getTime() - startOfDay(from).getTime()) / MS_PER_DAY); +} + +/** Fractional column index of `date` relative to the origin. */ +export function columnIndexOf(geometry: TimelineGeometry, date: Date): number { + const { origin, preset } = geometry; + + if (preset.unit === 'hour') { + return (date.getTime() - origin.getTime()) / MS_PER_HOUR; + } + + const day = startOfDay(date); + const dayIndex = calendarDaysBetween(origin, day); + const dayLength = addCalendarDays(day, 1).getTime() - day.getTime(); + + return dayIndex + (date.getTime() - day.getTime()) / dayLength; +} + +export function dateToX(geometry: TimelineGeometry, date: Date): number { + return columnIndexOf(geometry, date) * geometry.preset.columnWidth; +} + +/** Inverse of `dateToX`; the result is not snapped. */ +export function xToDate(geometry: TimelineGeometry, x: number): Date { + const { origin, preset } = geometry; + const index = x / preset.columnWidth; + + if (preset.unit === 'hour') { + return new Date(origin.getTime() + index * MS_PER_HOUR); + } + + const wholeDays = Math.floor(index); + const day = dayjs(origin).add(wholeDays, 'day'); + const dayLength = day.add(1, 'day').valueOf() - day.valueOf(); + + return new Date(day.valueOf() + (index - wholeDays) * dayLength); +} + +export function columnStart(geometry: TimelineGeometry, index: number): Date { + return addColumns(geometry.preset, geometry.origin, index); +} + +export function rangeEnd(geometry: TimelineGeometry): Date { + return columnStart(geometry, geometry.columnCount); +} + +export function totalWidth(geometry: TimelineGeometry): number { + return geometry.columnCount * geometry.preset.columnWidth; +} + +/** Snap `date` to the preset's grid, measured from local midnight. */ +export function snapDate(preset: TimelineScalePreset, date: Date, mode: SnapMode = 'round'): Date { + const day = startOfDay(date); + const minutesIntoDay = (date.getTime() - day.getTime()) / MS_PER_MINUTE; + const steps = minutesIntoDay / preset.snapMinutes; + const snappedSteps = mode === 'floor' ? Math.floor(steps) : mode === 'ceil' ? Math.ceil(steps) : Math.round(steps); + + return dayjs(day) + .add(snappedSteps * preset.snapMinutes, 'minute') + .toDate(); +} + +export interface BarSpan { + start: Date; + /** Exclusive end: the first instant after the bar. */ + endExclusive: Date; +} + +/** + * Time span a row's bar covers. All-day ranges are end-inclusive (a 9–12 Nov + * range covers four days); timed events end where their end timestamp says, + * or a synthetic 30 minutes later when they have none. + */ +export function getBarSpan(start: Date, end: Date | undefined, allDay: boolean): BarSpan { + if (allDay) { + return { + start: startOfDay(start), + endExclusive: dayjs(startOfDay(end && end >= start ? end : start)) + .add(1, 'day') + .toDate(), + }; + } + + return { + start, + endExclusive: end && end > start ? end : new Date(start.getTime() + DEFAULT_TIMED_DURATION_MS), + }; +} + +export function getSpanRect(geometry: TimelineGeometry, span: BarSpan, minWidth = MIN_BAR_WIDTH): BarRect { + const left = dateToX(geometry, span.start); + const width = Math.max(dateToX(geometry, span.endExclusive) - left, minWidth); + + return { left, width }; +} + +/** + * Narrowest a row's bar may render. On day scales a timed row keeps its whole + * day column, the way a calendar month cell shows a timed event regardless of + * its length; on hour scales bars are true to their duration. + */ +export function minBarWidth(geometry: TimelineGeometry, allDay: boolean): number { + return !allDay && geometry.preset.unit === 'day' ? geometry.preset.columnWidth : MIN_BAR_WIDTH; +} + +export function getBarRect(geometry: TimelineGeometry, start: Date, end: Date | undefined, allDay: boolean): BarRect { + return getSpanRect(geometry, getBarSpan(start, end, allDay), minBarWidth(geometry, allDay)); +} + +export interface HeaderColumn { + index: number; + start: Date; + x: number; + width: number; + label: string; + isWeekend: boolean; + isToday: boolean; + /** Whether a gridline is drawn at this column's left edge. */ + gridLine: boolean; +} + +export interface HeaderSegment { + start: Date; + x: number; + width: number; + label: string; +} + +export function isWeekend(date: Date): boolean { + const day = date.getDay(); + + return day === 0 || day === 6; +} + +function isColumnToday(preset: TimelineScalePreset, columnStartDate: Date, now: Date): boolean { + return preset.unit === 'day' ? dayjs(columnStartDate).isSame(now, 'day') : dayjs(columnStartDate).isSame(now, 'hour'); +} + +function hasGridLine(preset: TimelineScalePreset, date: Date, firstDayOfWeek: number): boolean { + switch (preset.gridLines) { + case 'column': + return true; + case 'week': + return date.getDay() === firstDayOfWeek; + case 'month': + return date.getDate() === 1; + default: + return false; + } +} + +/** Columns in `[fromIndex, toIndex)`, clamped to the rendered range. */ +export function buildHeaderColumns( + geometry: TimelineGeometry, + fromIndex: number, + toIndex: number, + firstDayOfWeek: number, + now: Date, + use24Hour = false +): HeaderColumn[] { + const { preset } = geometry; + const first = Math.max(0, Math.floor(fromIndex)); + const last = Math.min(geometry.columnCount, Math.ceil(toIndex)); + const columns: HeaderColumn[] = []; + + for (let index = first; index < last; index += 1) { + const start = columnStart(geometry, index); + + columns.push({ + index, + start, + x: index * preset.columnWidth, + width: preset.columnWidth, + label: preset.lowerText(start, firstDayOfWeek, use24Hour), + isWeekend: preset.shadeWeekends && isWeekend(start), + isToday: isColumnToday(preset, start, now), + gridLine: hasGridLine(preset, start, firstDayOfWeek), + }); + } + + return columns; +} + +/** + * Upper-band segments (days for hour presets, months otherwise) that overlap + * `[fromIndex, toIndex)`. Segment extents are clamped to the rendered range so + * a sticky label never sits outside the canvas. + */ +export function buildHeaderSegments(geometry: TimelineGeometry, fromIndex: number, toIndex: number): HeaderSegment[] { + const { preset } = geometry; + const unit = preset.unit === 'hour' ? 'day' : 'month'; + const rangeStart = geometry.origin; + const rangeStop = rangeEnd(geometry); + const windowStart = columnStart(geometry, Math.max(0, Math.floor(fromIndex))); + const windowStop = columnStart(geometry, Math.min(geometry.columnCount, Math.ceil(toIndex))); + const segments: HeaderSegment[] = []; + + let cursor = dayjs(windowStart).startOf(unit); + + while (cursor.toDate() < windowStop) { + const segmentStart = cursor.toDate() < rangeStart ? rangeStart : cursor.toDate(); + const next = cursor.add(1, unit).toDate(); + const segmentEnd = next > rangeStop ? rangeStop : next; + const x = dateToX(geometry, segmentStart); + const width = dateToX(geometry, segmentEnd) - x; + + if (width > 0) { + segments.push({ start: cursor.toDate(), x, width, label: preset.upperText(cursor.toDate()) }); + } + + cursor = cursor.add(1, unit); + } + + return segments; +} diff --git a/src/components/database/timeline/scale/presets.ts b/src/components/database/timeline/scale/presets.ts new file mode 100644 index 000000000..ecfb5a104 --- /dev/null +++ b/src/components/database/timeline/scale/presets.ts @@ -0,0 +1,188 @@ +/** + * Timeline scale presets. + * + * The preset shape (a column unit + step, a column width, upper/lower header + * label formatters, a snap unit and a padding chunk) is adapted from + * frappe/gantt's `DEFAULT_VIEW_MODES` (MIT, Copyright (c) 2024 Frappe + * Technologies Pvt. Ltd.). The seven presets themselves mirror Notion's + * timeline zoom menu: Hours, Day, Week, Bi-week, Month, Quarter, Year. + */ +import dayjs from 'dayjs'; + +import { TimelineLayout } from '@/application/database-yjs'; + +export type TimelineUnit = 'hour' | 'day'; + +export type TimelineGridLines = 'column' | 'week' | 'month'; + +/** How the single header row is drawn: one label per column, or one per band segment. */ +export type TimelineHeaderMode = 'cells' | 'segments'; + +export interface TimelineScalePreset { + zoom: TimelineLayout; + /** i18n key under `timeline.zoom` and its English fallback. */ + labelKey: string; + label: string; + /** Unit each column represents. Hour presets position bars by wall-clock time. */ + unit: TimelineUnit; + /** Pixel width of a single column. */ + columnWidth: number; + /** Columns added on each side when the range is first built or extended. */ + chunkColumns: number; + /** Columns scrolled by the ‹ › steppers. */ + stepColumns: number; + /** How far drag/resize snaps, in minutes. */ + snapMinutes: number; + /** + * Toolbar title for the date at the left edge of the viewport, and the label + * of a band segment (a day for hour presets, a month otherwise). + */ + upperText: (segmentStart: Date) => string; + /** Label for a column cell; empty string hides the cell label. */ + lowerText: (columnStart: Date, firstDayOfWeek: number, use24Hour: boolean) => string; + /** Whether the header row shows per-column labels or per-segment labels. */ + headerMode: TimelineHeaderMode; + /** Which columns get a gridline; coarse presets only draw week or month lines. */ + gridLines: TimelineGridLines; + /** Whether weekend columns are shaded. */ + shadeWeekends: boolean; +} + +const HOUR_UPPER = (segmentStart: Date) => dayjs(segmentStart).format('ddd, MMM D'); +const DAY_UPPER = (segmentStart: Date) => dayjs(segmentStart).format('MMMM D, YYYY'); +const MONTH_UPPER = (segmentStart: Date) => dayjs(segmentStart).format('MMMM YYYY'); +/** Hour labels follow the user's 12/24-hour preference, as the calendar's time grid does. */ +const HOUR_LOWER = (columnStart: Date, _firstDayOfWeek: number, use24Hour: boolean) => + dayjs(columnStart).format(use24Hour ? 'HH:mm' : 'h A'); +/** Day number, with the month named on the first of each month so boundaries read while scrolling. */ +const DAY_NUMBER = (columnStart: Date) => dayjs(columnStart).format(columnStart.getDate() === 1 ? 'MMM D' : 'D'); +/** Calendar week-header style: weekday name and day number. */ +const WEEKDAY_AND_DAY = (columnStart: Date) => + dayjs(columnStart).format(columnStart.getDate() === 1 ? 'ddd MMM D' : 'ddd D'); +const SHORT_WEEKDAY_AND_DAY = (columnStart: Date) => dayjs(columnStart).format('dd D'); + +export const TIMELINE_SCALE_PRESETS: Record = { + [TimelineLayout.Hours]: { + zoom: TimelineLayout.Hours, + labelKey: 'timeline.zoom.hours', + label: 'Hours', + unit: 'hour', + columnWidth: 60, + chunkColumns: 24 * 7, + stepColumns: 6, + snapMinutes: 15, + upperText: HOUR_UPPER, + lowerText: HOUR_LOWER, + headerMode: 'cells', + gridLines: 'column', + shadeWeekends: true, + }, + [TimelineLayout.Day]: { + zoom: TimelineLayout.Day, + labelKey: 'timeline.zoom.day', + label: 'Day', + unit: 'hour', + columnWidth: 88, + chunkColumns: 24 * 7, + stepColumns: 24, + snapMinutes: 15, + upperText: DAY_UPPER, + lowerText: HOUR_LOWER, + headerMode: 'cells', + gridLines: 'column', + shadeWeekends: true, + }, + [TimelineLayout.Week]: { + zoom: TimelineLayout.Week, + labelKey: 'timeline.zoom.week', + label: 'Week', + unit: 'day', + columnWidth: 140, + chunkColumns: 28, + stepColumns: 7, + snapMinutes: 24 * 60, + upperText: MONTH_UPPER, + lowerText: WEEKDAY_AND_DAY, + headerMode: 'cells', + gridLines: 'column', + shadeWeekends: true, + }, + [TimelineLayout.BiWeek]: { + zoom: TimelineLayout.BiWeek, + labelKey: 'timeline.zoom.biWeek', + label: 'Bi-week', + unit: 'day', + columnWidth: 70, + chunkColumns: 42, + stepColumns: 14, + snapMinutes: 24 * 60, + upperText: MONTH_UPPER, + lowerText: SHORT_WEEKDAY_AND_DAY, + headerMode: 'cells', + gridLines: 'column', + shadeWeekends: true, + }, + [TimelineLayout.Month]: { + zoom: TimelineLayout.Month, + labelKey: 'timeline.zoom.month', + label: 'Month', + unit: 'day', + columnWidth: 36, + chunkColumns: 62, + stepColumns: 30, + snapMinutes: 24 * 60, + upperText: MONTH_UPPER, + lowerText: DAY_NUMBER, + headerMode: 'cells', + gridLines: 'column', + shadeWeekends: true, + }, + [TimelineLayout.Quarter]: { + zoom: TimelineLayout.Quarter, + labelKey: 'timeline.zoom.quarter', + label: 'Quarter', + unit: 'day', + columnWidth: 12, + chunkColumns: 120, + stepColumns: 90, + snapMinutes: 24 * 60, + upperText: MONTH_UPPER, + // One label per week start; the first week of a month names the month. + lowerText: (columnStart, firstDayOfWeek) => + columnStart.getDay() === firstDayOfWeek + ? dayjs(columnStart).format(columnStart.getDate() <= 7 ? 'MMM D' : 'D') + : '', + headerMode: 'cells', + gridLines: 'week', + shadeWeekends: true, + }, + [TimelineLayout.Year]: { + zoom: TimelineLayout.Year, + labelKey: 'timeline.zoom.year', + label: 'Year', + unit: 'day', + columnWidth: 4, + chunkColumns: 365, + stepColumns: 365, + snapMinutes: 24 * 60, + upperText: (segmentStart) => dayjs(segmentStart).format('MMM YYYY'), + lowerText: () => '', + headerMode: 'segments', + gridLines: 'month', + shadeWeekends: false, + }, +}; + +export const TIMELINE_LAYOUT_ORDER: TimelineLayout[] = [ + TimelineLayout.Hours, + TimelineLayout.Day, + TimelineLayout.Week, + TimelineLayout.BiWeek, + TimelineLayout.Month, + TimelineLayout.Quarter, + TimelineLayout.Year, +]; + +export function getTimelinePreset(zoom: TimelineLayout): TimelineScalePreset { + return TIMELINE_SCALE_PRESETS[zoom] ?? TIMELINE_SCALE_PRESETS[TimelineLayout.Month]; +} From 10b8cbc611fb2cb7affd6deac78aac047086cc0f Mon Sep 17 00:00:00 2001 From: Nathan Date: Sun, 13 Sep 2026 14:43:17 +0000 Subject: [PATCH 02/21] fix(timeline): keep a dropped bar where it landed Dropping a bar cleared the drag preview at once, but the row selectors re-read cell data through a 150 ms trailing debounce, so the bar painted at its old dates for several frames before jumping to the new ones. Add createLocalFirstObserver: the user's own transactions re-read in a microtask (folding a dropped bar and its followers into one read, flushed with the drop itself), while remote bursts keep the debounce. Wire it into useDateFieldEventsSelector and useTimelineFieldValues, so date, progress and dependency edits all land in the same paint. A new BDD scenario samples the bar's position every animation frame across a drop and fails if any frame after the first move shows the origin again. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NoEH4HcEiojEcioVSLE3QM --- .../bdd/features/database/timeline.feature | 4 + playwright/bdd/steps/timeline.steps.ts | 94 ++++++++++++++---- playwright/support/timeline-test-helpers.ts | 44 ++++++++- .../__tests__/local-first-observer.test.ts | 98 +++++++++++++++++++ .../database-yjs/local-first-observer.ts | 56 +++++++++++ src/application/database-yjs/selector.ts | 26 ++--- .../timeline/hooks/useTimelineFieldValues.ts | 11 ++- 7 files changed, 295 insertions(+), 38 deletions(-) create mode 100644 src/application/database-yjs/__tests__/local-first-observer.test.ts create mode 100644 src/application/database-yjs/local-first-observer.ts diff --git a/playwright/bdd/features/database/timeline.feature b/playwright/bdd/features/database/timeline.feature index d374b0100..038823009 100644 --- a/playwright/bdd/features/database/timeline.feature +++ b/playwright/bdd/features/database/timeline.feature @@ -44,6 +44,10 @@ Feature: Timeline view interactions When I press undo Then the "Build" bar is back where it started + Scenario: A dropped bar stays where it landed while the row data catches up + When I drag the "Design" bar 9 columns later while sampling its position + Then the "Design" bar never painted back where it started after landing 9 columns later + Scenario: The end handle resizes a bar and Escape cancels a drag in progress When I drag the end handle of "Design" 2 columns later Then the "Design" bar grew by 2 columns diff --git a/playwright/bdd/steps/timeline.steps.ts b/playwright/bdd/steps/timeline.steps.ts index eac4f6311..81caac638 100644 --- a/playwright/bdd/steps/timeline.steps.ts +++ b/playwright/bdd/steps/timeline.steps.ts @@ -3,9 +3,18 @@ import { createBdd } from 'playwright-bdd'; import { DatabaseViewLayout } from '../../../src/application/types'; import { FieldType } from '../../../src/application/database-yjs/database.type'; -import { getCurrentDatabaseInfo, setRelationCellDirect, waitForDatabaseTestContext } from '../../support/relation-test-helpers'; +import { + getCurrentDatabaseInfo, + setRelationCellDirect, + waitForDatabaseTestContext, +} from '../../support/relation-test-helpers'; import { closeRowDetailWithEscape } from '../../support/row-detail-helpers'; -import { CalendarSelectors, DatabaseViewSelectors, RowDetailSelectors, TimelineSelectors } from '../../support/selectors'; +import { + CalendarSelectors, + DatabaseViewSelectors, + RowDetailSelectors, + TimelineSelectors, +} from '../../support/selectors'; import { generateRandomEmail } from '../../support/test-config'; import { activeViewRowIds, @@ -15,6 +24,8 @@ import { chooseTimelineZoom, clickRowCanvas, dragBarBy, + readBarSamples, + startBarSampler, dragHandleBy, expectBarWidth, expectBarX, @@ -84,14 +95,17 @@ async function visibleCanvas(page: Page) { return { left: view.x + TIMELINE_SIDEBAR_WIDTH, right: view.x + view.width }; } -Given('a cloud calendar with {string} today and {string} in {int} days', async ({ page, request, $testInfo }, first, second, offset) => { - $testInfo.setTimeout(240_000); - await loginAndCreateCalendarWithRows(page, request, generateRandomEmail(), [ - { title: first, offsetDays: 0 }, - { title: second, offsetDays: offset }, - ]); - scenarios.set(page, { rowIds: [], rowIdByTitle: new Map(), before: new Map() }); -}); +Given( + 'a cloud calendar with {string} today and {string} in {int} days', + async ({ page, request, $testInfo }, first, second, offset) => { + $testInfo.setTimeout(240_000); + await loginAndCreateCalendarWithRows(page, request, generateRandomEmail(), [ + { title: first, offsetDays: 0 }, + { title: second, offsetDays: offset }, + ]); + scenarios.set(page, { rowIds: [], rowIdByTitle: new Map(), before: new Map() }); + } +); Given('a Timeline view is added from the view menu', async ({ page }) => { await addTimelineView(page, 2); @@ -171,15 +185,21 @@ When('I step the timeline earlier {int} times', async ({ page }, times) => { Then('the {string} bar is off screen to the left with a left pill', async ({ page }, title) => { const canvas = await visibleCanvas(page); - await expect.poll(async () => (await barBox(page, title)).x + (await barBox(page, title)).width, { timeout: 10_000 }).toBeLessThan(canvas.left); - await expect(TimelineSelectors.row(page, rowId(page, title)).locator('[data-testid="timeline-offscreen-left"]')).toBeVisible(); + await expect + .poll(async () => (await barBox(page, title)).x + (await barBox(page, title)).width, { timeout: 10_000 }) + .toBeLessThan(canvas.left); + await expect( + TimelineSelectors.row(page, rowId(page, title)).locator('[data-testid="timeline-offscreen-left"]') + ).toBeVisible(); }); Then('the {string} bar is off screen to the right with a right pill', async ({ page }, title) => { const canvas = await visibleCanvas(page); await expect.poll(async () => (await barBox(page, title)).x, { timeout: 10_000 }).toBeGreaterThan(canvas.right); - await expect(TimelineSelectors.row(page, rowId(page, title)).locator('[data-testid="timeline-offscreen-right"]')).toBeVisible(); + await expect( + TimelineSelectors.row(page, rowId(page, title)).locator('[data-testid="timeline-offscreen-right"]') + ).toBeVisible(); }); When('I click the left off-screen pill', async ({ page }) => { @@ -218,6 +238,31 @@ Then('the {string} bar moved {int} columns later', async ({ page }, title, colum await expectBarX(page, title, before(page, title).x + columns * MONTH_COLUMN_WIDTH); }); +When('I drag the {string} bar {int} columns later while sampling its position', async ({ page }, title, columns) => { + await remember(page, 'Design', 'Build'); + await startBarSampler(page, title); + await dragBarBy(page, title, columns * MONTH_COLUMN_WIDTH); + // Long enough to cover the selectors' 150 ms remote debounce, which is where a snap-back would show. + await page.waitForTimeout(400); +}); + +Then( + 'the {string} bar never painted back where it started after landing {int} columns later', + async ({ page }, title, columns) => { + const origin = Math.round(before(page, title).x); + const target = Math.round(origin + columns * MONTH_COLUMN_WIDTH); + const samples = await readBarSamples(page); + // Frames before the pointer crossed the drag threshold still show the origin. + const firstMoved = samples.findIndex((left) => left !== origin); + + expect(firstMoved, `bar never moved: ${samples.join(',')}`).toBeGreaterThanOrEqual(0); + // Once it moved, no later frame may show the origin again: that would be + // the bar snapping back while the row data caught up. + expect(samples.slice(firstMoved), `bar snapped back after the drop: ${samples.join(',')}`).not.toContain(origin); + await expectBarX(page, title, target); + } +); + When('I press undo', async ({ page }) => { await page.keyboard.press('Control+z'); }); @@ -480,12 +525,18 @@ Given('{string} also has a {string} field {int} days later', async ({ page }, ti }, days); // Every row needs a value on the new field so the rows stay dated; Design keeps today. - await setTextCellDirect(page, rowId(page, 'Design'), 'date-ship', FieldType.DateTime, await page.evaluate(() => { - const date = new Date(); - - date.setHours(0, 0, 0, 0); - return String(Math.floor(date.getTime() / 1000)); - })); + await setTextCellDirect( + page, + rowId(page, 'Design'), + 'date-ship', + FieldType.DateTime, + await page.evaluate(() => { + const date = new Date(); + + date.setHours(0, 0, 0, 0); + return String(Math.floor(date.getTime() / 1000)); + }) + ); await setTextCellDirect(page, other, 'date-ship', FieldType.DateTime, timestamp); state.before.set('Design', design); }); @@ -513,7 +564,10 @@ When('I choose Monday as the timeline week start', async ({ page }) => { Then('the timeline quarter labels fall on Mondays', async ({ page }) => { // Quarter labels sit on week starts; the first week of each month carries the // month name ("Oct 5"), which is enough to resolve the weekday unambiguously. - const labels = await TimelineSelectors.header(page).locator('span').filter({ hasText: /^[A-Z][a-z]{2} \d{1,2}$/ }).allTextContents(); + const labels = await TimelineSelectors.header(page) + .locator('span') + .filter({ hasText: /^[A-Z][a-z]{2} \d{1,2}$/ }) + .allTextContents(); const year = new Date().getFullYear(); expect(labels.length).toBeGreaterThan(0); diff --git a/playwright/support/timeline-test-helpers.ts b/playwright/support/timeline-test-helpers.ts index 6291b86a6..ebfec1ffb 100644 --- a/playwright/support/timeline-test-helpers.ts +++ b/playwright/support/timeline-test-helpers.ts @@ -25,7 +25,10 @@ function isoDate(offsetDays: number) { date.setHours(0, 0, 0, 0); date.setDate(date.getDate() + offsetDays); - return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart( + 2, + '0' + )}`; } /** Create an all-day row through the calendar's placeholder editor. */ @@ -84,6 +87,38 @@ export async function dragBy(page: Page, x: number, y: number, dx: number) { await page.mouse.up(); } +/** + * Record the left edge of a bar on every animation frame until `readBarSamples` + * is called, so a test can prove a dropped bar never paints at its old dates + * while the row data catches up. + */ +export async function startBarSampler(page: Page, title: string) { + await page.evaluate((title) => { + const win = window as unknown as { __TIMELINE_BAR_SAMPLES__?: number[]; __TIMELINE_BAR_SAMPLER__?: number }; + const samples: number[] = []; + const sample = () => { + const bar = Array.from(document.querySelectorAll('[data-testid^="timeline-bar-"]')).find((element) => + element.textContent?.includes(title) + ); + + if (bar) samples.push(Math.round(bar.getBoundingClientRect().left)); + win.__TIMELINE_BAR_SAMPLER__ = requestAnimationFrame(sample); + }; + + win.__TIMELINE_BAR_SAMPLES__ = samples; + win.__TIMELINE_BAR_SAMPLER__ = requestAnimationFrame(sample); + }, title); +} + +export async function readBarSamples(page: Page): Promise { + return page.evaluate(() => { + const win = window as unknown as { __TIMELINE_BAR_SAMPLES__?: number[]; __TIMELINE_BAR_SAMPLER__?: number }; + + if (win.__TIMELINE_BAR_SAMPLER__) cancelAnimationFrame(win.__TIMELINE_BAR_SAMPLER__); + return win.__TIMELINE_BAR_SAMPLES__ ?? []; + }); +} + export async function dragBarBy(page: Page, title: string, dx: number) { const box = await barBox(page, title); @@ -111,7 +146,12 @@ export async function activeViewRowIds(page: Page): Promise { const ctx = (window as unknown as { __TEST_DATABASE_CONTEXT__: any }).__TEST_DATABASE_CONTEXT__; const database = ctx.databaseDoc.getMap('data').get('database'); - return database.get('views').get(ctx.activeViewId).get('row_orders').toArray().map((row: { id: string }) => row.id); + return database + .get('views') + .get(ctx.activeViewId) + .get('row_orders') + .toArray() + .map((row: { id: string }) => row.id); }); } diff --git a/src/application/database-yjs/__tests__/local-first-observer.test.ts b/src/application/database-yjs/__tests__/local-first-observer.test.ts new file mode 100644 index 000000000..2fe07ff68 --- /dev/null +++ b/src/application/database-yjs/__tests__/local-first-observer.test.ts @@ -0,0 +1,98 @@ +import * as Y from 'yjs'; + +import { createLocalFirstObserver } from '../local-first-observer'; + +// The global lodash-es mock makes debounce synchronous; these tests are about timing. +jest.mock('lodash-es', () => jest.requireActual('lodash')); + +const flushMicrotasks = () => new Promise((resolve) => queueMicrotask(resolve)); + +function localTransaction(observer: ReturnType, local = true) { + observer([], { local } as Y.Transaction); +} + +describe('createLocalFirstObserver', () => { + beforeEach(() => { + jest.useFakeTimers({ doNotFake: ['queueMicrotask'] }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + test('a local transaction reads in a microtask and folds the same call stack into one read', async () => { + const read = jest.fn(); + const observer = createLocalFirstObserver(read, 150); + + localTransaction(observer); + localTransaction(observer); + localTransaction(observer); + expect(read).not.toHaveBeenCalled(); + + await flushMicrotasks(); + expect(read).toHaveBeenCalledTimes(1); + }); + + test('remote transactions stay on the trailing debounce', async () => { + const read = jest.fn(); + const observer = createLocalFirstObserver(read, 150); + + localTransaction(observer, false); + await flushMicrotasks(); + expect(read).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(149); + expect(read).not.toHaveBeenCalled(); + jest.advanceTimersByTime(1); + expect(read).toHaveBeenCalledTimes(1); + }); + + test('an immediate read supersedes a pending debounced one', async () => { + const read = jest.fn(); + const observer = createLocalFirstObserver(read, 150); + + localTransaction(observer, false); + localTransaction(observer); + await flushMicrotasks(); + expect(read).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(300); + expect(read).toHaveBeenCalledTimes(1); + }); + + test('local writes right after an immediate read are debounced so a burst costs one read', async () => { + const read = jest.fn(); + const observer = createLocalFirstObserver(read, 150); + + localTransaction(observer); + await flushMicrotasks(); + expect(read).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(50); + localTransaction(observer); + localTransaction(observer); + await flushMicrotasks(); + expect(read).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(150); + expect(read).toHaveBeenCalledTimes(2); + + // Once the window has passed, the next local write is immediate again. + jest.advanceTimersByTime(200); + localTransaction(observer); + await flushMicrotasks(); + expect(read).toHaveBeenCalledTimes(3); + }); + + test('cancel drops both the queued microtask and the debounced read', async () => { + const read = jest.fn(); + const observer = createLocalFirstObserver(read, 150); + + localTransaction(observer); + localTransaction(observer, false); + observer.cancel(); + await flushMicrotasks(); + jest.advanceTimersByTime(300); + expect(read).not.toHaveBeenCalled(); + }); +}); diff --git a/src/application/database-yjs/local-first-observer.ts b/src/application/database-yjs/local-first-observer.ts new file mode 100644 index 000000000..46b239616 --- /dev/null +++ b/src/application/database-yjs/local-first-observer.ts @@ -0,0 +1,56 @@ +import { debounce } from 'lodash-es'; +import * as Y from 'yjs'; + +type YjsDeepObserver = Parameters['observeDeep']>[0]; + +export interface LocalFirstObserver extends YjsDeepObserver { + /** Drop any pending read; call from the effect cleanup. */ + cancel: () => void; +} + +/** + * Wraps a re-read callback for `observeDeep` so the user's own writes show up + * in the same paint as the interaction that made them, while remote updates, + * which arrive in bursts, stay debounced. + * + * A local transaction schedules the read in a microtask: every transaction in + * the same call stack (a dropped bar plus the rows that follow it, say) folds + * into one read, and React flushes the resulting state together with the + * interaction's own updates, so nothing paints from stale data in between. + * Local writes that land within `waitMs` of such a read (typing) fall back to + * the trailing debounce so a burst still costs one read per window. + */ +export function createLocalFirstObserver(read: () => void, waitMs = 150): LocalFirstObserver { + let cancelled = false; + let queued = false; + let lastImmediateReadAt = -Infinity; + const debounced = debounce(read, waitMs); + + const flush = () => { + queued = false; + if (cancelled) return; + lastImmediateReadAt = Date.now(); + debounced.cancel(); + read(); + }; + + const observer = ((_events, transaction) => { + if (cancelled) return; + + if (!transaction.local || Date.now() - lastImmediateReadAt < waitMs) { + debounced(); + return; + } + + if (queued) return; + queued = true; + queueMicrotask(flush); + }) as LocalFirstObserver; + + observer.cancel = () => { + cancelled = true; + debounced.cancel(); + }; + + return observer; +} diff --git a/src/application/database-yjs/selector.ts b/src/application/database-yjs/selector.ts index b8db40da2..bd36d02a9 100644 --- a/src/application/database-yjs/selector.ts +++ b/src/application/database-yjs/selector.ts @@ -47,6 +47,7 @@ import { parseFilter, } from '@/application/database-yjs/filter'; import { DEFAULT_GALLERY_LAYOUT_SETTINGS } from '@/application/database-yjs/gallery-layout'; +import { createLocalFirstObserver } from '@/application/database-yjs/local-first-observer'; import { areGroupRowsHydrated, getGroupColumns, @@ -1982,10 +1983,9 @@ export function useDatabaseGroupingSelector(layout: DatabaseViewLayout): Databas orderedIdSet.add(column.id); } }); - const orderedIds = - numberPolicy - ? orderNumberGroupIds(persistedAndDerivedIds, groupingFieldId, numberPolicy) - : persistedAndDerivedIds; + const orderedIds = numberPolicy + ? orderNumberGroupIds(persistedAndDerivedIds, groupingFieldId, numberPolicy) + : persistedAndDerivedIds; // Seed-only docs may lag a Desktop edit indefinitely because background // grouping hydration deliberately does not bind realtime for offscreen @@ -2011,8 +2011,9 @@ export function useDatabaseGroupingSelector(layout: DatabaseViewLayout): Databas metadataGroupIdSet.add(column.id); } }); - const orderedMetadataGroupIds = - numberPolicy ? orderNumberGroupIds(metadataGroupIds, groupingFieldId, numberPolicy) : metadataGroupIds; + const orderedMetadataGroupIds = numberPolicy + ? orderNumberGroupIds(metadataGroupIds, groupingFieldId, numberPolicy) + : metadataGroupIds; const collapsedValue = group.get(YjsDatabaseKey.collapsed_group_ids) as unknown; const collapsedIds = new Set( @@ -2085,7 +2086,8 @@ export function useDatabaseGroupingSelector(layout: DatabaseViewLayout): Databas const automaticallyHidden = ready && groupRows.length === 0 && - (hideEmptyGroups || (id !== currentFieldId && isDynamicDatabaseGroupFieldType(fieldType) && !numberPolicy?.retainsEmptyGroups)); + (hideEmptyGroups || + (id !== currentFieldId && isDynamicDatabaseGroupFieldType(fieldType) && !numberPolicy?.retainsEmptyGroups)); return { id, @@ -3249,23 +3251,25 @@ export function useDateFieldEventsSelector(fieldId: string) { observerEvent(); - const debouncedObserverEvent = debounce(observerEvent, 150); + // The user's own edits (a dropped calendar or timeline bar) re-read at + // once; remote bursts stay debounced. + const rowObserver = createLocalFirstObserver(observerEvent, 150); // for every row rowOrders?.forEach((row) => { const rowDoc = rows?.[row.id]; if (!rowDoc) return; - rowDoc.getMap(YjsEditorKey.data_section).observeDeep(debouncedObserverEvent); + rowDoc.getMap(YjsEditorKey.data_section).observeDeep(rowObserver); }); return () => { - debouncedObserverEvent.cancel(); + rowObserver.cancel(); rowOrders?.forEach((row) => { const rowDoc = rows?.[row.id]; if (!rowDoc) return; - rowDoc.getMap(YjsEditorKey.data_section).unobserveDeep(debouncedObserverEvent); + rowDoc.getMap(YjsEditorKey.data_section).unobserveDeep(rowObserver); }); }; }, [field, fieldClock, rowOrders, rows, fieldId, primaryFieldId, primaryField, primaryFieldClock, ensureRow]); diff --git a/src/components/database/timeline/hooks/useTimelineFieldValues.ts b/src/components/database/timeline/hooks/useTimelineFieldValues.ts index 3ba3633ff..b9b01df7b 100644 --- a/src/components/database/timeline/hooks/useTimelineFieldValues.ts +++ b/src/components/database/timeline/hooks/useTimelineFieldValues.ts @@ -1,8 +1,8 @@ -import debounce from 'lodash-es/debounce'; import { useEffect, useState } from 'react'; import * as Y from 'yjs'; import { getCell, useFieldSelector, useRowMap, useRowOrdersSelector } from '@/application/database-yjs'; +import { createLocalFirstObserver } from '@/application/database-yjs/local-first-observer'; import { YDatabaseCell, YjsDatabaseKey, YjsEditorKey } from '@/application/types'; const EMPTY = new Map(); @@ -41,13 +41,14 @@ export function useTimelineFieldValues( }; read(); - const debounced = debounce(read, 150); + // A released progress handle re-reads at once; remote bursts stay debounced. + const observer = createLocalFirstObserver(read, 150); const docs = rowOrders.map((row) => rows[row.id]).filter(Boolean); - docs.forEach((doc) => doc.getMap(YjsEditorKey.data_section).observeDeep(debounced)); + docs.forEach((doc) => doc.getMap(YjsEditorKey.data_section).observeDeep(observer)); return () => { - debounced.cancel(); - docs.forEach((doc) => doc.getMap(YjsEditorKey.data_section).unobserveDeep(debounced)); + observer.cancel(); + docs.forEach((doc) => doc.getMap(YjsEditorKey.data_section).unobserveDeep(observer)); }; }, [field, clock, fieldId, parse, rowOrders, rows]); From fe461869b0b1e74d99362e2c549484c399c4ff77 Mon Sep 17 00:00:00 2001 From: Nathan Date: Sun, 13 Sep 2026 15:25:28 +0000 Subject: [PATCH 03/21] feat(timeline): make the timeline a first-class layout everywhere Register ViewLayout.Timeline in every layout switch the calendar is in: isDatabaseLayout, the view-loader collab-type map, ViewModal, published CollabView / DatabaseView skeletons, copy-link row params, first-child navigation, mobile outline and chat icons. Add the `timeline` document block type so a timeline can be embedded from the slash menu (Timeline / Linked Timeline) and created from the sidebar's New page menu. BDD: timeline-integration.feature covers the New page menu, both slash entries (including the page modal), and a visitor opening a published timeline read-only. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NoEH4HcEiojEcioVSLE3QM --- .../database/timeline-integration.feature | 33 ++++ .../bdd/steps/timeline-integration.steps.ts | 179 ++++++++++++++++++ playwright/support/i18n-constants.ts | 2 + playwright/support/page-utils.ts | 15 +- src/@types/translations/en.json | 3 + .../__tests__/database-block.test.ts | 1 + src/application/__tests__/view-utils.test.ts | 5 + src/application/database-block.ts | 3 + src/application/publish/context.tsx | 1 + src/application/types.ts | 1 + src/application/view-loader/index.ts | 1 + src/application/view-utils.ts | 3 +- .../mobile-outline/MobileOutlineWithCover.tsx | 1 + src/components/app/DatabaseView.tsx | 6 +- src/components/app/ViewModal.tsx | 1 + src/components/app/hooks/useViewOperations.ts | 1 + src/components/app/hooks/useWorkspaceData.ts | 2 + .../app/view-actions/AddPageActions.tsx | 8 + .../chat/components/view/page-icon.tsx | 3 + src/components/chat/lib/views.ts | 1 + src/components/chat/types/request.ts | 1 + .../panels/slash-panel/SlashPanel.tsx | 32 +++- .../__tests__/database-layout.test.ts | 4 +- .../panels/slash-panel/database-layout.ts | 3 + .../panels/slash-panel/slash-menu-options.ts | 4 + src/components/publish/CollabView.tsx | 2 + src/components/publish/DatabaseView.tsx | 1 + 27 files changed, 308 insertions(+), 9 deletions(-) create mode 100644 playwright/bdd/features/database/timeline-integration.feature create mode 100644 playwright/bdd/steps/timeline-integration.steps.ts diff --git a/playwright/bdd/features/database/timeline-integration.feature b/playwright/bdd/features/database/timeline-integration.feature new file mode 100644 index 000000000..5d602538c --- /dev/null +++ b/playwright/bdd/features/database/timeline-integration.feature @@ -0,0 +1,33 @@ +@timeline @cloud +Feature: Timeline view integration + A timeline is a first-class database layout everywhere a calendar is: it can + be created as a page, embedded in a document (new or linked), opened in the + page modal, and published. + + Scenario: New page menu creates a timeline page + Given I am signed in to a fresh workspace + When I add a Timeline page from the sidebar + Then the timeline view renders with an empty canvas and a New row footer + And the view tab is a Timeline tab + + Scenario: The slash menu embeds a new timeline in a document + Given I am signed in to a fresh workspace + And I am editing a new document + When I insert a Timeline through the slash menu + Then the timeline opens in the page modal + When I close the timeline page modal + Then the document contains a timeline block + + Scenario: The slash menu links an existing database as a timeline + Given I am signed in to a fresh workspace + And a timeline page exists + And I am editing a new document + When I link the timeline database as a timeline through the slash menu + Then the document contains a timeline block titled "View of New Database" + + Scenario: A published timeline page renders read-only for visitors + Given a cloud calendar with "Design" today and "Build" in 2 days + And a Timeline view is added from the view menu + When I publish the timeline page + And a visitor opens the published timeline + Then the visitor sees the "Design" and "Build" bars without editing controls diff --git a/playwright/bdd/steps/timeline-integration.steps.ts b/playwright/bdd/steps/timeline-integration.steps.ts new file mode 100644 index 000000000..2eb8d0c5b --- /dev/null +++ b/playwright/bdd/steps/timeline-integration.steps.ts @@ -0,0 +1,179 @@ +import { BrowserContext, expect, Page } from '@playwright/test'; +import { createBdd } from 'playwright-bdd'; + +import { signInAndWaitForApp } from '../../support/auth-flow-helpers'; +import { createDocumentPageAndNavigate, insertLinkedDatabaseViaSlash } from '../../support/page-utils'; +import { + AddPageSelectors, + DatabaseViewSelectors, + EditorSelectors, + ShareSelectors, + SlashCommandSelectors, + TimelineSelectors, +} from '../../support/selectors'; +import { generateRandomEmail, setupPageErrorHandling } from '../../support/test-config'; + +const { Given, When, Then, After } = createBdd(); + +interface IntegrationState { + docViewId?: string; + publishedUrl?: string; + visitorContext?: BrowserContext; + visitorPage?: Page; +} + +const states = new WeakMap(); + +function state(page: Page): IntegrationState { + let current = states.get(page); + + if (!current) { + current = {}; + states.set(page, current); + } + + return current; +} + +After(async ({ page }) => { + const current = states.get(page); + + await current?.visitorContext?.close().catch(() => undefined); + states.delete(page); +}); + +/** Sidebar `+` → Timeline; resolves once the new page renders its timeline. */ +async function addTimelinePage(page: Page) { + await AddPageSelectors.inlineAddButton(page).first().click({ force: true }); + await page.getByTestId('add-timeline-page-button').click({ force: true }); + await expect(TimelineSelectors.view(page)).toBeVisible({ timeout: 30_000 }); + await page.waitForTimeout(1500); +} + +Given('I am signed in to a fresh workspace', async ({ page, request, $testInfo }) => { + $testInfo.setTimeout(240_000); + await signInAndWaitForApp(page, request, generateRandomEmail()); + await expect(page).toHaveURL(/\/app/, { timeout: 30_000 }); + await page.waitForTimeout(3000); +}); + +When('I add a Timeline page from the sidebar', async ({ page }) => { + await addTimelinePage(page); +}); + +Given('a timeline page exists', async ({ page }) => { + await addTimelinePage(page); +}); + +Then('the timeline view renders with an empty canvas and a New row footer', async ({ page }) => { + await expect(TimelineSelectors.view(page)).toBeVisible(); + await expect(TimelineSelectors.header(page)).toBeVisible(); + await expect(TimelineSelectors.bars(page)).toHaveCount(0); + await expect(page.getByTestId('timeline-new-row')).toBeVisible(); +}); + +Then('the view tab is a Timeline tab', async ({ page }) => { + await expect(DatabaseViewSelectors.activeViewTab(page)).toContainText('Timeline'); +}); + +Given('I am editing a new document', async ({ page }) => { + state(page).docViewId = await createDocumentPageAndNavigate(page); +}); + +When('I insert a Timeline through the slash menu', async ({ page }) => { + const editor = EditorSelectors.firstEditor(page); + + await editor.click({ force: true }); + await page.keyboard.type('/'); + await expect(SlashCommandSelectors.slashPanel(page)).toBeVisible({ timeout: 10_000 }); + await page.getByTestId('slash-menu-timeline').click({ force: true }); +}); + +Then('the timeline opens in the page modal', async ({ page }) => { + const dialog = page.locator('[role="dialog"]').last(); + + await expect(dialog).toBeVisible({ timeout: 15_000 }); + await expect(dialog.getByTestId('timeline-view')).toBeVisible({ timeout: 30_000 }); +}); + +When('I close the timeline page modal', async ({ page }) => { + await page.keyboard.press('Escape'); + await expect(page.locator('[role="dialog"]')).toHaveCount(0, { timeout: 10_000 }); +}); + +Then('the document contains a timeline block', async ({ page }) => { + const docViewId = state(page).docViewId ?? ''; + const block = page.locator(`#editor-${docViewId} [data-block-type="timeline"]`); + + await expect(block).toHaveCount(1, { timeout: 15_000 }); + await expect(block.getByTestId('timeline-view')).toBeVisible({ timeout: 30_000 }); +}); + +When('I link the timeline database as a timeline through the slash menu', async ({ page }) => { + await insertLinkedDatabaseViaSlash(page, state(page).docViewId ?? '', 'New Database', 'Timeline'); +}); + +Then('the document contains a timeline block titled {string}', async ({ page }, title) => { + const docViewId = state(page).docViewId ?? ''; + const block = page.locator(`#editor-${docViewId} [data-block-type="timeline"]`); + + await expect(block).toHaveCount(1, { timeout: 15_000 }); + await expect(block.getByTestId('timeline-view')).toBeVisible({ timeout: 30_000 }); + await expect(block).toContainText(title); +}); + +When('I publish the timeline page', async ({ page }) => { + await ShareSelectors.shareButton(page).click({ force: true }); + const popover = ShareSelectors.sharePopover(page); + + await expect(popover).toBeVisible({ timeout: 10_000 }); + await popover.getByText('Publish', { exact: true }).click({ force: true }); + const publishButton = ShareSelectors.publishConfirmButton(page); + + await expect(publishButton).toBeEnabled({ timeout: 15_000 }); + const response = page.waitForResponse( + (candidate) => candidate.request().method() === 'POST' && new URL(candidate.url()).pathname.endsWith('/publish'), + { timeout: 60_000 } + ); + + await publishButton.click({ force: true }); + expect((await response).ok()).toBeTruthy(); + await expect(ShareSelectors.publishNamespace(page)).toBeVisible({ timeout: 30_000 }); + const namespace = ((await ShareSelectors.publishNamespace(page).textContent()) ?? '').trim(); + const publishName = (await ShareSelectors.publishNameInput(page).inputValue()).trim(); + + expect(namespace).not.toBe(''); + expect(publishName).not.toBe(''); + state(page).publishedUrl = `${new URL(page.url()).origin}/${namespace}/${publishName}`; + await page.keyboard.press('Escape'); +}); + +When('a visitor opens the published timeline', async ({ page, browser }) => { + const current = state(page); + const publishedUrl = current.publishedUrl ?? ''; + + expect(publishedUrl).not.toBe(''); + const context = await browser.newContext({ viewport: { width: 1440, height: 900 } }); + const visitor = await context.newPage(); + + setupPageErrorHandling(visitor); + current.visitorContext = context; + current.visitorPage = visitor; + await visitor.goto(publishedUrl, { waitUntil: 'domcontentloaded' }); + // The published container opens on its first view; switch to the Timeline tab. + const timelineTab = DatabaseViewSelectors.viewTab(visitor).filter({ hasText: 'Timeline' }); + + await expect(timelineTab).toBeVisible({ timeout: 60_000 }); + await timelineTab.click(); +}); + +Then('the visitor sees the {string} and {string} bars without editing controls', async ({ page }, first, second) => { + const visitor = state(page).visitorPage; + + if (!visitor) throw new Error('The visitor page was not opened'); + await expect(TimelineSelectors.view(visitor)).toBeVisible({ timeout: 60_000 }); + await expect(TimelineSelectors.barByTitle(visitor, first)).toBeVisible({ timeout: 30_000 }); + await expect(TimelineSelectors.barByTitle(visitor, second)).toBeVisible(); + await expect(visitor.getByTestId('timeline-new-row')).toHaveCount(0); + await expect(visitor.locator('[data-testid^="timeline-handle-"]')).toHaveCount(0); +}); diff --git a/playwright/support/i18n-constants.ts b/playwright/support/i18n-constants.ts index 8d2489c8e..47893042f 100644 --- a/playwright/support/i18n-constants.ts +++ b/playwright/support/i18n-constants.ts @@ -28,6 +28,8 @@ export const SlashMenuNames = { linkedKanban: 'Linked Kanban', calendar: 'Calendar', linkedCalendar: 'Linked Calendar', + timeline: 'Timeline', + linkedTimeline: 'Linked Timeline', quote: 'Quote', divider: 'Divider', table: 'Table', diff --git a/playwright/support/page-utils.ts b/playwright/support/page-utils.ts index 1e71648ed..cc521753f 100644 --- a/playwright/support/page-utils.ts +++ b/playwright/support/page-utils.ts @@ -190,11 +190,20 @@ export async function insertLinkedDatabaseViaSlash( page: Page, docViewId: string, dbName: string, - layout: 'Feed' | 'Gallery' | 'Grid' | 'List' = 'Grid' + layout: 'Feed' | 'Gallery' | 'Grid' | 'List' | 'Timeline' = 'Grid' ): Promise { const editor = page.locator(`#editor-${docViewId}`); await expect(editor).toBeVisible({ timeout: 15000 }); - const blockType = layout === 'List' ? 'list' : layout === 'Gallery' ? 'gallery' : layout === 'Feed' ? 'feed' : 'grid'; + const blockType = + layout === 'List' + ? 'list' + : layout === 'Gallery' + ? 'gallery' + : layout === 'Feed' + ? 'feed' + : layout === 'Timeline' + ? 'timeline' + : 'grid'; const initialBlockCount = await editor.locator(BlockSelectors.blockSelector(blockType)).count(); let lastError: unknown; @@ -213,6 +222,8 @@ export async function insertLinkedDatabaseViaSlash( ? 'linkedGallery' : layout === 'Feed' ? 'linkedFeed' + : layout === 'Timeline' + ? 'linkedTimeline' : 'linkedGrid'; await SlashCommandSelectors.slashMenuItem(page, getSlashMenuItemName(slashMenuKey)).first().click({ force: true }); diff --git a/src/@types/translations/en.json b/src/@types/translations/en.json index de3fb1ee6..8c4137fed 100644 --- a/src/@types/translations/en.json +++ b/src/@types/translations/en.json @@ -2375,6 +2375,8 @@ "linkedKanban": "Linked Kanban", "calendar": "Calendar", "linkedCalendar": "Linked Calendar", + "timeline": "Timeline", + "linkedTimeline": "Linked Timeline", "quote": "Quote", "divider": "Divider", "table": "Table", @@ -4348,6 +4350,7 @@ }, "timeline": { "menuName": "Timeline", + "referencedTimelinePrefix": "View of", "settings": { "name": "Timeline settings", "layoutDateField": "Timeline by", diff --git a/src/application/__tests__/database-block.test.ts b/src/application/__tests__/database-block.test.ts index b2eec1e45..a94bb11b1 100644 --- a/src/application/__tests__/database-block.test.ts +++ b/src/application/__tests__/database-block.test.ts @@ -16,6 +16,7 @@ describe('database block types', () => { [BlockType.ListBlock, ViewLayout.List], [BlockType.DatabaseGalleryBlock, ViewLayout.Gallery], [BlockType.FeedBlock, ViewLayout.Feed], + [BlockType.TimelineBlock, ViewLayout.Timeline], ])('maps the native %s block to database layout %s', (blockType, layout) => { expect(getDatabaseLayoutFromBlockType(blockType)).toBe(layout); }); diff --git a/src/application/__tests__/view-utils.test.ts b/src/application/__tests__/view-utils.test.ts index d2c077c21..c21b56555 100644 --- a/src/application/__tests__/view-utils.test.ts +++ b/src/application/__tests__/view-utils.test.ts @@ -70,6 +70,10 @@ describe('view-utils', () => { expect(isDatabaseLayout(ViewLayout.Form)).toBe(true); }); + it('should return true for Timeline layout', () => { + expect(isDatabaseLayout(ViewLayout.Timeline)).toBe(true); + }); + it('should return false for Document layout', () => { expect(isDatabaseLayout(ViewLayout.Document)).toBe(false); }); @@ -833,6 +837,7 @@ describe('view-utils', () => { ViewLayout.Gallery, ViewLayout.Feed, ViewLayout.Form, + ViewLayout.Timeline, ]; const parentView = createMockView({ view_id: 'parent-doc', diff --git a/src/application/database-block.ts b/src/application/database-block.ts index 34516750e..ffeeee9ce 100644 --- a/src/application/database-block.ts +++ b/src/application/database-block.ts @@ -8,6 +8,7 @@ export const DATABASE_BLOCK_TYPES = [ BlockType.ChartBlock, BlockType.DatabaseGalleryBlock, BlockType.FeedBlock, + BlockType.TimelineBlock, ] as const; export type DatabaseBlockType = (typeof DATABASE_BLOCK_TYPES)[number]; @@ -34,6 +35,8 @@ export function getDatabaseLayoutFromBlockType(type: unknown): ViewLayout | unde return ViewLayout.Gallery; case BlockType.FeedBlock: return ViewLayout.Feed; + case BlockType.TimelineBlock: + return ViewLayout.Timeline; default: return undefined; } diff --git a/src/application/publish/context.tsx b/src/application/publish/context.tsx index 47f9d731a..5f9edfdd1 100644 --- a/src/application/publish/context.tsx +++ b/src/application/publish/context.tsx @@ -446,6 +446,7 @@ export const PublishProvider = ({ case ViewLayout.List: case ViewLayout.Gallery: case ViewLayout.Feed: + case ViewLayout.Timeline: searchParams.set('r', blockId); break; default: diff --git a/src/application/types.ts b/src/application/types.ts index f0ccb1aab..9b9bdaef4 100644 --- a/src/application/types.ts +++ b/src/application/types.ts @@ -38,6 +38,7 @@ export enum BlockType { GridBlock = 'grid', BoardBlock = 'board', CalendarBlock = 'calendar', + TimelineBlock = 'timeline', ListBlock = 'list', ChartBlock = 'chart', DatabaseGalleryBlock = 'gallery', diff --git a/src/application/view-loader/index.ts b/src/application/view-loader/index.ts index c7abb8722..5f02e691d 100644 --- a/src/application/view-loader/index.ts +++ b/src/application/view-loader/index.ts @@ -63,6 +63,7 @@ const LAYOUT_COLLAB_TYPE_MAP: Partial> = { [ViewLayout.Gallery]: Types.Database, [ViewLayout.Feed]: Types.Database, [ViewLayout.Form]: Types.Database, + [ViewLayout.Timeline]: Types.Database, }; const DOC_KEY_COLLAB_TYPE_MAP: Record = { diff --git a/src/application/view-utils.ts b/src/application/view-utils.ts index 92241ea39..6489c9b76 100644 --- a/src/application/view-utils.ts +++ b/src/application/view-utils.ts @@ -38,7 +38,8 @@ export function isDatabaseLayout(layout: ViewLayout): boolean { layout === ViewLayout.List || layout === ViewLayout.Gallery || layout === ViewLayout.Feed || - layout === ViewLayout.Form + layout === ViewLayout.Form || + layout === ViewLayout.Timeline ); } diff --git a/src/components/_shared/mobile-outline/MobileOutlineWithCover.tsx b/src/components/_shared/mobile-outline/MobileOutlineWithCover.tsx index 0622b9a2c..eeebd6cd0 100644 --- a/src/components/_shared/mobile-outline/MobileOutlineWithCover.tsx +++ b/src/components/_shared/mobile-outline/MobileOutlineWithCover.tsx @@ -67,6 +67,7 @@ function MobileOutlineWithCover({ bgColor: isDark ? '#8B80AD33' : '#F5F4FFFF', }; case ViewLayout.Calendar: + case ViewLayout.Timeline: return { iconClassName: 'text-[#FD9D44]', bgColor: isDark ? '#A68B7733' : '#FFF7F0FF', diff --git a/src/components/app/DatabaseView.tsx b/src/components/app/DatabaseView.tsx index e2ba98f5f..842b9aa8f 100644 --- a/src/components/app/DatabaseView.tsx +++ b/src/components/app/DatabaseView.tsx @@ -443,6 +443,7 @@ function DatabaseView(props: DatabaseViewProps) { case ViewLayout.Board: return ; case ViewLayout.Calendar: + case ViewLayout.Timeline: return ; default: return ; @@ -460,7 +461,10 @@ function DatabaseView(props: DatabaseViewProps) {
diff --git a/src/components/app/ViewModal.tsx b/src/components/app/ViewModal.tsx index 10f94422d..58f01a142 100644 --- a/src/components/app/ViewModal.tsx +++ b/src/components/app/ViewModal.tsx @@ -434,6 +434,7 @@ function ViewModal({ viewId, open, onClose }: { viewId?: string; open: boolean; case ViewLayout.Gallery: case ViewLayout.Feed: case ViewLayout.Form: + case ViewLayout.Timeline: return DatabaseView; default: return null; diff --git a/src/components/app/hooks/useViewOperations.ts b/src/components/app/hooks/useViewOperations.ts index 658180e3b..fab259b2e 100644 --- a/src/components/app/hooks/useViewOperations.ts +++ b/src/components/app/hooks/useViewOperations.ts @@ -497,6 +497,7 @@ export function useViewOperations({ case ViewLayout.List: case ViewLayout.Gallery: case ViewLayout.Feed: + case ViewLayout.Timeline: searchParams.set('r', blockId); break; default: diff --git a/src/components/app/hooks/useWorkspaceData.ts b/src/components/app/hooks/useWorkspaceData.ts index a9e7016b0..8480cf5a5 100644 --- a/src/components/app/hooks/useWorkspaceData.ts +++ b/src/components/app/hooks/useWorkspaceData.ts @@ -925,6 +925,7 @@ export function useWorkspaceData() { ViewLayout.Gallery, ViewLayout.Feed, ViewLayout.Form, + ViewLayout.Timeline, ]); if (firstView) { @@ -964,6 +965,7 @@ export function useWorkspaceData() { ViewLayout.Gallery, ViewLayout.Feed, ViewLayout.Form, + ViewLayout.Timeline, ]); if (firstChild) { diff --git a/src/components/app/view-actions/AddPageActions.tsx b/src/components/app/view-actions/AddPageActions.tsx index 6445ea8e2..103e4e01b 100644 --- a/src/components/app/view-actions/AddPageActions.tsx +++ b/src/components/app/view-actions/AddPageActions.tsx @@ -210,6 +210,14 @@ function AddPageActions({ view, onImportClick }: { view: View; onImportClick?: ( void handleAddPage(ViewLayout.Calendar, t('document.plugins.database.newDatabase')); }, }, + { + label: t('timeline.menuName', { defaultValue: 'Timeline' }), + icon: , + testId: 'add-timeline-page-button', + onSelect: () => { + void handleAddPage(ViewLayout.Timeline, t('document.plugins.database.newDatabase')); + }, + }, ...(aiEnabled ? [ { diff --git a/src/components/chat/components/view/page-icon.tsx b/src/components/chat/components/view/page-icon.tsx index 08ed17e57..8bb71f398 100644 --- a/src/components/chat/components/view/page-icon.tsx +++ b/src/components/chat/components/view/page-icon.tsx @@ -8,6 +8,7 @@ import { ReactComponent as GalleryIcon } from '@/assets/icons/gallery.svg'; import { ReactComponent as GridIcon } from '@/assets/icons/grid.svg'; import { ReactComponent as ListIcon } from '@/assets/icons/list.svg'; import { ReactComponent as DocIcon } from '@/assets/icons/page.svg'; +import { ReactComponent as TimelineIcon } from '@/assets/icons/timeline.svg'; import { getIcon, renderColor } from '@/components/chat/lib/utils'; import { View, ViewIconType, ViewLayout } from '@/components/chat/types'; import { cn } from '@/lib/utils'; @@ -75,6 +76,8 @@ function PageIcon({ view }: { view: View }) { return ; case ViewLayout.Feed: return ; + case ViewLayout.Timeline: + return ; default: return ; } diff --git a/src/components/chat/lib/views.ts b/src/components/chat/lib/views.ts index 50ee072a3..e751798f9 100644 --- a/src/components/chat/lib/views.ts +++ b/src/components/chat/lib/views.ts @@ -56,6 +56,7 @@ export function hasDatabaseViewChild(view: View): boolean { ViewLayout.List, ViewLayout.Gallery, ViewLayout.Feed, + ViewLayout.Timeline, ].includes(view.layout) || (view.layout === ViewLayout.Document && view.children.some((child) => hasDatabaseViewChild(child))) ); diff --git a/src/components/chat/types/request.ts b/src/components/chat/types/request.ts index ae877bcd4..372849fff 100644 --- a/src/components/chat/types/request.ts +++ b/src/components/chat/types/request.ts @@ -164,6 +164,7 @@ export enum ViewLayout { List = 6, Gallery = 7, Feed = 8, + Timeline = 10, } export interface View { diff --git a/src/components/editor/components/panels/slash-panel/SlashPanel.tsx b/src/components/editor/components/panels/slash-panel/SlashPanel.tsx index 2ca77c14e..732b48767 100644 --- a/src/components/editor/components/panels/slash-panel/SlashPanel.tsx +++ b/src/components/editor/components/panels/slash-panel/SlashPanel.tsx @@ -6,10 +6,7 @@ import { Editor, Element, Transforms } from 'slate'; import { ReactEditor, useSlateStatic } from 'slate-react'; import { isDatabaseBlockType } from '@/application/database-block'; -import { - createDatabaseFeedPageViaGrid, - createLinkedDatabaseFeedView, -} from '@/application/database-yjs/feed-layout'; +import { createDatabaseFeedPageViaGrid, createLinkedDatabaseFeedView } from '@/application/database-yjs/feed-layout'; import { createDatabaseGalleryPageViaGrid, createLinkedDatabaseGalleryView, @@ -60,6 +57,7 @@ import { ReactComponent as AudioIcon } from '@/assets/icons/audio.svg'; import { ReactComponent as BoardIcon } from '@/assets/icons/board.svg'; import { ReactComponent as BulletedListIcon } from '@/assets/icons/bulleted_list.svg'; import { ReactComponent as CalendarIcon } from '@/assets/icons/calendar.svg'; +import { ReactComponent as TimelineIcon } from '@/assets/icons/timeline.svg'; import { ReactComponent as CalloutIcon } from '@/assets/icons/callout.svg'; import { ReactComponent as ChartIcon } from '@/assets/icons/chart.svg'; import { ReactComponent as ContinueWritingIcon } from '@/assets/icons/continue_writing.svg'; @@ -729,6 +727,10 @@ export function SlashPanel({ return t('document.chart.referencedChartPrefix', { defaultValue: 'View of', }); + case ViewLayout.Timeline: + return t('timeline.referencedTimelinePrefix', { + defaultValue: 'View of', + }); default: return ''; } @@ -1422,6 +1424,28 @@ export function SlashPanel({ void handleOpenLinkedDatabasePicker(ViewLayout.Calendar, 'linkedCalendar'); }, }, + { + label: t('document.slashMenu.name.timeline', { defaultValue: 'Timeline' }), + key: 'timeline', + icon: , + group: SlashMenuGroupKey.Database, + keywords: ['timeline', 'gantt', 'date', 'database', 'schedule'], + aliases: ['timeline view', 'gantt'], + onClick: () => { + void createInlineDatabase(ViewLayout.Timeline); + }, + }, + { + label: t('document.slashMenu.name.linkedTimeline', { defaultValue: 'Linked Timeline' }), + key: 'linkedTimeline', + icon: , + group: SlashMenuGroupKey.Database, + keywords: ['linked', 'timeline', 'gantt', 'date', 'database'], + aliases: ['link to timeline', 'referenced timeline', 'ltt'], + onClick: () => { + void handleOpenLinkedDatabasePicker(ViewLayout.Timeline, 'linkedTimeline'); + }, + }, { label: t('list.menuName'), key: 'list', diff --git a/src/components/editor/components/panels/slash-panel/__tests__/database-layout.test.ts b/src/components/editor/components/panels/slash-panel/__tests__/database-layout.test.ts index 0e6898dd3..3fd5278d7 100644 --- a/src/components/editor/components/panels/slash-panel/__tests__/database-layout.test.ts +++ b/src/components/editor/components/panels/slash-panel/__tests__/database-layout.test.ts @@ -11,14 +11,16 @@ describe('slash menu database layout plumbing', () => { ['Board', ViewLayout.Board, BlockType.BoardBlock], ['Calendar', ViewLayout.Calendar, BlockType.CalendarBlock], ['Chart', ViewLayout.Chart, BlockType.ChartBlock], + ['Timeline', ViewLayout.Timeline, BlockType.TimelineBlock], ])('maps %s to its database block type', (_name, layout, blockType) => { expect(getDatabaseBlockTypeForLayout(layout)).toBe(blockType); }); - it('allows List, Gallery, and Feed databases in the linked database picker', () => { + it('allows List, Gallery, Feed and Timeline databases in the linked database picker', () => { expect(isSlashMenuDatabaseLayout(ViewLayout.List)).toBe(true); expect(isSlashMenuDatabaseLayout(ViewLayout.Gallery)).toBe(true); expect(isSlashMenuDatabaseLayout(ViewLayout.Feed)).toBe(true); + expect(isSlashMenuDatabaseLayout(ViewLayout.Timeline)).toBe(true); }); it('rejects non-database layouts', () => { diff --git a/src/components/editor/components/panels/slash-panel/database-layout.ts b/src/components/editor/components/panels/slash-panel/database-layout.ts index 3a03528b9..c974e7b9c 100644 --- a/src/components/editor/components/panels/slash-panel/database-layout.ts +++ b/src/components/editor/components/panels/slash-panel/database-layout.ts @@ -8,6 +8,7 @@ const DATABASE_LAYOUTS = new Set([ ViewLayout.List, ViewLayout.Gallery, ViewLayout.Feed, + ViewLayout.Timeline, ]); /** Map each database view layout to its cross-client document block type. */ @@ -27,6 +28,8 @@ export function getDatabaseBlockTypeForLayout(layout: ViewLayout): BlockType | n return BlockType.DatabaseGalleryBlock; case ViewLayout.Feed: return BlockType.FeedBlock; + case ViewLayout.Timeline: + return BlockType.TimelineBlock; default: return null; } diff --git a/src/components/editor/components/panels/slash-panel/slash-menu-options.ts b/src/components/editor/components/panels/slash-panel/slash-menu-options.ts index 324568ad3..efbcb72ff 100644 --- a/src/components/editor/components/panels/slash-panel/slash-menu-options.ts +++ b/src/components/editor/components/panels/slash-panel/slash-menu-options.ts @@ -42,6 +42,8 @@ export const SIMPLE_TABLE_EXCLUDED_OPTION_KEYS = new Set([ 'linkedKanban', 'calendar', 'linkedCalendar', + 'timeline', + 'linkedTimeline', 'list', 'linkedList', 'databaseGallery', @@ -63,6 +65,8 @@ export const AI_MEETING_EXCLUDED_OPTION_KEYS = new Set([ 'linkedKanban', 'calendar', 'linkedCalendar', + 'timeline', + 'linkedTimeline', 'list', 'linkedList', 'databaseGallery', diff --git a/src/components/publish/CollabView.tsx b/src/components/publish/CollabView.tsx index 87497d279..8803bffe4 100644 --- a/src/components/publish/CollabView.tsx +++ b/src/components/publish/CollabView.tsx @@ -29,6 +29,7 @@ function CollabView({ doc }: CollabViewProps) { case ViewLayout.List: case ViewLayout.Gallery: case ViewLayout.Feed: + case ViewLayout.Timeline: return DatabaseView; default: return null; @@ -85,6 +86,7 @@ function CollabView({ doc }: CollabViewProps) { case ViewLayout.Board: return ; case ViewLayout.Calendar: + case ViewLayout.Timeline: return ; case ViewLayout.Document: return ; diff --git a/src/components/publish/DatabaseView.tsx b/src/components/publish/DatabaseView.tsx index 208e823bd..9bf547dd3 100644 --- a/src/components/publish/DatabaseView.tsx +++ b/src/components/publish/DatabaseView.tsx @@ -205,6 +205,7 @@ function DatabaseView({ viewMeta, navigateToView, ...props }: DatabaseProps) { case ViewLayout.Board: return ; case ViewLayout.Calendar: + case ViewLayout.Timeline: return ; default: return ; From f2bf0f07b792cd0f5ca7ccb12bebe146a4404c93 Mon Sep 17 00:00:00 2001 From: Nathan Date: Sun, 13 Sep 2026 15:49:51 +0000 Subject: [PATCH 04/21] =?UTF-8?q?feat(timeline):=20table=20row=20gutter=20?= =?UTF-8?q?=E2=80=94=20insert,=20menu,=20drag=20to=20reorder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Give the docked table Notion's hover gutter: `+` inserts a row below, `⋮⋮` opens insert above / below, duplicate and delete, and dragging it reorders rows (with the List view's clear-sorts confirmation). The gutter is editor-only; header and "+ New row" line up with it. Reuse the List view's ListRowActions and move its row DnD hook to the shared drag-and-drop folder (useRowDnd, now with an optional wider drop target so a drop anywhere along a timeline row counts). Fix a latent List bug on the way: Radix opened the ⋮⋮ menu on pointer-down and cancelled the event, which stopped the browser from ever starting the native drag on the same handle. The trigger now opens on click, so the handle can be dragged. BDD: hover gutter insert / insert above / duplicate / delete, drag reorder + undo, and no gutter for published visitors. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NoEH4HcEiojEcioVSLE3QM --- .../bdd/features/database/timeline.feature | 18 +++ .../bdd/steps/timeline-integration.steps.ts | 81 +++++++---- playwright/bdd/steps/timeline.steps.ts | 78 +++++++++++ .../components/drag-and-drop/useRowDnd.tsx | 118 ++++++++++++++++ src/components/database/list/ListRow.tsx | 102 +------------- .../database/list/ListRowActions.tsx | 9 +- src/components/database/timeline/Timeline.tsx | 9 +- .../database/timeline/TimelineRow.tsx | 83 +++++------ .../database/timeline/TimelineSidebarRow.tsx | 129 ++++++++++++++++++ .../database/timeline/TimelineView.tsx | 42 +++++- .../timeline/hooks/useTimelineRows.ts | 6 +- 11 files changed, 491 insertions(+), 184 deletions(-) create mode 100644 src/components/database/components/drag-and-drop/useRowDnd.tsx create mode 100644 src/components/database/timeline/TimelineSidebarRow.tsx diff --git a/playwright/bdd/features/database/timeline.feature b/playwright/bdd/features/database/timeline.feature index 038823009..22ec9865c 100644 --- a/playwright/bdd/features/database/timeline.feature +++ b/playwright/bdd/features/database/timeline.feature @@ -162,3 +162,21 @@ Feature: Timeline view interactions When I drag the end handle of "Design" 3 columns later Then the "Design" bar grew by 3 columns And the "Build" bar moved 3 columns later + + Scenario: The table's hover gutter inserts, duplicates and deletes rows + When I click the hover "+" of the table row "Design" + Then the table lists "Design, Untitled, Build" in that order + When I open the row menu of the table row "Build" and choose "Insert above" + Then the table lists "Design, Untitled, Untitled, Build" in that order + When I open the row menu of the table row "Build" and choose "Duplicate" + Then the table lists "Design, Untitled, Untitled, Build, Build" in that order + And the timeline shows 3 bars + When I open the row menu of the last table row and choose "Delete" + Then the table lists "Design, Untitled, Untitled, Build" in that order + And the timeline shows 2 bars + + Scenario: Dragging a row's handle reorders the table + When I drag the table row "Build" above "Design" + Then the table lists "Build, Design" in that order + When I press undo + Then the table lists "Design, Build" in that order diff --git a/playwright/bdd/steps/timeline-integration.steps.ts b/playwright/bdd/steps/timeline-integration.steps.ts index 2eb8d0c5b..c5a1f760f 100644 --- a/playwright/bdd/steps/timeline-integration.steps.ts +++ b/playwright/bdd/steps/timeline-integration.steps.ts @@ -122,30 +122,61 @@ Then('the document contains a timeline block titled {string}', async ({ page }, await expect(block).toContainText(title); }); +/** + * Publish through the Share popover. Under parallel test load the popover can + * be torn down by an outline refresh right after it opens, so the whole + * open → Publish tab → confirm sequence is retried until a publish request + * actually leaves the page. + */ +async function publishCurrentPage(page: Page): Promise { + for (let attempt = 0; attempt < 3; attempt += 1) { + await ShareSelectors.shareButton(page).click({ force: true }); + const popover = ShareSelectors.sharePopover(page); + + await expect(popover).toBeVisible({ timeout: 10_000 }); + await popover.getByText('Publish', { exact: true }).click({ force: true }); + const publishButton = ShareSelectors.publishConfirmButton(page); + + await expect(publishButton).toBeEnabled({ timeout: 15_000 }); + const response = page + .waitForResponse( + (candidate) => candidate.request().method() === 'POST' && new URL(candidate.url()).pathname.endsWith('/publish'), + { timeout: 20_000 } + ) + .then((res) => ({ res })) + .catch(() => ({ timeout: true as const })); + const publishError = page.locator('[data-sonner-toast][data-type="error"]').last(); + const errorPromise = publishError + .waitFor({ state: 'visible', timeout: 20_000 }) + .then(async () => ({ error: (await publishError.innerText()).trim() })) + .catch(() => ({ timeout: true as const })); + + await publishButton.click({ force: true }); + const result = await Promise.race([response, errorPromise]); + + if ('error' in result) throw new Error(`Publishing failed: ${result.error}`); + if ('timeout' in result) { + await page.keyboard.press('Escape'); + await page.waitForTimeout(1000); + continue; + } + + expect(result.res.ok(), `Publishing failed with HTTP ${result.res.status()}`).toBeTruthy(); + await expect(ShareSelectors.publishNamespace(page)).toBeVisible({ timeout: 30_000 }); + const namespace = ((await ShareSelectors.publishNamespace(page).textContent()) ?? '').trim(); + const publishName = (await ShareSelectors.publishNameInput(page).inputValue()).trim(); + + expect(namespace).not.toBe(''); + expect(publishName).not.toBe(''); + await page.keyboard.press('Escape'); + return `${new URL(page.url()).origin}/${namespace}/${publishName}`; + } + + throw new Error('The publish request never left the page'); +} + When('I publish the timeline page', async ({ page }) => { - await ShareSelectors.shareButton(page).click({ force: true }); - const popover = ShareSelectors.sharePopover(page); - - await expect(popover).toBeVisible({ timeout: 10_000 }); - await popover.getByText('Publish', { exact: true }).click({ force: true }); - const publishButton = ShareSelectors.publishConfirmButton(page); - - await expect(publishButton).toBeEnabled({ timeout: 15_000 }); - const response = page.waitForResponse( - (candidate) => candidate.request().method() === 'POST' && new URL(candidate.url()).pathname.endsWith('/publish'), - { timeout: 60_000 } - ); - - await publishButton.click({ force: true }); - expect((await response).ok()).toBeTruthy(); - await expect(ShareSelectors.publishNamespace(page)).toBeVisible({ timeout: 30_000 }); - const namespace = ((await ShareSelectors.publishNamespace(page).textContent()) ?? '').trim(); - const publishName = (await ShareSelectors.publishNameInput(page).inputValue()).trim(); - - expect(namespace).not.toBe(''); - expect(publishName).not.toBe(''); - state(page).publishedUrl = `${new URL(page.url()).origin}/${namespace}/${publishName}`; - await page.keyboard.press('Escape'); + state(page).publishedUrl = await publishCurrentPage(page); }); When('a visitor opens the published timeline', async ({ page, browser }) => { @@ -176,4 +207,8 @@ Then('the visitor sees the {string} and {string} bars without editing controls', await expect(TimelineSelectors.barByTitle(visitor, second)).toBeVisible(); await expect(visitor.getByTestId('timeline-new-row')).toHaveCount(0); await expect(visitor.locator('[data-testid^="timeline-handle-"]')).toHaveCount(0); + // The table's hover gutter (insert / menu / drag handle) is editor-only. + await visitor.locator('[data-testid^="timeline-sidebar-cell-"]').first().hover(); + await expect(visitor.locator('[data-testid^="list-row-actions-"]')).toHaveCount(0); + await expect(visitor.getByTestId('row-accessory-button')).toHaveCount(0); }); diff --git a/playwright/bdd/steps/timeline.steps.ts b/playwright/bdd/steps/timeline.steps.ts index 81caac638..7045c14b2 100644 --- a/playwright/bdd/steps/timeline.steps.ts +++ b/playwright/bdd/steps/timeline.steps.ts @@ -626,3 +626,81 @@ When("the timeline's date field is deleted from the database", async ({ page }) Then('the timeline explains that it has no date property', async ({ page }) => { await expect(page.getByTestId('timeline-unsupported')).toBeVisible({ timeout: 15_000 }); }); + +// --- Table row actions (Notion's hover gutter) ------------------------------- + +function sidebarCell(page: Page, title: string) { + return page.locator('[data-testid^="timeline-sidebar-cell-"]').filter({ hasText: title }).first(); +} + +async function sidebarTitles(page: Page): Promise { + const titles = await TimelineSelectors.sidebarRows(page).allInnerTexts(); + + return titles.map((title) => title.trim()); +} + +When('I click the hover {string} of the table row {string}', async ({ page }, _plus, title) => { + const cell = sidebarCell(page, title); + + await cell.hover(); + await cell.locator('[data-testid^="list-row-add-below-"]').click(); +}); + +const ROW_MENU_ITEM: Record = { + 'Insert above': 'row-menu-insert-above', + 'Insert below': 'row-menu-insert-below', + Duplicate: 'row-menu-duplicate', + Delete: 'row-menu-delete', +}; + +async function chooseRowMenuItem(page: Page, cell: ReturnType, action: string) { + await cell.hover(); + await cell.getByTestId('row-accessory-button').click(); + await page.getByTestId('list-row-action-menu').getByTestId(ROW_MENU_ITEM[action]).click(); + if (action === 'Delete') { + await page.getByTestId('delete-row-confirm-button').click(); + await expect(page.getByTestId('delete-row-confirm-button')).toHaveCount(0); + } +} + +When('I open the row menu of the table row {string} and choose {string}', async ({ page }, title, action) => { + await chooseRowMenuItem(page, sidebarCell(page, title), action); +}); + +When('I open the row menu of the last table row and choose {string}', async ({ page }, action) => { + await chooseRowMenuItem(page, page.locator('[data-testid^="timeline-sidebar-cell-"]').last(), action); +}); + +Then('the table lists {string} in that order', async ({ page }, list) => { + const expected = list.split(',').map((title: string) => title.trim().replace(/^"|"$/g, '')); + + await expect.poll(() => sidebarTitles(page), { timeout: 15_000 }).toEqual(expected); +}); + +Then('the timeline shows {int} bars', async ({ page }, count) => { + await expect(TimelineSelectors.bars(page)).toHaveCount(count, { timeout: 15_000 }); +}); + +When('I drag the table row {string} above {string}', async ({ page }, source, target) => { + const sourceCell = sidebarCell(page, source); + const targetCell = sidebarCell(page, target); + + await sourceCell.hover(); + const handle = sourceCell.getByTestId('row-accessory-button'); + const handleBox = await handle.boundingBox(); + const targetBox = await targetCell.boundingBox(); + + if (!handleBox || !targetBox) throw new Error('Row handle or target is not visible'); + const start = { x: handleBox.x + handleBox.width / 2, y: handleBox.y + handleBox.height / 2 }; + // Aim at the top quarter of the target so the closest edge resolves to "top". + const end = { x: targetBox.x + 60, y: targetBox.y + Math.min(targetBox.height * 0.15, 5) }; + + await page.mouse.move(start.x, start.y, { steps: 10 }); + await page.mouse.down(); + await page.waitForTimeout(100); + await page.mouse.move(start.x + 6, start.y - 4, { steps: 5 }); + await page.waitForTimeout(100); + await page.mouse.move(end.x, end.y, { steps: 20 }); + await page.waitForTimeout(200); + await page.mouse.up(); +}); diff --git a/src/components/database/components/drag-and-drop/useRowDnd.tsx b/src/components/database/components/drag-and-drop/useRowDnd.tsx new file mode 100644 index 000000000..8ed904ef2 --- /dev/null +++ b/src/components/database/components/drag-and-drop/useRowDnd.tsx @@ -0,0 +1,118 @@ +import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; +import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; +import { attachClosestEdge, extractClosestEdge, type Edge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'; +import { type MutableRefObject, useEffect, useRef, useState } from 'react'; + +import { ClearSortingConfirm } from '@/components/database/components/sorts/ClearSortingConfirm'; + +export type { Edge }; + +export interface UseRowDndOptions { + dragHandleRef: MutableRefObject; + enabled: boolean; + onDropRow?: (sourceRowId: string, targetRowId: string, edge: Edge) => void; + rowId: string; + /** The element that is picked up (and, by default, the drop target). */ + rowRef: MutableRefObject; + /** A wider element to accept drops on, e.g. a whole timeline row. */ + dropTargetRef?: MutableRefObject; + hasSorts: boolean; + /** Drag payload type; rows only drop onto rows of the same kind. */ + dragType?: string; +} + +/** + * Vertical row reordering with a dedicated drag handle: the row is the drop + * target (top / bottom edge), a manual sort is confirmed away first when the + * view is sorted, and the click that follows a drag is swallowed. + */ +export function useRowDnd({ + dragHandleRef, + enabled, + onDropRow, + rowId, + rowRef, + dropTargetRef, + hasSorts, + dragType = 'database-list-row', +}: UseRowDndOptions) { + const [closestEdge, setClosestEdge] = useState(null); + const [dragging, setDragging] = useState(false); + const [clearSortsOpen, setClearSortsOpen] = useState(false); + const pendingDropRef = useRef<(() => void) | null>(null); + const ignoreClickRef = useRef(false); + + useEffect(() => { + const element = rowRef.current; + const dropTarget = dropTargetRef?.current ?? element; + const dragHandle = dragHandleRef.current; + + if (!enabled || !element || !dropTarget || !dragHandle || !onDropRow) return; + + return combine( + draggable({ + element, + dragHandle, + getInitialData: () => ({ type: dragType, rowId }), + onDragStart: () => { + ignoreClickRef.current = true; + setDragging(true); + }, + onDrop: () => { + setDragging(false); + window.setTimeout(() => { + ignoreClickRef.current = false; + }, 0); + }, + }), + dropTargetForElements({ + element: dropTarget, + canDrop: ({ source }) => source.data.type === dragType && source.data.rowId !== rowId, + getData: ({ input, element: targetElement }) => + attachClosestEdge( + { type: dragType, rowId }, + { allowedEdges: ['top', 'bottom'], element: targetElement, input } + ), + onDragEnter: ({ self }) => setClosestEdge(extractClosestEdge(self.data)), + onDrag: ({ self }) => setClosestEdge(extractClosestEdge(self.data)), + onDragLeave: () => setClosestEdge(null), + onDrop: ({ self, source }) => { + const edge = extractClosestEdge(self.data); + const sourceRowId = source.data.rowId; + + setClosestEdge(null); + if (!edge || typeof sourceRowId !== 'string') return; + + const move = () => onDropRow(sourceRowId, rowId, edge); + + if (hasSorts) { + pendingDropRef.current = move; + setClearSortsOpen(true); + } else { + move(); + } + }, + }) + ); + }, [dragHandleRef, dragType, dropTargetRef, enabled, hasSorts, onDropRow, rowId, rowRef]); + + return { + clearSortsDialog: + enabled && clearSortsOpen ? ( + { + pendingDropRef.current = null; + setClearSortsOpen(false); + }} + onRemoved={() => { + pendingDropRef.current?.(); + pendingDropRef.current = null; + }} + open={clearSortsOpen} + /> + ) : null, + closestEdge, + dragging, + ignoreClickRef, + }; +} diff --git a/src/components/database/list/ListRow.tsx b/src/components/database/list/ListRow.tsx index f179b70f0..5915cf73f 100644 --- a/src/components/database/list/ListRow.tsx +++ b/src/components/database/list/ListRow.tsx @@ -1,15 +1,10 @@ -import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; -import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; -import { attachClosestEdge, extractClosestEdge, type Edge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'; import { type CSSProperties, memo, - type MutableRefObject, useCallback, useEffect, useMemo, useRef, - useState, } from 'react'; import { useTranslation } from 'react-i18next'; @@ -27,7 +22,7 @@ import { ReactComponent as DocumentIcon } from '@/assets/icons/doc.svg'; import { ReactComponent as CommentIcon } from '@/assets/icons/titlebar_comment.svg'; import { Cell } from '@/components/database/components/cell/Cell'; import { DropRowIndicator } from '@/components/database/components/drag-and-drop/DropRowIndicator'; -import { ClearSortingConfirm } from '@/components/database/components/sorts/ClearSortingConfirm'; +import { type Edge, useRowDnd as useListRowDnd } from '@/components/database/components/drag-and-drop/useRowDnd'; import { cn } from '@/lib/utils'; import { isFlagEmoji } from '@/utils/emoji'; @@ -144,101 +139,6 @@ function ListPrimaryField({ field, rowId }: { field: Column; rowId: string }) { ); } -function useListRowDnd({ - dragHandleRef, - enabled, - onDropRow, - rowId, - rowRef, - hasSorts, -}: { - dragHandleRef: MutableRefObject; - enabled: boolean; - onDropRow?: (sourceRowId: string, targetRowId: string, edge: Edge) => void; - rowId: string; - rowRef: MutableRefObject; - hasSorts: boolean; -}) { - const [closestEdge, setClosestEdge] = useState(null); - const [dragging, setDragging] = useState(false); - const [clearSortsOpen, setClearSortsOpen] = useState(false); - const pendingDropRef = useRef<(() => void) | null>(null); - const ignoreClickRef = useRef(false); - - useEffect(() => { - const element = rowRef.current; - const dragHandle = dragHandleRef.current; - - if (!enabled || !element || !dragHandle || !onDropRow) return; - - return combine( - draggable({ - element, - dragHandle, - getInitialData: () => ({ type: 'database-list-row', rowId }), - onDragStart: () => { - ignoreClickRef.current = true; - setDragging(true); - }, - onDrop: () => { - setDragging(false); - window.setTimeout(() => { - ignoreClickRef.current = false; - }, 0); - }, - }), - dropTargetForElements({ - element, - canDrop: ({ source }) => source.data.type === 'database-list-row' && source.data.rowId !== rowId, - getData: ({ input, element: targetElement }) => - attachClosestEdge( - { type: 'database-list-row', rowId }, - { allowedEdges: ['top', 'bottom'], element: targetElement, input } - ), - onDragEnter: ({ self }) => setClosestEdge(extractClosestEdge(self.data)), - onDrag: ({ self }) => setClosestEdge(extractClosestEdge(self.data)), - onDragLeave: () => setClosestEdge(null), - onDrop: ({ self, source }) => { - const edge = extractClosestEdge(self.data); - const sourceRowId = source.data.rowId; - - setClosestEdge(null); - if (!edge || typeof sourceRowId !== 'string') return; - - const move = () => onDropRow(sourceRowId, rowId, edge); - - if (hasSorts) { - pendingDropRef.current = move; - setClearSortsOpen(true); - } else { - move(); - } - }, - }) - ); - }, [dragHandleRef, enabled, hasSorts, onDropRow, rowId, rowRef]); - - return { - clearSortsDialog: - enabled && clearSortsOpen ? ( - { - pendingDropRef.current = null; - setClearSortsOpen(false); - }} - onRemoved={() => { - pendingDropRef.current?.(); - pendingDropRef.current = null; - }} - open={clearSortsOpen} - /> - ) : null, - closestEdge, - dragging, - ignoreClickRef, - }; -} - export interface ListRowProps { fields: Column[]; groupFieldId?: string; diff --git a/src/components/database/list/ListRowActions.tsx b/src/components/database/list/ListRowActions.tsx index bd8b9b6e1..d2953e3ab 100644 --- a/src/components/database/list/ListRowActions.tsx +++ b/src/components/database/list/ListRowActions.tsx @@ -190,7 +190,14 @@ export function ListRowActions({ aria-label={reorderable ? `${t('tooltip.dragRow')}. ${t('tooltip.openMenu')}` : t('tooltip.openMenu')} className='h-[30px] w-5 rounded-[4px] p-[3px] text-icon-secondary focus-visible:ring-1 focus-visible:ring-fill-theme-thick' data-testid='row-accessory-button' - onClick={(event) => event.stopPropagation()} + // Radix opens on pointer-down and cancels that event, which + // keeps the browser from ever starting a native drag on the + // same handle. Leave pointer-down alone and open on click. + onPointerDownCapture={(event) => event.stopPropagation()} + onClick={(event) => { + event.stopPropagation(); + setMenuOpen((open) => !open); + }} size='icon-sm' title={reorderable ? `${t('tooltip.dragRow')} · ${t('tooltip.openMenu')}` : t('tooltip.openMenu')} type='button' diff --git a/src/components/database/timeline/Timeline.tsx b/src/components/database/timeline/Timeline.tsx index cc9d8dd57..b644db2d4 100644 --- a/src/components/database/timeline/Timeline.tsx +++ b/src/components/database/timeline/Timeline.tsx @@ -2,6 +2,8 @@ import { FieldType, useFieldSelector, useTimelineLayoutSetting } from '@/applica import { YjsDatabaseKey } from '@/application/types'; import { TimelineUnsupported } from './TimelineUnsupported'; +import { ListSortSubscription } from '@/components/database/list/ListSortState'; + import { TimelineView } from './TimelineView'; const DATE_FIELD_TYPES = [FieldType.DateTime, FieldType.CreatedTime, FieldType.LastEditedTime]; @@ -15,7 +17,12 @@ export function Timeline() { return ; } - return ; + // One sort subscription for every row's insert / reorder confirmation. + return ( + + + + ); } export default Timeline; diff --git a/src/components/database/timeline/TimelineRow.tsx b/src/components/database/timeline/TimelineRow.tsx index 45a7a7bd8..a1e3a1dd1 100644 --- a/src/components/database/timeline/TimelineRow.tsx +++ b/src/components/database/timeline/TimelineRow.tsx @@ -1,13 +1,10 @@ -import { memo, MouseEvent, PointerEvent as ReactPointerEvent, useCallback } from 'react'; +import { memo, MouseEvent, PointerEvent as ReactPointerEvent, useCallback, useRef } from 'react'; import { useTranslation } from 'react-i18next'; -import { Column, useRowMetaSelector } from '@/application/database-yjs'; +import { Column, Row } from '@/application/database-yjs'; import { ReactComponent as ArrowLeft } from '@/assets/icons/arrow_left.svg'; import { ReactComponent as ArrowRight } from '@/assets/icons/arrow_right.svg'; -import { ReactComponent as ExpandIcon } from '@/assets/icons/expand.svg'; -import { GalleryRowIcon } from '@/components/database/gallery/GalleryRowIcon'; -import { Button } from '@/components/ui/button'; -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { type Edge } from '@/components/database/components/drag-and-drop/useRowDnd'; import { cn } from '@/lib/utils'; import { TIMELINE_ROW_HEIGHT } from './constants'; @@ -15,6 +12,7 @@ import { TimelineDragMode } from './hooks/useTimelineDrag'; import { TimelineRowModel } from './hooks/useTimelineRows'; import { BarRect } from './scale/geometry'; import { TimelineBar, TimelineBarDragLabel } from './TimelineBar'; +import { TimelineSidebarRow } from './TimelineSidebarRow'; interface TimelineRowProps { row: TimelineRowModel; @@ -36,12 +34,16 @@ interface TimelineRowProps { anyDragging?: boolean; /** User-preference time formatter shared by all bars. */ formatTime: (date: Date) => string; + /** View-ordered rows for the table's insert / reorder actions. */ + rowOrders: Row[]; onOpen?: (rowId: string) => void; onSelect?: (rowId: string | null) => void; onScrollTo?: (x: number) => void; onBarPointerDown?: (event: ReactPointerEvent, row: TimelineRowModel, mode: TimelineDragMode) => void; /** An undated row's canvas was clicked at canvas pixel `x`. */ onEmptyClick?: (row: TimelineRowModel, x: number) => void; + /** A table row was dropped on this one (undefined = reordering disabled). */ + onDropRow?: (sourceRowId: string, targetRowId: string, edge: Edge) => void; } function OffscreenPill({ @@ -91,15 +93,16 @@ export const TimelineRow = memo( progressPreview, anyDragging, formatTime, + rowOrders, onOpen, onSelect, onScrollTo, onBarPointerDown, onEmptyClick, + onDropRow, }: TimelineRowProps) => { const { t } = useTranslation(); - const meta = useRowMetaSelector(row.rowId); - const icon = meta?.icon ?? ''; + const rowRef = useRef(null); const showLeftPill = rect !== null && offscreenLeft; const showRightPill = rect !== null && offscreenRight; const canAssignDate = editable && rect === null; @@ -125,52 +128,32 @@ export const TimelineRow = memo( return (
-
- {showSidebar ? ( - <> - - - - - - {t('timeline.openRow', { defaultValue: 'Open' })} - - - ) : null} -
+ {showSidebar ? ( + + ) : ( +
+ )}
; + onOpen?: (rowId: string) => void; + onSelect?: (rowId: string | null) => void; + onDropRow?: (sourceRowId: string, targetRowId: string, edge: Edge) => void; +} + +/** + * One row of the docked table: Notion's hover `+` / `⋮⋮` gutter (insert, + * duplicate, delete, drag to reorder — the List view's actions), the page + * icon and title, and the open button. + */ +export const TimelineSidebarRow = memo( + ({ + row, + width, + editable, + selected, + rowOrders, + dropTargetRef, + onOpen, + onSelect, + onDropRow, + }: TimelineSidebarRowProps) => { + const { t } = useTranslation(); + const meta = useRowMetaSelector(row.rowId); + const icon = meta?.icon ?? ''; + const cellRef = useRef(null); + const dragHandleRef = useRef(null); + const hasSorts = useListHasSorts(); + const dnd = useRowDnd({ + dragHandleRef, + dropTargetRef, + dragType: TIMELINE_ROW_DRAG_TYPE, + enabled: editable && Boolean(onDropRow), + hasSorts, + onDropRow, + rowId: row.rowId, + rowRef: cellRef, + }); + + return ( +
+ {editable ? ( + { + dragHandleRef.current = element; + }} + reorderable={Boolean(onDropRow)} + rowId={row.rowId} + rowOrders={rowOrders} + /> + ) : ( +
+ )} + + + + + + {t('timeline.openRow', { defaultValue: 'Open' })} + + {dnd.closestEdge ? : null} + {dnd.clearSortsDialog} +
+ ); + } +); + +TimelineSidebarRow.displayName = 'TimelineSidebarRow'; diff --git a/src/components/database/timeline/TimelineView.tsx b/src/components/database/timeline/TimelineView.tsx index a8a58d566..31babedc3 100644 --- a/src/components/database/timeline/TimelineView.tsx +++ b/src/components/database/timeline/TimelineView.tsx @@ -1,3 +1,5 @@ +import { reorder } from '@atlaskit/pragmatic-drag-and-drop/reorder'; +import { getReorderDestinationIndex } from '@atlaskit/pragmatic-drag-and-drop-hitbox/util/get-reorder-destination-index'; import { useVirtualizer } from '@tanstack/react-virtual'; import dayjs from 'dayjs'; import { PointerEvent as ReactPointerEvent, useCallback, useMemo, useRef, useState } from 'react'; @@ -16,13 +18,14 @@ import { usePrimaryFieldId, } from '@/application/database-yjs'; import { useUpdateAnyCellDispatch, useUpdateStartEndTimeCell } from '@/application/database-yjs/dispatch/cell'; -import { useNewRowDispatch } from '@/application/database-yjs/dispatch/row'; +import { useNewRowDispatch, useReorderRowDispatch } from '@/application/database-yjs/dispatch/row'; import { useUpdateTimelineSetting } from '@/application/database-yjs/dispatch'; import { YjsDatabaseKey } from '@/application/types'; import { ReactComponent as CollapseIcon } from '@/assets/icons/double_arrow_left.svg'; import { ReactComponent as ExpandIcon } from '@/assets/icons/double_arrow_right.svg'; import { ReactComponent as PlusIcon } from '@/assets/icons/plus.svg'; import { useAIEnabled } from '@/components/app/app.hooks'; +import { type Edge } from '@/components/database/components/drag-and-drop/useRowDnd'; import { useTimeFormat } from '@/components/database/fullcalendar/hooks/useTimeFormat'; import { shouldUseFixedDatabaseViewport } from '@/components/database/layout'; import { Button } from '@/components/ui/button'; @@ -119,7 +122,30 @@ export function TimelineView({ setting }: { setting: TimelineLayoutSetting }) { const showSidebar = localOverride?.showTable ?? setting.showTable; const sidebarWidth = showSidebar ? TIMELINE_SIDEBAR_WIDTH : TIMELINE_COLLAPSED_SIDEBAR_WIDTH; - const { rows, emptyEvents } = useTimelineRows(showSidebar); + const { rows, emptyEvents, rowOrders } = useTimelineRows(showSidebar); + const reorderRow = useReorderRowDispatch(); + // Same reorder semantics as the List view: drop above / below a row, then + // tell the view which row now precedes the moved one. + const handleDropRow = useCallback( + (sourceRowId: string, targetRowId: string, closestEdgeOfTarget: Edge) => { + const startIndex = rowOrders.findIndex((row) => row.id === sourceRowId); + const indexOfTarget = rowOrders.findIndex((row) => row.id === targetRowId); + + if (startIndex < 0 || indexOfTarget < 0) return; + const finishIndex = getReorderDestinationIndex({ + axis: 'vertical', + closestEdgeOfTarget, + indexOfTarget, + startIndex, + }); + + if (finishIndex === startIndex) return; + const nextRows = reorder({ finishIndex, list: rowOrders, startIndex }); + + reorderRow(sourceRowId, nextRows[finishIndex - 1]?.id); + }, + [reorderRow, rowOrders] + ); const relations = useTimelineFieldValues(setting.dependencyFieldId, parseRelationRowIds); const progressValues = useTimelineFieldValues(setting.progressFieldId, parseProgressPercent); const rowIds = useMemo(() => rows.map((row) => row.rowId), [rows]); @@ -416,9 +442,11 @@ export function TimelineView({ setting }: { setting: TimelineLayoutSetting }) { className={cn( // Grid-style header cell: the primary field name plus the table toggle. 'sticky left-0 z-30 flex h-full shrink-0 items-center border-b border-r border-border-primary bg-background-primary', - showSidebar ? 'justify-between pl-2 pr-1' : 'justify-center' + showSidebar ? 'justify-between pr-1' : 'justify-center' )} - style={{ width: sidebarWidth }} + // Line the field name up with the row titles, which sit after the + // 40px hover gutter when the table is editable. + style={{ width: sidebarWidth, paddingLeft: showSidebar ? (permissions.editable ? 44 : 12) : undefined }} > {showSidebar ? {primaryFieldName} : null} @@ -509,11 +537,13 @@ export function TimelineView({ setting }: { setting: TimelineLayoutSetting }) { progressPreview={isDragged && preview?.mode === 'progress' ? preview.progress : undefined} anyDragging={dragging} formatTime={formatTimeDisplay} + rowOrders={rowOrders} onOpen={handleOpen} onSelect={setSelectedRowId} onScrollTo={handleScrollToX} onBarPointerDown={handleBarPointerDown} onEmptyClick={handleEmptyClick} + onDropRow={permissions.editable ? handleDropRow : undefined} />
); @@ -530,9 +560,9 @@ export function TimelineView({ setting }: { setting: TimelineLayoutSetting }) { // Same treatment as the grid's "+ New row" footer. className={cn( 'sticky left-0 z-10 flex h-full shrink-0 cursor-pointer items-center gap-1.5 border-b border-r border-border-primary bg-fill-content text-sm font-medium text-text-secondary hover:bg-fill-content-hover', - showSidebar ? 'px-3' : 'justify-center' + showSidebar ? 'pr-3' : 'justify-center' )} - style={{ width: sidebarWidth }} + style={{ width: sidebarWidth, paddingLeft: showSidebar ? 40 : undefined }} data-testid='timeline-new-row' aria-label={t('grid.row.newRow', { defaultValue: 'New row' })} onClick={handleNewRow} diff --git a/src/components/database/timeline/hooks/useTimelineRows.ts b/src/components/database/timeline/hooks/useTimelineRows.ts index 7da1338f9..57115acc6 100644 --- a/src/components/database/timeline/hooks/useTimelineRows.ts +++ b/src/components/database/timeline/hooks/useTimelineRows.ts @@ -1,6 +1,8 @@ import { useMemo } from 'react'; -import { CalendarEvent, useRowOrdersSelector, useTimelineEventsSelector } from '@/application/database-yjs'; +import { CalendarEvent, Row, useRowOrdersSelector, useTimelineEventsSelector } from '@/application/database-yjs'; + +const EMPTY_ROW_ORDERS: Row[] = []; export interface TimelineRowModel { rowId: string; @@ -55,5 +57,5 @@ export function useTimelineRows(includeUndated: boolean) { return result; }, [events, emptyEvents, includeUndated, rowOrders]); - return { rows, emptyEvents }; + return { rows, emptyEvents, rowOrders: rowOrders ?? EMPTY_ROW_ORDERS }; } From 46aa714a3e6ce3e6b26474466a71394397473ea6 Mon Sep 17 00:00:00 2001 From: Nathan Date: Sun, 13 Sep 2026 16:06:20 +0000 Subject: [PATCH 05/21] feat(timeline): dependency shift modes, avoid weekends, drag-to-link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Notion's dependency options on the timeline settings: - Shift dependents: "Only when dates overlap" (default — a follower moves just far enough to start after the bar it depends on, cascading through the chain), "Keep the time between items" (the former behaviour), or "Never" (followers stay, and a dependent may be dragged before its dependency). Stored as `dependency_shift_ty`. - Avoid weekends: a shifted follower never lands on a Saturday or Sunday (`avoid_weekends`). - Drag to connect: every bar shows a connector handle when a dependency field is bound; dragging it onto another bar appends the source row to the target's relation cell, with duplicate and cycle guards. A dashed connector follows the pointer. useUpdateRelationCell is now a thin wrapper over a row-agnostic useUpdateRelationCellDispatch. The layout setting reader also carries `end_field_id` for the upcoming separate start/end feature. Tests: 4 new jest cases for the shift math; BDD scenarios for each shift mode, avoid weekends (date-aware) and the connector drag. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NoEH4HcEiojEcioVSLE3QM --- .../bdd/features/database/timeline.feature | 36 ++++- playwright/bdd/steps/timeline.steps.ts | 94 ++++++++++++ src/@types/translations/en.json | 7 + .../__tests__/timeline-layout.test.ts | 40 ++++- src/application/database-yjs/database.type.ts | 30 +++- .../database-yjs/dispatch/relation.ts | 16 +- .../database-yjs/timeline-layout.ts | 20 ++- src/application/types.ts | 25 ++- .../settings/TimelineLayoutSettings.tsx | 58 ++++++- .../database/timeline/TimelineArrows.tsx | 20 ++- .../database/timeline/TimelineBar.tsx | 27 +++- .../database/timeline/TimelineRow.tsx | 12 ++ .../database/timeline/TimelineView.tsx | 56 ++++++- .../timeline/__tests__/dependencies.test.ts | 95 +++++++++++- .../timeline/hooks/useTimelineDrag.ts | 143 +++++++++++++++--- .../timeline/hooks/useTimelineLinkDrag.ts | 121 +++++++++++++++ 16 files changed, 749 insertions(+), 51 deletions(-) create mode 100644 src/components/database/timeline/hooks/useTimelineLinkDrag.ts diff --git a/playwright/bdd/features/database/timeline.feature b/playwright/bdd/features/database/timeline.feature index 22ec9865c..cd5538025 100644 --- a/playwright/bdd/features/database/timeline.feature +++ b/playwright/bdd/features/database/timeline.feature @@ -75,8 +75,9 @@ Feature: Timeline view interactions When I show the timeline table Then the timeline table lists 3 rows - Scenario: Dependencies draw arrows, dependents follow the dragged bar, and a bar cannot start before its dependency + Scenario: Dependencies draw arrows, dependents keep their gap, and a bar cannot start before its dependency Given "Build" depends on "Design" through a relation field + And dependents shift with "Keep the time between items" Then the timeline draws 1 dependency arrow When I drag the "Design" bar 2 columns later Then the "Build" bar moved 2 columns later @@ -159,10 +160,43 @@ Feature: Timeline view interactions Scenario: Extending a bar's end pushes its dependents along Given "Build" depends on "Design" through a relation field + And dependents shift with "Keep the time between items" When I drag the end handle of "Design" 3 columns later Then the "Design" bar grew by 3 columns And the "Build" bar moved 3 columns later + Scenario: By default dependents shift only when dates overlap + Given "Build" depends on "Design" through a relation field + When I drag the "Design" bar 1 columns later + Then the "Build" bar is back where it started + When I drag the "Design" bar 2 columns later + Then the "Build" bar moved 2 columns later + When I drag the end handle of "Design" 2 columns later + Then the "Build" bar moved 2 columns later + + Scenario: With shifting off, dependents stay put and a bar may precede its dependency + Given "Build" depends on "Design" through a relation field + And dependents shift with "Never" + When I drag the "Design" bar 3 columns later + Then the "Build" bar is back where it started + When I drag the "Build" bar 6 columns earlier + Then the "Build" bar starts 7 columns before the "Design" bar + + Scenario: Avoid weekends moves a shifted dependent to the next Monday + Given "Build" depends on "Design" through a relation field + And dependents avoid weekends + When I drag the "Design" bar so that "Build" would land on a Saturday + Then the "Build" bar starts on the following Monday + + Scenario: Dragging a bar's connector onto another bar adds a dependency + Given a relation field is bound as the dependency field + Then the timeline draws 0 dependency arrow + When I drag the connector of "Design" onto the "Build" bar + Then the timeline draws 1 dependency arrow + And "Build" depends on "Design" + When I drag the connector of "Build" onto the "Design" bar + Then the timeline draws 1 dependency arrow + Scenario: The table's hover gutter inserts, duplicates and deletes rows When I click the hover "+" of the table row "Design" Then the table lists "Design, Untitled, Build" in that order diff --git a/playwright/bdd/steps/timeline.steps.ts b/playwright/bdd/steps/timeline.steps.ts index 7045c14b2..df2a79e11 100644 --- a/playwright/bdd/steps/timeline.steps.ts +++ b/playwright/bdd/steps/timeline.steps.ts @@ -57,6 +57,8 @@ interface TimelineScenario { rowIdByTitle: Map; /** Bar boxes captured right before the last drag, keyed by title. */ before: Map; + /** Columns "Design" was dragged in the avoid-weekends scenario. */ + weekendShift?: number; } const scenarios = new WeakMap(); @@ -415,6 +417,98 @@ Given('{string} depends on {string} through a relation field', async ({ page }, await chooseTimelineSettingsOption(page, 'timeline-dependency-field-rel-deps'); }); +Given('a relation field is bound as the dependency field', async ({ page }) => { + const { databaseId } = await getCurrentDatabaseInfo(page); + + await injectFieldDirect(page, { + fieldId: 'rel-deps', + name: 'Blocked by', + fieldType: FieldType.Relation, + typeOption: { database_id: databaseId, is_two_way: false, source_limit: 0, target_limit: 0 }, + }); + await chooseTimelineSettingsOption(page, 'timeline-dependency-field-rel-deps'); +}); + +const SHIFT_OPTION: Record = { + 'Only when dates overlap': 0, + 'Keep the time between items': 1, + Never: 2, +}; + +Given('dependents shift with {string}', async ({ page }, option) => { + await chooseTimelineSettingsOption(page, `timeline-shift-${SHIFT_OPTION[option]}`); +}); + +Given('dependents avoid weekends', async ({ page }) => { + await chooseTimelineSettingsOption(page, 'timeline-avoid-weekends'); +}); + +Then('the {string} bar starts {int} columns before the {string} bar', async ({ page }, title, columns, other) => { + await expectBarX(page, title, (await barBox(page, other)).x - columns * MONTH_COLUMN_WIDTH); +}); + +/** + * "Design" is today and "Build" two days later. Move Design so its end lands on + * a Saturday: the pushed Build would start there, so avoid-weekends must put + * it on Monday instead. `columns` is at least 2 so Build actually overlaps. + */ +When('I drag the {string} bar so that {string} would land on a Saturday', async ({ page }, title, follower) => { + await remember(page, 'Design', 'Build'); + const today = new Date().getDay(); + let columns = (12 - today) % 7; // (today + 1 + columns) % 7 === 6 + + if (columns < 2) columns += 7; + scenario(page).weekendShift = columns; + await dragBarBy(page, title, columns * MONTH_COLUMN_WIDTH); + await expect(TimelineSelectors.barByTitle(page, follower)).toBeVisible(); +}); + +Then('the {string} bar starts on the following Monday', async ({ page }, title) => { + const columns = scenario(page).weekendShift ?? 0; + + // Saturday = the day after Design's new last day, Monday two days on; Build began on day 2. + await expectBarX(page, title, before(page, title).x + (columns + 3 - 2) * MONTH_COLUMN_WIDTH); +}); + +When('I drag the connector of {string} onto the {string} bar', async ({ page }, source, target) => { + const sourceBar = TimelineSelectors.barByTitle(page, source); + const targetBox = await barBox(page, target); + + await sourceBar.hover(); + const handle = sourceBar.locator('[data-testid^="timeline-link-"]'); + const handleBox = await handle.boundingBox(); + + if (!handleBox) throw new Error('Link handle 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 + targetBox.height / 2, { steps: 12 }); + await expect(page.getByTestId('timeline-link-preview')).toBeVisible(); + await page.mouse.up(); +}); + +Then('{string} depends on {string}', async ({ page }, dependent, dependency) => { + const dependentId = rowId(page, dependent); + const dependencyId = rowId(page, dependency); + + await expect + .poll( + () => + page.evaluate( + async ({ dependentId }) => { + const ctx = (window as unknown as { __TEST_DATABASE_CONTEXT__: any }).__TEST_DATABASE_CONTEXT__; + const rowDoc = ctx.rowMap?.[dependentId] ?? (await ctx.ensureRow(dependentId)); + const cell = rowDoc.getMap('data').get('data').get('cells').get('rel-deps'); + const data = cell?.get('data'); + + return data?.toArray ? data.toArray() : data ?? []; + }, + { dependentId } + ), + { timeout: 10_000 } + ) + .toContain(dependencyId); +}); + Then('the timeline draws {int} dependency arrow', async ({ page }, count) => { await expect(TimelineSelectors.arrows(page)).toHaveCount(count, { timeout: 15_000 }); }); diff --git a/src/@types/translations/en.json b/src/@types/translations/en.json index 8c4137fed..d5a70d53b 100644 --- a/src/@types/translations/en.json +++ b/src/@types/translations/en.json @@ -4361,6 +4361,12 @@ "unsupportedTitle": "This timeline has no date property", "unsupportedHint": "Add a date property to the database, or pick one in the timeline settings.", "dependencies": "Dependencies", + "shiftDependents": "Shift dependents", + "shiftOverlapOnly": "Only when dates overlap", + "shiftMaintainGap": "Keep the time between items", + "shiftNever": "Never", + "avoidWeekends": "Avoid weekends", + "endDateField": "End date", "progress": "Progress" }, "zoom": { @@ -4383,6 +4389,7 @@ "progress": "{{percent}}% complete" }, "dependencyBlocked": "Cannot start before its dependencies", + "linkHandle": "Drag to add a dependency", "openRow": "Open" } } diff --git a/src/application/database-yjs/__tests__/timeline-layout.test.ts b/src/application/database-yjs/__tests__/timeline-layout.test.ts index d764d97db..1d6c1e8a9 100644 --- a/src/application/database-yjs/__tests__/timeline-layout.test.ts +++ b/src/application/database-yjs/__tests__/timeline-layout.test.ts @@ -2,7 +2,7 @@ import * as Y from 'yjs'; import { YDatabase, YDatabaseView, YjsDatabaseKey, YjsEditorKey } from '@/application/types'; -import { TimelineLayout } from '../database.type'; +import { TimelineDependencyShift, TimelineLayout } from '../database.type'; import { createTimelineLayoutStore, initializeTimelineLayoutSetting, @@ -36,7 +36,10 @@ test('missing setting falls back to month scale, docked table, and the user week layout: TimelineLayout.Month, showTable: true, firstDayOfWeek: 1, + endFieldId: '', dependencyFieldId: '', + dependencyShift: TimelineDependencyShift.OverlapOnly, + avoidWeekends: false, progressFieldId: '', use24Hour: false, }); @@ -81,7 +84,10 @@ test('integers written by the server as BigInt decode like web numbers', () => { layout: TimelineLayout.Quarter, showTable: false, firstDayOfWeek: 1, + endFieldId: '', dependencyFieldId: '', + dependencyShift: TimelineDependencyShift.OverlapOnly, + avoidWeekends: false, progressFieldId: '', use24Hour: false, }); @@ -104,6 +110,35 @@ test('dependency and progress bindings are optional keys that an empty id remove expect(setting.get(YjsDatabaseKey.progress_field_id)).toBe('num'); }); +test('dependency shift, avoid-weekends and the end field round-trip like the calendar keys', () => { + const { doc, view, database } = createFixture(); + + doc.transact(() => + updateTimelineLayoutSetting(view, { + fieldId: 'date', + endFieldId: 'date-end', + dependencyShift: TimelineDependencyShift.MaintainGap, + avoidWeekends: true, + }) + ); + expect(readTimelineLayoutSetting(database, 'timeline', 0, false)).toMatchObject({ + endFieldId: 'date-end', + dependencyShift: TimelineDependencyShift.MaintainGap, + avoidWeekends: true, + }); + const setting = view.get(YjsDatabaseKey.layout_settings).get(TIMELINE_LAYOUT_KEY); + + expect(setting.get(YjsDatabaseKey.dependency_shift_ty)).toBe(1); + doc.transact(() => + updateTimelineLayoutSetting(view, { endFieldId: '', dependencyShift: 99 as TimelineDependencyShift }) + ); + expect(setting.has(YjsDatabaseKey.end_field_id)).toBe(false); + // Out-of-range wire values fall back to Notion's default. + expect(readTimelineLayoutSetting(database, 'timeline', 0, false).dependencyShift).toBe( + TimelineDependencyShift.OverlapOnly + ); +}); + test('the store notifies on remote changes only for this view and tolerates bad values', () => { const desktop = createFixture(); const webDoc = new Y.Doc(); @@ -129,7 +164,10 @@ test('the store notifies on remote changes only for this view and tolerates bad layout: TimelineLayout.Quarter, showTable: false, firstDayOfWeek: 1, + endFieldId: '', dependencyFieldId: '', + dependencyShift: TimelineDependencyShift.OverlapOnly, + avoidWeekends: false, progressFieldId: '', use24Hour: false, }); diff --git a/src/application/database-yjs/database.type.ts b/src/application/database-yjs/database.type.ts index 1022ff1c9..f7ca91885 100644 --- a/src/application/database-yjs/database.type.ts +++ b/src/application/database-yjs/database.type.ts @@ -33,7 +33,9 @@ export enum FieldType { export const ATTRIBUTION_FIELD_TYPES = [FieldType.CreatedBy, FieldType.LastEditedBy] as const; export function isAttributionFieldType(fieldType: FieldType | undefined): boolean { - return fieldType !== undefined && ATTRIBUTION_FIELD_TYPES.includes(fieldType as (typeof ATTRIBUTION_FIELD_TYPES)[number]); + return ( + fieldType !== undefined && ATTRIBUTION_FIELD_TYPES.includes(fieldType as (typeof ATTRIBUTION_FIELD_TYPES)[number]) + ); } export const AI_FIELD_TYPES = [FieldType.Summary, FieldType.Translate] as const; @@ -117,6 +119,16 @@ export enum TimelineLayout { Year = 6, } +/** Notion's "Shift dependents" options, stored as `dependency_shift_ty`. */ +export enum TimelineDependencyShift { + /** Move a dependent only as far as needed to start after the bar it depends on. */ + OverlapOnly = 0, + /** Move dependents by the same distance, preserving the gap between items. */ + MaintainGap = 1, + /** Never move dependents automatically. */ + Never = 2, +} + export interface TimelineLayoutSetting { /// DateTime field plotted on the timeline. fieldId: string; @@ -126,8 +138,14 @@ export interface TimelineLayoutSetting { firstDayOfWeek: number; /** User preference, read like the calendar does so hour labels match. */ use24Hour: boolean; + /// Optional second date field supplying each bar's end ("separate start and end dates"). + endFieldId: string; /// Relation field (pointing at this database) whose linked rows are the row's dependencies. dependencyFieldId: string; + /// How dependents move when the bar they depend on is dragged. + dependencyShift: TimelineDependencyShift; + /// Shifted dependents never land on a Saturday or Sunday. + avoidWeekends: boolean; /// Number field holding 0–100 progress drawn as a fill inside the bar. progressFieldId: string; } @@ -152,9 +170,9 @@ export enum RowMetaKey { export interface RowMeta { documentId: string; cover: { - data: string, - cover_type: RowCoverType, - offset?: number, + data: string; + cover_type: RowCoverType; + offset?: number; } | null; icon: string; isEmptyDocument: boolean; @@ -169,7 +187,7 @@ export enum AITranslateLanguage { Spanish, Portuguese, Standard_Arabic, - Simplified_Chinese + Simplified_Chinese, } export enum RowCommentKey { @@ -191,5 +209,5 @@ export enum DateGroupCondition { Day = 1, Week = 2, Month = 3, - Year = 4 + Year = 4, } diff --git a/src/application/database-yjs/dispatch/relation.ts b/src/application/database-yjs/dispatch/relation.ts index a73a3c0b1..a2e6e3264 100644 --- a/src/application/database-yjs/dispatch/relation.ts +++ b/src/application/database-yjs/dispatch/relation.ts @@ -579,7 +579,11 @@ export async function applyRelationReciprocalInserts(args: { ); } -export function useUpdateRelationCell(rowId: RowId, fieldId: FieldId) { +/** + * Row-agnostic relation cell writer: `(rowId, fieldId, changes)`. Keeps the + * reciprocal side of a two-way relation in step, like `useUpdateRelationCell`. + */ +export function useUpdateRelationCellDispatch() { const context = useDatabaseContext(); const database = useDatabase(); const rowMap = useRowMap(); @@ -588,7 +592,7 @@ export function useUpdateRelationCell(rowId: RowId, fieldId: FieldId) { const actorUid = resolveUserAttributionUid(currentUser); return useCallback( - async (changes: RelationCellChanges) => { + async (rowId: RowId, fieldId: FieldId, changes: RelationCellChanges) => { const field = database.get(YjsDatabaseKey.fields)?.get(fieldId); if (!field) return; @@ -685,10 +689,16 @@ export function useUpdateRelationCell(rowId: RowId, fieldId: FieldId) { }), ]); }, - [actorUid, bindViewSync, context, createRow, database, fieldId, getViewIdFromDatabaseId, loadView, rowId, rowMap] + [actorUid, bindViewSync, context, createRow, database, getViewIdFromDatabaseId, loadView, rowMap] ); } +export function useUpdateRelationCell(rowId: RowId, fieldId: FieldId) { + const update = useUpdateRelationCellDispatch(); + + return useCallback((changes: RelationCellChanges) => update(rowId, fieldId, changes), [fieldId, rowId, update]); +} + export function useUpdateRelationTypeOption(fieldId: FieldId) { const context = useDatabaseContext(); const database = useDatabase(); diff --git a/src/application/database-yjs/timeline-layout.ts b/src/application/database-yjs/timeline-layout.ts index b38faae1f..fc696926c 100644 --- a/src/application/database-yjs/timeline-layout.ts +++ b/src/application/database-yjs/timeline-layout.ts @@ -9,13 +9,14 @@ import { YjsEditorKey, } from '@/application/types'; -import { TimelineLayout, TimelineLayoutSetting } from './database.type'; +import { TimelineDependencyShift, TimelineLayout, TimelineLayoutSetting } from './database.type'; /** Layout-settings key for `DatabaseViewLayout.Timeline`. */ export const TIMELINE_LAYOUT_KEY = '8'; export const DEFAULT_TIMELINE_LAYOUT = TimelineLayout.Month; export const DEFAULT_TIMELINE_SHOW_TABLE = true; +export const DEFAULT_TIMELINE_DEPENDENCY_SHIFT = TimelineDependencyShift.OverlapOnly; function integer(value: unknown, min: number, max: number): number | undefined { if (typeof value !== 'number' && typeof value !== 'bigint') return undefined; @@ -42,6 +43,12 @@ export function readTimelineLayoutSetting( ?.get(TIMELINE_LAYOUT_KEY); const layout = integer(setting?.get(YjsDatabaseKey.layout_ty), TimelineLayout.Hours, TimelineLayout.Year); const showTable = setting?.get(YjsDatabaseKey.show_table); + const avoidWeekends = setting?.get(YjsDatabaseKey.avoid_weekends); + const dependencyShift = integer( + setting?.get(YjsDatabaseKey.dependency_shift_ty), + TimelineDependencyShift.OverlapOnly, + TimelineDependencyShift.Never + ); const weekday = integer(setting?.get(YjsDatabaseKey.first_day_of_week_v2), 0, 6) ?? integer(setting?.get(YjsDatabaseKey.first_day_of_week), 0, 6); @@ -52,7 +59,10 @@ export function readTimelineLayoutSetting( showTable: typeof showTable === 'boolean' ? showTable : DEFAULT_TIMELINE_SHOW_TABLE, firstDayOfWeek: weekday ?? firstDayOfWeek, use24Hour, + endFieldId: setting?.get(YjsDatabaseKey.end_field_id) ?? '', dependencyFieldId: setting?.get(YjsDatabaseKey.dependency_field_id) ?? '', + dependencyShift: dependencyShift ?? DEFAULT_TIMELINE_DEPENDENCY_SHIFT, + avoidWeekends: typeof avoidWeekends === 'boolean' ? avoidWeekends : false, progressFieldId: setting?.get(YjsDatabaseKey.progress_field_id) ?? '', }; } @@ -98,6 +108,14 @@ export function updateTimelineLayoutSetting(view: YDatabaseView, settings: Timel if (settings.progressFieldId) setting.set(YjsDatabaseKey.progress_field_id, settings.progressFieldId); else setting.delete(YjsDatabaseKey.progress_field_id); } + + if (settings.endFieldId !== undefined) { + if (settings.endFieldId) setting.set(YjsDatabaseKey.end_field_id, settings.endFieldId); + else setting.delete(YjsDatabaseKey.end_field_id); + } + + if (settings.dependencyShift !== undefined) setting.set(YjsDatabaseKey.dependency_shift_ty, settings.dependencyShift); + if (settings.avoidWeekends !== undefined) setting.set(YjsDatabaseKey.avoid_weekends, settings.avoidWeekends); } /** diff --git a/src/application/types.ts b/src/application/types.ts index 9b9bdaef4..90323f1fd 100644 --- a/src/application/types.ts +++ b/src/application/types.ts @@ -668,6 +668,12 @@ export enum YjsDatabaseKey { dependency_field_id = 'dependency_field_id', /// Timeline layout setting: Number field (0–100) drawn as a progress fill. progress_field_id = 'progress_field_id', + /// Timeline layout setting: second date field supplying each bar's end. + end_field_id = 'end_field_id', + /// Timeline layout setting: how dependents move with a dragged bar (`TimelineDependencyShift`). + dependency_shift_ty = 'dependency_shift_ty', + /// Timeline layout setting: shifted dependents skip Saturdays and Sundays. + avoid_weekends = 'avoid_weekends', icon = 'icon', is_inline = 'is_inline', embedded = 'embedded', @@ -1004,7 +1010,12 @@ export interface YDatabaseBoardLayoutSetting extends Y.Map { export interface YDatabaseCalendarLayoutSetting extends Y.Map { get(key: YjsDatabaseKey.field_id): string; get( - key: YjsDatabaseKey.first_day_of_week | YjsDatabaseKey.first_day_of_week_v2 | YjsDatabaseKey.layout_ty | YjsDatabaseKey.day_count | YjsDatabaseKey.number_of_days + key: + | YjsDatabaseKey.first_day_of_week + | YjsDatabaseKey.first_day_of_week_v2 + | YjsDatabaseKey.layout_ty + | YjsDatabaseKey.day_count + | YjsDatabaseKey.number_of_days ): number | bigint | null | undefined; get(key: YjsDatabaseKey.show_week_numbers | YjsDatabaseKey.show_weekends): boolean; @@ -1014,11 +1025,17 @@ export interface YDatabaseCalendarLayoutSetting extends Y.Map { /// the timeline-only `show_table` and optional field bindings. export interface YDatabaseTimelineLayoutSetting extends Y.Map { get(key: YjsDatabaseKey.field_id): string; - get(key: YjsDatabaseKey.dependency_field_id | YjsDatabaseKey.progress_field_id): string | undefined; get( - key: YjsDatabaseKey.layout_ty | YjsDatabaseKey.first_day_of_week | YjsDatabaseKey.first_day_of_week_v2 + key: YjsDatabaseKey.dependency_field_id | YjsDatabaseKey.progress_field_id | YjsDatabaseKey.end_field_id + ): string | undefined; + get( + key: + | YjsDatabaseKey.layout_ty + | YjsDatabaseKey.first_day_of_week + | YjsDatabaseKey.first_day_of_week_v2 + | YjsDatabaseKey.dependency_shift_ty ): number | bigint | null | undefined; - get(key: YjsDatabaseKey.show_table): boolean | undefined; + get(key: YjsDatabaseKey.show_table | YjsDatabaseKey.avoid_weekends): boolean | undefined; } export interface YDatabaseChartLayoutSetting extends Y.Map { diff --git a/src/components/database/components/settings/TimelineLayoutSettings.tsx b/src/components/database/components/settings/TimelineLayoutSettings.tsx index 101ac1636..bb5b1ca4c 100644 --- a/src/components/database/components/settings/TimelineLayoutSettings.tsx +++ b/src/components/database/components/settings/TimelineLayoutSettings.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'; import { FieldType, parseRelationTypeOption, + TimelineDependencyShift, useDatabase, useDatabaseFields, usePropertiesSelector, @@ -28,6 +29,21 @@ import { Switch } from '@/components/ui/switch'; const DATE_FIELD_TYPES = [FieldType.DateTime, FieldType.LastEditedTime, FieldType.CreatedTime]; +// Notion's "Shift dependents" choices, in its order. +const SHIFT_OPTIONS = [ + { + value: TimelineDependencyShift.OverlapOnly, + labelKey: 'timeline.settings.shiftOverlapOnly', + fallback: 'Only when dates overlap', + }, + { + value: TimelineDependencyShift.MaintainGap, + labelKey: 'timeline.settings.shiftMaintainGap', + fallback: 'Keep the time between items', + }, + { value: TimelineDependencyShift.Never, labelKey: 'timeline.settings.shiftNever', fallback: 'Never' }, +]; + function TimelineLayoutSettings() { const { t } = useTranslation(); const setting = useTimelineLayoutSetting(); @@ -111,7 +127,9 @@ function TimelineLayoutSettings() { - {t('timeline.settings.layoutDateField', { defaultValue: 'Timeline by' })} + + {t('timeline.settings.layoutDateField', { defaultValue: 'Timeline by' })} + {dateProperties.map((property) => ( updateSetting({ dependencyFieldId }) )} + {setting.dependencyFieldId ? ( + <> + + {t('timeline.settings.shiftDependents', { defaultValue: 'Shift dependents' })} + + {SHIFT_OPTIONS.map((option) => ( + { + e.preventDefault(); + updateSetting({ dependencyShift: option.value }); + }} + > + {t(option.labelKey, { defaultValue: option.fallback })} + {setting.dependencyShift === option.value && } + + ))} + { + e.preventDefault(); + updateSetting({ avoidWeekends: !setting.avoidWeekends }); + }} + > + {t('timeline.settings.avoidWeekends', { defaultValue: 'Avoid weekends' })} + + + + ) : null} + {renderOptionalField( @@ -163,7 +215,9 @@ function TimelineLayoutSettings() { - {t('timeline.settings.firstDayOfWeek', { defaultValue: 'Start week on' })} + + {t('timeline.settings.firstDayOfWeek', { defaultValue: 'Start week on' })} + {weekDays.map((day) => ( { const paths = useMemo(() => { const indexOf = new Map(rowIds.map((rowId, index) => [rowId, index] as const)); @@ -65,7 +69,7 @@ export const TimelineArrows = memo( return result; }, [firstVisibleIndex, graph, lastVisibleIndex, rects, rowIds]); - if (paths.length === 0) return null; + if (paths.length === 0 && !pending) return null; return ( ))} + {pending ? ( + + + + + ) : null} ); } diff --git a/src/components/database/timeline/TimelineBar.tsx b/src/components/database/timeline/TimelineBar.tsx index e7baf11ad..738a2e82c 100644 --- a/src/components/database/timeline/TimelineBar.tsx +++ b/src/components/database/timeline/TimelineBar.tsx @@ -46,6 +46,11 @@ interface TimelineBarProps { hoverDisabled?: boolean; /** User-preference time formatter, owned by the view so bars don't subscribe individually. */ formatTime: (date: Date) => string; + /** A dependency field is bound: show the connector handle and accept link drops. */ + linkable?: boolean; + /** Another bar's connector is being dragged over this one. */ + linkTarget?: boolean; + onLinkPointerDown?: (event: ReactPointerEvent) => void; onOpen?: (rowId: string) => void; onPointerDown?: (event: ReactPointerEvent, mode: TimelineDragMode) => void; } @@ -100,6 +105,9 @@ export const TimelineBar = memo( progressPreview, hoverDisabled, formatTime, + linkable, + linkTarget, + onLinkPointerDown, onOpen, onPointerDown, }: TimelineBarProps) => { @@ -145,7 +153,7 @@ export const TimelineBar = memo( 'transition-shadow duration-200 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border-theme-thick', editable ? 'cursor-grab active:cursor-grabbing' : 'cursor-pointer', highlighted && (row.allDay ? 'bg-other-colors-filled-event-hover' : 'bg-fill-content-hover'), - highlighted && 'ring-1 ring-fill-theme-thick', + (highlighted || linkTarget) && 'ring-1 ring-fill-theme-thick', 'py-0 pl-1 pr-1' )} > @@ -200,8 +208,10 @@ export const TimelineBar = memo( width: rect.width, }} data-testid={`timeline-bar-${rowId}`} + data-timeline-bar={rowId} data-dragging={dragging ? 'true' : undefined} data-selected={selected ? 'true' : undefined} + data-link-target={linkTarget ? 'true' : undefined} > {iconOnly ? (
+ {linkable ? ( + + ) : null} {showProgress && !iconOnly ? (
void; /** A table row was dropped on this one (undefined = reordering disabled). */ onDropRow?: (sourceRowId: string, targetRowId: string, edge: Edge) => void; + /** A dependency field is bound, so bars offer a connector handle. */ + linkable?: boolean; + /** A connector is being dragged over this row's bar. */ + linkTarget?: boolean; + /** The connector handle was pressed: start a link drag from this row. */ + onLinkPointerDown?: (event: ReactPointerEvent, row: TimelineRowModel, rect: BarRect) => void; } function OffscreenPill({ @@ -100,6 +106,9 @@ export const TimelineRow = memo( onBarPointerDown, onEmptyClick, onDropRow, + linkable, + linkTarget, + onLinkPointerDown, }: TimelineRowProps) => { const { t } = useTranslation(); const rowRef = useRef(null); @@ -183,6 +192,9 @@ export const TimelineRow = memo( progressPreview={progressPreview} hoverDisabled={anyDragging} formatTime={formatTime} + linkable={linkable} + linkTarget={linkTarget} + onLinkPointerDown={onLinkPointerDown ? (event) => onLinkPointerDown(event, row, rect) : undefined} onOpen={onOpen} onPointerDown={handleBarPointerDown} /> diff --git a/src/components/database/timeline/TimelineView.tsx b/src/components/database/timeline/TimelineView.tsx index 31babedc3..4c0ce1aeb 100644 --- a/src/components/database/timeline/TimelineView.tsx +++ b/src/components/database/timeline/TimelineView.tsx @@ -18,6 +18,7 @@ import { usePrimaryFieldId, } from '@/application/database-yjs'; import { useUpdateAnyCellDispatch, useUpdateStartEndTimeCell } from '@/application/database-yjs/dispatch/cell'; +import { useUpdateRelationCellDispatch } from '@/application/database-yjs/dispatch/relation'; import { useNewRowDispatch, useReorderRowDispatch } from '@/application/database-yjs/dispatch/row'; import { useUpdateTimelineSetting } from '@/application/database-yjs/dispatch'; import { YjsDatabaseKey } from '@/application/types'; @@ -44,6 +45,7 @@ import { } from './constants'; import { useScrollWindow } from './hooks/useScrollWindow'; import { TimelineDragMode, TimelineDragPreview, TimelineDragSpan, useTimelineDrag } from './hooks/useTimelineDrag'; +import { useTimelineLinkDrag } from './hooks/useTimelineLinkDrag'; import { parseProgressPercent, parseRelationRowIds, useTimelineFieldValues } from './hooks/useTimelineFieldValues'; import { useTimelinePermissions } from './hooks/useTimelinePermissions'; import { useTimelineRange } from './hooks/useTimelineRange'; @@ -54,6 +56,7 @@ import { buildHeaderSegments, calendarDaysBetween, dateToX, + BarRect, getBarRect, getBarSpan, getSpanRect, @@ -272,8 +275,12 @@ export function TimelineView({ setting }: { setting: TimelineLayoutSetting }) { if (!permissions.editable || !row.start) return; const span = getBarSpan(row.start, row.end, row.allDay); const byId = new Map(rowsRef.current.map((candidate) => [candidate.rowId, candidate] as const)); - // Dependents move with the bar (frappe's `move_dependencies`). - const followers: TimelineDragSpan[] = collectDependents(row.rowId, graph).flatMap((dependentId) => { + // Dependents move with the bar per the "Shift dependents" setting; each + // carries the dependencies it has inside the moving set so "only when + // overlapping" can cascade through the chain. + const dependentIds = collectDependents(row.rowId, graph); + const movingSet = new Set([row.rowId, ...dependentIds]); + const followers: TimelineDragSpan[] = dependentIds.flatMap((dependentId) => { const dependent = byId.get(dependentId); if (!dependent?.start) return []; @@ -282,6 +289,7 @@ export function TimelineView({ setting }: { setting: TimelineLayoutSetting }) { rowId: dependent.rowId, allDay: dependent.allDay, ...getBarSpan(dependent.start, dependent.end, dependent.allDay), + predecessors: (graph.predecessors.get(dependentId) ?? []).filter((id) => movingSet.has(id)), }, ]; }); @@ -302,13 +310,15 @@ export function TimelineView({ setting }: { setting: TimelineLayoutSetting }) { allDay: row.allDay, ...span, followers, + shift: setting.dependencyShift, + avoidWeekends: setting.avoidWeekends, minStart, progress: progressValues.get(row.rowId) ?? 0, }, mode ); }, - [graph, permissions.editable, progressValues, startDrag] + [graph, permissions.editable, progressValues, setting.avoidWeekends, setting.dependencyShift, startDrag] ); const handleEmptyClick = useCallback( @@ -333,6 +343,40 @@ export function TimelineView({ setting }: { setting: TimelineLayoutSetting }) { }); const rowIndexById = useMemo(() => new Map(rows.map((row, index) => [row.rowId, index] as const)), [rows]); + + const updateRelationCell = useUpdateRelationCellDispatch(); + const graphRef = useRef(graph); + + graphRef.current = graph; + // Dropping a bar's connector on another bar makes the target depend on the + // source: the source row id is appended to the target's relation cell. + const handleLinkCommit = useCallback( + (sourceRowId: string, targetRowId: string) => { + const current = graphRef.current; + + if (!setting.dependencyFieldId || sourceRowId === targetRowId) return; + if (current.predecessors.get(targetRowId)?.includes(sourceRowId)) return; + // Refuse a link that would close a cycle (the source already depends on the target). + if (collectDependents(targetRowId, current).includes(sourceRowId)) return; + void updateRelationCell(targetRowId, setting.dependencyFieldId, { insertedRowIds: [sourceRowId] }).catch( + () => undefined + ); + }, + [setting.dependencyFieldId, updateRelationCell] + ); + const { link, startLink } = useTimelineLinkDrag({ scrollerRef, sidebarWidth, onCommit: handleLinkCommit }); + const handleLinkPointerDown = useCallback( + (event: ReactPointerEvent, row: TimelineRowModel, rect: BarRect) => { + const index = rowIndexById.get(row.rowId); + + if (index === undefined) return; + startLink(event, row.rowId, { + x: rect.left + rect.width, + y: index * TIMELINE_ROW_HEIGHT + TIMELINE_ROW_HEIGHT / 2, + }); + }, + [rowIndexById, startLink] + ); // Base rects only change with the data or the scale; a drag overlays the few // rows it moves so every other row keeps its rect reference (and its memo). const baseRects = useMemo( @@ -488,8 +532,9 @@ export function TimelineView({ setting }: { setting: TimelineLayoutSetting }) { /> - {graph.predecessors.size > 0 ? ( + {graph.predecessors.size > 0 || link ? (
); diff --git a/src/components/database/timeline/__tests__/dependencies.test.ts b/src/components/database/timeline/__tests__/dependencies.test.ts index da8a00899..c4360eb9e 100644 --- a/src/components/database/timeline/__tests__/dependencies.test.ts +++ b/src/components/database/timeline/__tests__/dependencies.test.ts @@ -1,4 +1,4 @@ -import { TimelineLayout } from '@/application/database-yjs'; +import { TimelineDependencyShift, TimelineLayout } from '@/application/database-yjs'; import { applyDragDelta } from '../hooks/useTimelineDrag'; import { buildDependencyGraph, collectDependents, dependencyArrowPath } from '../scale/dependencies'; @@ -85,10 +85,12 @@ describe('applyDragDelta with dependencies and progress', () => { endExclusive: local(2020, 11, day + days), }); - test('moving a bar shifts its followers by the same snapped delta', () => { + const keepGap = { shift: TimelineDependencyShift.MaintainGap }; + + test('"maintain gap" moves followers by the same snapped delta', () => { const preview = applyDragDelta( geometry, - { ...span('a', 5, 3), mode: 'move', followers: [span('b', 9, 2), span('c', 12, 1)] }, + { ...span('a', 5, 3), mode: 'move', ...keepGap, followers: [span('b', 9, 2), span('c', 12, 1)] }, columnWidth * 2 + 5 ); @@ -103,7 +105,7 @@ describe('applyDragDelta with dependencies and progress', () => { test('a bar cannot move or start before its dependencies, and followers only travel the clamped distance', () => { const move = applyDragDelta( geometry, - { ...span('b', 10, 2), mode: 'move', minStart: local(2020, 11, 8), followers: [span('c', 14, 1)] }, + { ...span('b', 10, 2), mode: 'move', ...keepGap, minStart: local(2020, 11, 8), followers: [span('c', 14, 1)] }, -columnWidth * 5 ); @@ -121,10 +123,10 @@ describe('applyDragDelta with dependencies and progress', () => { expect(resize.followers).toEqual([]); }); - test('extending the end pushes followers along; shrinking pulls them back', () => { + test('"maintain gap": extending the end pushes followers along; shrinking pulls them back', () => { const grow = applyDragDelta( geometry, - { ...span('a', 5, 3), mode: 'resize-end', followers: [span('b', 9, 2)] }, + { ...span('a', 5, 3), mode: 'resize-end', ...keepGap, followers: [span('b', 9, 2)] }, columnWidth * 2 ); @@ -133,7 +135,7 @@ describe('applyDragDelta with dependencies and progress', () => { const shrink = applyDragDelta( geometry, - { ...span('a', 5, 3), mode: 'resize-end', followers: [span('b', 9, 2)] }, + { ...span('a', 5, 3), mode: 'resize-end', ...keepGap, followers: [span('b', 9, 2)] }, -columnWidth * 10 ); @@ -142,6 +144,85 @@ describe('applyDragDelta with dependencies and progress', () => { expect(shrink.followers[0].start).toEqual(local(2020, 11, 7)); }); + test('"only when overlapping" (default) moves a follower just past its dependency and cascades', () => { + // a: Nov 5–7, b (depends on a): Nov 9–10, c (depends on b): Nov 11 + const followers = [ + { ...span('b', 9, 2), predecessors: ['a'] }, + { ...span('c', 11, 1), predecessors: ['b'] }, + ]; + const small = applyDragDelta(geometry, { ...span('a', 5, 3), mode: 'move', followers }, columnWidth); + + // a now ends Nov 9 (exclusive): b still starts on the 9th, nothing overlaps. + expect(small.followers.map((follower) => follower.start)).toEqual([local(2020, 11, 9), local(2020, 11, 11)]); + + const big = applyDragDelta(geometry, { ...span('a', 5, 3), mode: 'move', followers }, columnWidth * 4); + + // a: Nov 9–11 → b must start on the 12th (2 days → ends 14th) → c on the 14th. + expect(big.followers.map((follower) => [follower.start, follower.endExclusive])).toEqual([ + [local(2020, 11, 12), local(2020, 11, 14)], + [local(2020, 11, 14), local(2020, 11, 15)], + ]); + + // Moving earlier never pulls followers back. + const earlier = applyDragDelta(geometry, { ...span('a', 5, 3), mode: 'move', followers }, -columnWidth * 3); + + expect(earlier.followers.map((follower) => follower.start)).toEqual([local(2020, 11, 9), local(2020, 11, 11)]); + }); + + test('"only when overlapping" also applies when the end handle grows into a follower', () => { + const grow = applyDragDelta( + geometry, + { ...span('a', 5, 3), mode: 'resize-end', followers: [{ ...span('b', 9, 2), predecessors: ['a'] }] }, + columnWidth * 3 + ); + + expect(grow.endExclusive).toEqual(local(2020, 11, 11)); + expect(grow.followers[0].start).toEqual(local(2020, 11, 11)); + }); + + test('"never" leaves followers alone and lets a dependent be dragged before its dependency', () => { + const preview = applyDragDelta( + geometry, + { + ...span('b', 10, 2), + mode: 'move', + shift: TimelineDependencyShift.Never, + minStart: local(2020, 11, 8), + followers: [{ ...span('c', 14, 1), predecessors: ['b'] }], + }, + -columnWidth * 5 + ); + + expect(preview.start).toEqual(local(2020, 11, 5)); + expect(preview.followers).toEqual([]); + }); + + test('avoid weekends pushes a shifted follower to the next Monday', () => { + // Nov 2020: the 14th is a Saturday, the 16th a Monday. + const overlap = applyDragDelta( + geometry, + { + ...span('a', 5, 3), + mode: 'move', + avoidWeekends: true, + followers: [{ ...span('b', 9, 2), predecessors: ['a'] }], + }, + columnWidth * 6 + ); + + // a: Nov 11–13 → b would start Saturday the 14th → Monday the 16th. + expect(overlap.followers[0].start).toEqual(local(2020, 11, 16)); + expect(overlap.followers[0].endExclusive).toEqual(local(2020, 11, 18)); + + const gap = applyDragDelta( + geometry, + { ...span('a', 5, 3), mode: 'move', ...keepGap, avoidWeekends: true, followers: [span('b', 9, 2)] }, + columnWidth * 5 + ); + + expect(gap.followers[0].start).toEqual(local(2020, 11, 16)); + }); + test('progress drags convert pixels to a clamped whole percent and never touch dates', () => { const origin = { ...span('a', 5, 4), mode: 'progress' as const, progress: 25 }; const width = columnWidth * 4; diff --git a/src/components/database/timeline/hooks/useTimelineDrag.ts b/src/components/database/timeline/hooks/useTimelineDrag.ts index 9d302a1a5..4a15c422a 100644 --- a/src/components/database/timeline/hooks/useTimelineDrag.ts +++ b/src/components/database/timeline/hooks/useTimelineDrag.ts @@ -7,13 +7,25 @@ * delta into a snapped date (or percent) delta on every move, and only commit * on pointer-up when the pointer actually travelled. Dates are computed from * the origin dates rather than from pixels so a bar never drifts across - * repeated drags. Like frappe's `move_dependencies`, rows that depend on the - * dragged one ("followers") shift with it, and a bar cannot start before its - * dependencies do. + * repeated drags. Rows that depend on the dragged one ("followers") move with + * it according to Notion's "Shift dependents" setting: only as far as needed + * to avoid overlapping (default), by the same distance like frappe's + * `move_dependencies`, or not at all. Unless shifting is off, a bar cannot + * start before its dependencies do. */ import { PointerEvent as ReactPointerEvent, useCallback, useEffect, useRef, useState } from 'react'; -import { columnIndexOf, dateToX, snapDate, TimelineGeometry, xToDate } from '../scale/geometry'; +import { TimelineDependencyShift } from '@/application/database-yjs'; + +import { + calendarDaysBetween, + columnIndexOf, + dateToX, + snapDate, + startOfDay, + TimelineGeometry, + xToDate, +} from '../scale/geometry'; export type TimelineDragMode = 'move' | 'resize-start' | 'resize-end' | 'progress'; @@ -24,11 +36,17 @@ export interface TimelineDragSpan { /** Exclusive bar end (the day after the last covered day for all-day rows). */ endExclusive: Date; allDay: boolean; + /** For followers: the rows it depends on, limited to the dragged bar and other followers. */ + predecessors?: string[]; } export interface TimelineDragOrigin extends TimelineDragSpan { - /** Rows that shift with this one when it moves or its end moves. */ + /** Rows that depend (transitively) on this one, in dependency order. */ followers?: TimelineDragSpan[]; + /** How followers move; defaults to Notion's "only when dates overlap". */ + shift?: TimelineDependencyShift; + /** Shifted followers never land on a Saturday or Sunday. */ + avoidWeekends?: boolean; /** Earliest start allowed, e.g. the latest start among its dependencies. */ minStart?: Date; /** Current 0–100 progress, required for the progress mode. */ @@ -83,6 +101,98 @@ function shiftSpan(geometry: TimelineGeometry, span: TimelineDragSpan, deltaPx: return { rowId: span.rowId, allDay: span.allDay, start, endExclusive }; } +function isWeekend(date: Date): boolean { + const day = date.getDay(); + + return day === 0 || day === 6; +} + +/** Same time of day on the next Monday when `date` falls on a weekend. */ +function skipWeekend(date: Date): Date { + if (!isWeekend(date)) return date; + const next = new Date(date.getTime()); + + while (isWeekend(next)) next.setDate(next.getDate() + 1); + return next; +} + +/** Move a span so it starts at `start`, keeping its length (calendar days for all-day rows). */ +function moveSpanTo(span: TimelineDragSpan, start: Date): TimelineDragSpan { + const endExclusive = new Date(start.getTime()); + + if (span.allDay) endExclusive.setDate(endExclusive.getDate() + calendarDaysBetween(span.start, span.endExclusive)); + else endExclusive.setTime(start.getTime() + (span.endExclusive.getTime() - span.start.getTime())); + + return { ...span, start, endExclusive }; +} + +/** + * Notion's "Shift only when dates overlap": every follower moves just far + * enough to start once the bars it depends on end, cascading through the + * follower chain. Followers whose dependencies did not move stay put. + */ +function resolveOverlaps( + movedRoot: TimelineDragSpan, + followers: TimelineDragSpan[], + avoidWeekends: boolean +): TimelineDragSpan[] { + const current = new Map([[movedRoot.rowId, movedRoot]]); + + followers.forEach((follower) => current.set(follower.rowId, follower)); + + // Followers arrive in breadth-first order, which is not always topological; + // iterate to a fixed point (bounded, so cycles terminate). + for (let pass = 0; pass <= followers.length; pass += 1) { + let changed = false; + + followers.forEach((follower) => { + const span = current.get(follower.rowId) ?? follower; + let required = 0; + + (follower.predecessors ?? []).forEach((predecessorId) => { + const predecessor = current.get(predecessorId); + + if (predecessor) required = Math.max(required, predecessor.endExclusive.getTime()); + }); + if (required <= span.start.getTime()) return; + let start = new Date(required); + + // An all-day follower starts on the first whole day after its dependency. + if (span.allDay && startOfDay(start).getTime() !== start.getTime()) { + start = startOfDay(start); + start.setDate(start.getDate() + 1); + } + + if (avoidWeekends) start = skipWeekend(start); + current.set(follower.rowId, moveSpanTo(span, start)); + changed = true; + }); + if (!changed) break; + } + + return followers.map((follower) => current.get(follower.rowId) ?? follower); +} + +function shiftFollowers( + geometry: TimelineGeometry, + drag: TimelineDragOrigin, + movedRoot: TimelineDragSpan, + deltaPx: number +): TimelineDragSpan[] { + const followers = drag.followers ?? []; + const shift = drag.shift ?? TimelineDependencyShift.OverlapOnly; + + if (followers.length === 0 || shift === TimelineDependencyShift.Never) return []; + if (shift === TimelineDependencyShift.OverlapOnly) + return resolveOverlaps(movedRoot, followers, drag.avoidWeekends === true); + + return followers.map((follower) => { + const shifted = shiftSpan(geometry, follower, deltaPx); + + return drag.avoidWeekends && isWeekend(shifted.start) ? moveSpanTo(shifted, skipWeekend(shifted.start)) : shifted; + }); +} + /** Pure delta application, exported for tests. */ export function applyDragDelta( geometry: TimelineGeometry, @@ -91,8 +201,9 @@ export function applyDragDelta( ): TimelineDragPreview { const { preset } = geometry; const snapMs = preset.snapMinutes * 60_000; - const followers = drag.followers ?? []; const base = { rowId: drag.rowId, mode: drag.mode, allDay: drag.allDay }; + // With shifting off a dependent may be dragged anywhere, as in Notion. + const minStart = drag.shift === TimelineDependencyShift.Never ? undefined : drag.minStart; if (drag.mode === 'progress') { const width = dateToX(geometry, drag.endExclusive) - dateToX(geometry, drag.start); @@ -106,24 +217,20 @@ export function applyDragDelta( let moved = shiftSpan(geometry, drag, deltaPx); let effectiveDelta = deltaPx; - if (drag.minStart && moved.start < drag.minStart) { + if (minStart && moved.start < minStart) { // Clamp to the dependency and re-derive the pixel delta so followers // keep the offset the bar actually travelled. - effectiveDelta = dateToX(geometry, drag.minStart) - dateToX(geometry, drag.start); + effectiveDelta = dateToX(geometry, minStart) - dateToX(geometry, drag.start); moved = shiftSpan(geometry, drag, effectiveDelta); } - return { - ...base, - ...moved, - followers: followers.map((follower) => shiftSpan(geometry, follower, effectiveDelta)), - }; + return { ...base, ...moved, followers: shiftFollowers(geometry, drag, moved, effectiveDelta) }; } if (drag.mode === 'resize-start') { let start = shiftDate(geometry, drag.start, deltaPx); - if (drag.minStart && start < drag.minStart) start = drag.minStart; + if (minStart && start < minStart) start = minStart; if (drag.endExclusive.getTime() - start.getTime() < snapMs) start = new Date(drag.endExclusive.getTime() - snapMs); return { ...base, start, endExclusive: drag.endExclusive, followers: [] }; @@ -133,13 +240,9 @@ export function applyDragDelta( if (endExclusive.getTime() - drag.start.getTime() < snapMs) endExclusive = new Date(drag.start.getTime() + snapMs); const effectiveDelta = dateToX(geometry, endExclusive) - dateToX(geometry, drag.endExclusive); + const resized = { rowId: drag.rowId, allDay: drag.allDay, start: drag.start, endExclusive }; - return { - ...base, - start: drag.start, - endExclusive, - followers: followers.map((follower) => shiftSpan(geometry, follower, effectiveDelta)), - }; + return { ...base, ...resized, followers: shiftFollowers(geometry, drag, resized, effectiveDelta) }; } function samePreview(a: TimelineDragPreview | null, b: TimelineDragPreview): boolean { diff --git a/src/components/database/timeline/hooks/useTimelineLinkDrag.ts b/src/components/database/timeline/hooks/useTimelineLinkDrag.ts new file mode 100644 index 000000000..b59403982 --- /dev/null +++ b/src/components/database/timeline/hooks/useTimelineLinkDrag.ts @@ -0,0 +1,121 @@ +/** + * Drag from a bar's link handle onto another bar to make the second depend on + * the first (Notion's drag-to-connect). The pointer is tracked in canvas + * coordinates so a connector can be drawn while dragging; the bar under the + * pointer is hit-tested through `data-timeline-bar` on each bar root. + */ +import { PointerEvent as ReactPointerEvent, useCallback, useEffect, useRef, useState } from 'react'; + +import { TIMELINE_HEADER_HEIGHT } from '../constants'; + +export interface TimelineLinkPoint { + x: number; + y: number; +} + +export interface TimelineLinkDrag { + sourceRowId: string; + /** Right edge of the source bar, in canvas coordinates. */ + from: TimelineLinkPoint; + /** Pointer position, in canvas coordinates. */ + to: TimelineLinkPoint; + /** Bar currently under the pointer, if any (never the source). */ + targetRowId: string | null; +} + +export interface UseTimelineLinkDragOptions { + scrollerRef: React.RefObject; + sidebarWidth: number; + onCommit: (sourceRowId: string, targetRowId: string) => void; +} + +function rowIdUnderPointer(clientX: number, clientY: number): string | null { + const element = document.elementFromPoint(clientX, clientY); + const bar = element instanceof Element ? element.closest('[data-timeline-bar]') : null; + + return bar?.dataset.timelineBar ?? null; +} + +export function useTimelineLinkDrag({ scrollerRef, sidebarWidth, onCommit }: UseTimelineLinkDragOptions) { + const [link, setLink] = useState(null); + const activeRef = useRef<{ sourceRowId: string; pointerId: number; from: TimelineLinkPoint } | null>(null); + + const toCanvas = useCallback( + (clientX: number, clientY: number): TimelineLinkPoint => { + const scroller = scrollerRef.current; + + if (!scroller) return { x: 0, y: 0 }; + const bounds = scroller.getBoundingClientRect(); + + return { + x: clientX - bounds.left + scroller.scrollLeft - sidebarWidth, + y: clientY - bounds.top + scroller.scrollTop - TIMELINE_HEADER_HEIGHT, + }; + }, + [scrollerRef, sidebarWidth] + ); + + useEffect(() => { + if (!link) return; + + const handleMove = (event: PointerEvent) => { + const active = activeRef.current; + + if (!active || event.pointerId !== active.pointerId) return; + const target = rowIdUnderPointer(event.clientX, event.clientY); + + setLink({ + sourceRowId: active.sourceRowId, + from: active.from, + to: toCanvas(event.clientX, event.clientY), + targetRowId: target && target !== active.sourceRowId ? target : null, + }); + }; + + const finish = (commit: boolean) => (event: PointerEvent) => { + const active = activeRef.current; + + if (!active || event.pointerId !== active.pointerId) return; + const target = commit ? rowIdUnderPointer(event.clientX, event.clientY) : null; + + activeRef.current = null; + setLink(null); + if (target && target !== active.sourceRowId) onCommit(active.sourceRowId, target); + }; + + const handleUp = finish(true); + const handleCancel = finish(false); + const handleKey = (event: KeyboardEvent) => { + if (event.key === 'Escape' && activeRef.current) { + event.preventDefault(); + activeRef.current = null; + setLink(null); + } + }; + + window.addEventListener('pointermove', handleMove, { passive: true }); + window.addEventListener('pointerup', handleUp); + window.addEventListener('pointercancel', handleCancel); + window.addEventListener('keydown', handleKey, true); + return () => { + window.removeEventListener('pointermove', handleMove); + window.removeEventListener('pointerup', handleUp); + window.removeEventListener('pointercancel', handleCancel); + window.removeEventListener('keydown', handleKey, true); + }; + }, [link, onCommit, toCanvas]); + + /** Begin on the handle's pointer-down; `from` is the source bar's right edge in canvas coordinates. */ + const startLink = useCallback( + (event: ReactPointerEvent, sourceRowId: string, from: TimelineLinkPoint) => { + if (event.button !== 0) return; + event.preventDefault(); + event.stopPropagation(); + activeRef.current = { sourceRowId, pointerId: event.pointerId, from }; + setLink({ sourceRowId, from, to: from, targetRowId: null }); + }, + [] + ); + + return { link, startLink }; +} From a25f72e7e33cdcb8785266629189bee120761aab Mon Sep 17 00:00:00 2001 From: Nathan Date: Sun, 13 Sep 2026 16:11:01 +0000 Subject: [PATCH 06/21] feat(timeline): separate start and end date fields Notion's "separate start and end dates": an optional End date field (`end_field_id`) in the timeline settings makes each bar run from the start field's date to the end field's date. A row whose end is missing or earlier than its start is a single-unit bar; a row without a start stays undated. Drags write the two cells as one undo group; moving a bar that has no end only moves its start, while the end handle creates the end date. Tests: a hook test for the merged selector (range, backwards, open, unbinding) and a BDD scenario covering bind, move, undo, resize and unbind. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NoEH4HcEiojEcioVSLE3QM --- .../bdd/features/database/timeline.feature | 15 ++ playwright/bdd/steps/timeline.steps.ts | 50 ++++++ .../timeline-events-selector.test.tsx | 147 ++++++++++++++++++ src/application/database-yjs/selector.ts | 30 +++- .../settings/TimelineLayoutSettings.tsx | 15 ++ .../database/timeline/TimelineView.tsx | 15 +- .../timeline/hooks/useTimelineRows.ts | 4 +- 7 files changed, 271 insertions(+), 5 deletions(-) create mode 100644 src/application/database-yjs/__tests__/timeline-events-selector.test.tsx diff --git a/playwright/bdd/features/database/timeline.feature b/playwright/bdd/features/database/timeline.feature index cd5538025..2506596dc 100644 --- a/playwright/bdd/features/database/timeline.feature +++ b/playwright/bdd/features/database/timeline.feature @@ -214,3 +214,18 @@ Feature: Timeline view interactions Then the table lists "Build, Design" in that order When I press undo Then the table lists "Design, Build" in that order + + Scenario: Separate start and end date fields plot one bar and are written together + Given a "Due" date field where "Design" is due in 3 days + When I choose "Due" as the timeline end date field + Then the "Design" bar spans 4 columns + When I drag the "Design" bar 1 columns later + Then the "Design" bar moved 1 columns later + And the "Design" bar spans 4 columns + When I press undo + Then the "Design" bar is back where it started + When I drag the end handle of "Design" 2 columns later + Then the "Design" bar spans 6 columns + And the "Design" due date is 5 days from today + When I choose no timeline end date field + Then the "Design" bar spans 1 columns diff --git a/playwright/bdd/steps/timeline.steps.ts b/playwright/bdd/steps/timeline.steps.ts index df2a79e11..b5790cd1c 100644 --- a/playwright/bdd/steps/timeline.steps.ts +++ b/playwright/bdd/steps/timeline.steps.ts @@ -509,6 +509,56 @@ Then('{string} depends on {string}', async ({ page }, dependent, dependency) => .toContain(dependencyId); }); +// --- Separate start and end date fields ------------------------------------ + +function localMidnightOffset(days: number): Date { + const date = new Date(); + + date.setHours(0, 0, 0, 0); + date.setDate(date.getDate() + days); + return date; +} + +Given('a {string} date field where {string} is due in {int} days', async ({ page }, name, title, days) => { + await injectFieldDirect(page, { fieldId: 'due', name, fieldType: FieldType.DateTime }); + await setTextCellDirect( + page, + rowId(page, title), + 'due', + FieldType.DateTime, + String(Math.floor(localMidnightOffset(days).getTime() / 1000)) + ); +}); + +When('I choose {string} as the timeline end date field', async ({ page }, _name) => { + await chooseTimelineSettingsOption(page, 'timeline-end-field-due'); +}); + +When('I choose no timeline end date field', async ({ page }) => { + await chooseTimelineSettingsOption(page, 'timeline-end-field-none'); +}); + +Then('the {string} bar spans {int} columns', async ({ page }, title, columns) => { + await expectBarWidth(page, title, columns * MONTH_COLUMN_WIDTH); +}); + +Then('the {string} due date is {int} days from today', async ({ page }, title, days) => { + const id = rowId(page, title); + const seconds = await page.evaluate( + async ({ id }) => { + const ctx = (window as unknown as { __TEST_DATABASE_CONTEXT__: any }).__TEST_DATABASE_CONTEXT__; + const rowDoc = ctx.rowMap?.[id] ?? (await ctx.ensureRow(id)); + + return Number(rowDoc.getMap('data').get('data').get('cells').get('due')?.get('data')); + }, + { id } + ); + const stored = new Date(seconds * 1000); + + stored.setHours(0, 0, 0, 0); + expect(stored.getTime()).toBe(localMidnightOffset(days).getTime()); +}); + Then('the timeline draws {int} dependency arrow', async ({ page }, count) => { await expect(TimelineSelectors.arrows(page)).toHaveCount(count, { timeout: 15_000 }); }); diff --git a/src/application/database-yjs/__tests__/timeline-events-selector.test.tsx b/src/application/database-yjs/__tests__/timeline-events-selector.test.tsx new file mode 100644 index 000000000..ddbe3d9b5 --- /dev/null +++ b/src/application/database-yjs/__tests__/timeline-events-selector.test.tsx @@ -0,0 +1,147 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import type React from 'react'; +import * as Y from 'yjs'; + +import { DatabaseContext, DatabaseContextState, FieldType, useTimelineEventsSelector } from '@/application/database-yjs'; +import { + YDatabase, + YDatabaseField, + YDatabaseFields, + YDatabaseRowOrders, + YDatabaseSorts, + YDatabaseView, + YDatabaseViews, + YDoc, + YjsDatabaseKey, + YjsEditorKey, +} from '@/application/types'; +import { AFConfigContext } from '@/components/main/app.hooks'; + +import { createRowDoc } from './test-helpers'; + +jest.mock('@/utils/runtime-config', () => ({ + getConfigValue: (_key: string, fallback: string) => fallback, +})); + +const databaseId = 'database-id'; +const viewId = 'view-id'; +const START = 'start-field'; +const END = 'end-field'; +const PRIMARY = 'primary-field'; +const DAY = 86_400; +const jan2 = Math.floor(new Date(2025, 0, 2).getTime() / 1000); + +function dateField(id: string, name: string) { + const field = new Y.Map() as YDatabaseField; + + field.set(YjsDatabaseKey.id, id); + field.set(YjsDatabaseKey.name, name); + field.set(YjsDatabaseKey.type, FieldType.DateTime); + return field; +} + +/** Three rows: a proper range, an end before its start, and no end at all. */ +function createFixture() { + const databaseDoc = new Y.Doc({ guid: databaseId }) as YDoc; + const sharedRoot = databaseDoc.getMap(YjsEditorKey.data_section); + const database = new Y.Map() as YDatabase; + const fields = new Y.Map() as YDatabaseFields; + const views = new Y.Map() as YDatabaseViews; + const view = new Y.Map() as YDatabaseView; + const rowOrders = new Y.Array<{ id: string; height: number }>() as YDatabaseRowOrders; + const layoutSettings = new Y.Map(); + const timelineSettings = new Y.Map(); + const primaryField = new Y.Map() as YDatabaseField; + + primaryField.set(YjsDatabaseKey.id, PRIMARY); + primaryField.set(YjsDatabaseKey.name, 'Name'); + primaryField.set(YjsDatabaseKey.type, FieldType.RichText); + primaryField.set(YjsDatabaseKey.is_primary, true); + + timelineSettings.set(YjsDatabaseKey.field_id, START); + timelineSettings.set(YjsDatabaseKey.end_field_id, END); + layoutSettings.set('8', timelineSettings); + rowOrders.push([ + { id: 'range', height: 36 }, + { id: 'backwards', height: 36 }, + { id: 'open', height: 36 }, + ]); + view.set(YjsDatabaseKey.row_orders, rowOrders); + view.set(YjsDatabaseKey.filters, new Y.Array()); + view.set(YjsDatabaseKey.sorts, new Y.Array() as YDatabaseSorts); + view.set(YjsDatabaseKey.layout_settings, layoutSettings); + fields.set(START, dateField(START, 'Start')); + fields.set(END, dateField(END, 'End')); + fields.set(PRIMARY, primaryField); + views.set(viewId, view); + database.set(YjsDatabaseKey.id, databaseId); + database.set(YjsDatabaseKey.fields, fields); + database.set(YjsDatabaseKey.views, views); + sharedRoot.set(YjsEditorKey.database, database); + + const rowMap = { + range: createRowDoc('range', databaseId, { + [START]: { fieldType: FieldType.DateTime, data: String(jan2) }, + [END]: { fieldType: FieldType.DateTime, data: String(jan2 + 3 * DAY) }, + [PRIMARY]: { fieldType: FieldType.RichText, data: 'Range' }, + }), + backwards: createRowDoc('backwards', databaseId, { + [START]: { fieldType: FieldType.DateTime, data: String(jan2) }, + [END]: { fieldType: FieldType.DateTime, data: String(jan2 - DAY) }, + [PRIMARY]: { fieldType: FieldType.RichText, data: 'Backwards' }, + }), + open: createRowDoc('open', databaseId, { + [START]: { fieldType: FieldType.DateTime, data: String(jan2) }, + [PRIMARY]: { fieldType: FieldType.RichText, data: 'Open' }, + }), + }; + const contextValue = { + readOnly: false, + databaseDoc, + databasePageId: viewId, + activeViewId: viewId, + rowMap, + workspaceId: 'workspace-id', + } as DatabaseContextState; + const wrapper = ({ children }: { children: React.ReactNode }) => ( + undefined, openLoginModal: () => undefined }} + > + {children} + + ); + + return { wrapper, timelineSettings, databaseDoc }; +} + +describe('useTimelineEventsSelector with separate start and end fields', () => { + it('ends each bar at the end field, ignoring ends before the start or missing', async () => { + const { wrapper } = createFixture(); + const { result } = renderHook(() => useTimelineEventsSelector(), { wrapper }); + + await waitFor(() => expect(result.current.events).toHaveLength(3)); + expect(result.current.hasEndField).toBe(true); + const byId = new Map(result.current.events.map((event) => [event.rowId, event])); + + expect(byId.get('range')).toMatchObject({ isRange: true }); + expect(byId.get('range')?.end?.getTime()).toBe((jan2 + 3 * DAY) * 1000); + expect(byId.get('backwards')).toMatchObject({ isRange: false, end: undefined }); + expect(byId.get('open')).toMatchObject({ isRange: false, end: undefined }); + }); + + it('falls back to the start field alone once the end field is unbound', async () => { + const { wrapper, timelineSettings, databaseDoc } = createFixture(); + const { result } = renderHook(() => useTimelineEventsSelector(), { wrapper }); + + await waitFor(() => expect(result.current.hasEndField).toBe(true)); + act(() => { + databaseDoc.transact(() => timelineSettings.delete(YjsDatabaseKey.end_field_id)); + }); + await waitFor(() => expect(result.current.hasEndField).toBe(false)); + const range = result.current.events.find((event) => event.rowId === 'range'); + + // Without an end field a single-date cell is the synthetic 30-minute event. + expect(range?.isRange).toBe(false); + expect(range?.end?.getTime()).toBe(jan2 * 1000 + 30 * 60_000); + }); +}); diff --git a/src/application/database-yjs/selector.ts b/src/application/database-yjs/selector.ts index bd36d02a9..db3f0e6b8 100644 --- a/src/application/database-yjs/selector.ts +++ b/src/application/database-yjs/selector.ts @@ -3121,10 +3121,38 @@ export function useCalendarEventsSelector() { return useDateFieldEventsSelector(setting?.fieldId || ''); } +/** + * Rows plotted on the timeline. With Notion's "separate start and end dates" + * (`endFieldId` set) each bar runs from the start field's date to the end + * field's date; a row whose end is missing or earlier than its start is a + * single-unit bar, and a row without a start is undated. + */ export function useTimelineEventsSelector() { const setting = useTimelineLayoutSetting(); + const startFieldId = setting?.fieldId || ''; + const endFieldId = setting?.endFieldId && setting.endFieldId !== startFieldId ? setting.endFieldId : ''; + const starts = useDateFieldEventsSelector(startFieldId); + const ends = useDateFieldEventsSelector(endFieldId); + const { field: endField } = useFieldSelector(endFieldId); + const endFieldType = endField ? (Number(endField.get(YjsDatabaseKey.type)) as FieldType) : null; + const hasEndField = + endFieldId !== '' && + endFieldType !== null && + [FieldType.DateTime, FieldType.LastEditedTime, FieldType.CreatedTime].includes(endFieldType); + + const events = useMemo(() => { + if (!hasEndField) return starts.events; + const endByRow = new Map(ends.events.map((event) => [event.rowId, event] as const)); + + return starts.events.map((event) => { + const end = endByRow.get(event.rowId)?.start; + + if (!end || !event.start || end < event.start) return { ...event, end: undefined, isRange: false }; + return { ...event, end, isRange: true }; + }); + }, [ends.events, hasEndField, starts.events]); - return useDateFieldEventsSelector(setting?.fieldId || ''); + return { events, emptyEvents: starts.emptyEvents, hasEndField }; } /** diff --git a/src/components/database/components/settings/TimelineLayoutSettings.tsx b/src/components/database/components/settings/TimelineLayoutSettings.tsx index bb5b1ca4c..c5b57ccf5 100644 --- a/src/components/database/components/settings/TimelineLayoutSettings.tsx +++ b/src/components/database/components/settings/TimelineLayoutSettings.tsx @@ -57,6 +57,11 @@ function TimelineLayoutSettings() { () => allProperties.filter((property) => DATE_FIELD_TYPES.includes(property.type)), [allProperties] ); + // Notion's "separate start and end dates": any other date field can end the bar. + const endDateProperties = useMemo( + () => dateProperties.filter((property) => property.id !== setting.fieldId), + [dateProperties, setting.fieldId] + ); // Only relations that point back at this database can express dependencies. const dependencyProperties = useMemo( () => @@ -147,6 +152,16 @@ function TimelineLayoutSettings() { + {renderOptionalField( + t('timeline.settings.endDateField', { defaultValue: 'End date' }), + 'timeline-end-field', + endDateProperties, + setting.endFieldId, + (endFieldId) => updateSetting({ endFieldId }) + )} + + + { const history = historyGroup ? { historyGroup } : undefined; + if (hasEndField) { + // Separate start and end fields: the start cell and the end cell each + // hold a single date, written as one undo group. + const group = history ?? { historyGroup: {} }; + const end = allDay ? correctAllDayEndForStorage(endExclusive) : endExclusive; + + updateStartEnd(rowId, setting.fieldId, dateToUnixTimestamp(start), undefined, allDay, group); + if (!keepSingle) updateStartEnd(rowId, setting.endFieldId, dateToUnixTimestamp(end), undefined, allDay, group); + return; + } + if (allDay) { const singleDay = calendarDaysBetween(start, endExclusive) <= 1; const end = singleDay ? undefined : dateToUnixTimestamp(correctAllDayEndForStorage(endExclusive)); @@ -221,7 +232,7 @@ export function TimelineView({ setting }: { setting: TimelineLayoutSetting }) { history ); }, - [setting.fieldId, updateStartEnd] + [hasEndField, setting.endFieldId, setting.fieldId, updateStartEnd] ); const rowsRef = useRef(rows); diff --git a/src/components/database/timeline/hooks/useTimelineRows.ts b/src/components/database/timeline/hooks/useTimelineRows.ts index 57115acc6..61a6615dd 100644 --- a/src/components/database/timeline/hooks/useTimelineRows.ts +++ b/src/components/database/timeline/hooks/useTimelineRows.ts @@ -21,7 +21,7 @@ export interface TimelineRowModel { */ export function useTimelineRows(includeUndated: boolean) { const rowOrders = useRowOrdersSelector(); - const { events, emptyEvents } = useTimelineEventsSelector(); + const { events, emptyEvents, hasEndField } = useTimelineEventsSelector(); const rows = useMemo(() => { const byRowId = new Map(); @@ -57,5 +57,5 @@ export function useTimelineRows(includeUndated: boolean) { return result; }, [events, emptyEvents, includeUndated, rowOrders]); - return { rows, emptyEvents, rowOrders: rowOrders ?? EMPTY_ROW_ORDERS }; + return { rows, emptyEvents, rowOrders: rowOrders ?? EMPTY_ROW_ORDERS, hasEndField }; } From b907c29d287e30510e9ce143937855a5dffc5c32 Mon Sep 17 00:00:00 2001 From: Nathan Date: Sun, 13 Sep 2026 16:25:19 +0000 Subject: [PATCH 07/21] feat(timeline): table properties columns and calculations footer Notion's table properties: a "Table properties" submenu in the timeline settings picks which properties appear as columns of the docked table (`table_field_ids`, kept in the view's property order, separate from the bar's chips). Each column is 140px, headed by the field's icon and name, and rendered with the same CardField cells the board uses. A calculations footer under the table offers the grid's calculation menu per column. GridCalculateRowCell now accepts explicit row orders so it works outside the grid provider. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NoEH4HcEiojEcioVSLE3QM --- .../bdd/features/database/timeline.feature | 10 +++ playwright/bdd/steps/timeline.steps.ts | 66 +++++++++++++++++++ playwright/support/selectors.ts | 10 +-- src/@types/translations/en.json | 1 + .../__tests__/timeline-layout.test.ts | 7 ++ src/application/database-yjs/database.type.ts | 2 + .../database-yjs/timeline-layout.ts | 22 ++++++- src/application/types.ts | 3 + .../grid/grid-cell/GridCalculateRowCell.tsx | 13 ++-- .../settings/TimelineLayoutSettings.tsx | 47 +++++++++++++ .../database/timeline/TimelineRow.tsx | 4 ++ .../database/timeline/TimelineSidebarRow.tsx | 17 ++++- .../database/timeline/TimelineView.tsx | 66 +++++++++++++++++-- src/components/database/timeline/constants.ts | 2 + 14 files changed, 255 insertions(+), 15 deletions(-) diff --git a/playwright/bdd/features/database/timeline.feature b/playwright/bdd/features/database/timeline.feature index 2506596dc..2c1542d48 100644 --- a/playwright/bdd/features/database/timeline.feature +++ b/playwright/bdd/features/database/timeline.feature @@ -229,3 +229,13 @@ Feature: Timeline view interactions And the "Design" due date is 5 days from today When I choose no timeline end date field Then the "Design" bar spans 1 columns + + Scenario: Table properties add columns with a calculations footer + Given "Design" has a progress field at 40 percent + When I show "Progress" as a table column + Then the table has a "Progress" column reading 40 for "Design" + And the docked table is 140 px wider + When I set the "Progress" column calculation to "Sum" + Then the "Progress" column calculation reads "Sum40" + When I hide the "Progress" table column + Then the table has no "Progress" column diff --git a/playwright/bdd/steps/timeline.steps.ts b/playwright/bdd/steps/timeline.steps.ts index b5790cd1c..9cf904dca 100644 --- a/playwright/bdd/steps/timeline.steps.ts +++ b/playwright/bdd/steps/timeline.steps.ts @@ -59,6 +59,8 @@ interface TimelineScenario { before: Map; /** Columns "Design" was dragged in the avoid-weekends scenario. */ weekendShift?: number; + /** Docked table width before a table column was added. */ + sidebarWidthBefore?: number; } const scenarios = new WeakMap(); @@ -559,6 +561,70 @@ Then('the {string} due date is {int} days from today', async ({ page }, title, d expect(stored.getTime()).toBe(localMidnightOffset(days).getTime()); }); +// --- Table properties and calculations -------------------------------------- + +const TABLE_FIELD_ID: Record = { Progress: 'num-progress', Due: 'due' }; + +async function toggleTableColumn(page: Page, name: string) { + await page.getByTestId('database-actions-settings').click(); + const settingsTrigger = TimelineSelectors.settingsTrigger(page); + + await settingsTrigger.click(); + const nestedTrigger = page.getByTestId('timeline-table-properties-trigger'); + const from = await settingsTrigger.boundingBox(); + const to = await nestedTrigger.boundingBox(); + + if (!from || !to) throw new Error('Timeline settings menu is not open'); + // Travel Radix's grace area instead of teleporting the pointer, which would + // close the settings submenu before the nested trigger can open. + await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2); + await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2, { steps: 25 }); + await nestedTrigger.click(); + await page.getByTestId(`timeline-table-field-${TABLE_FIELD_ID[name]}`).click(); + await page.keyboard.press('Escape'); + await page.keyboard.press('Escape'); + await page.keyboard.press('Escape'); +} + +When('I show {string} as a table column', async ({ page }, name) => { + scenario(page).sidebarWidthBefore = (await TimelineSelectors.sidebarCells(page).first().boundingBox())?.width; + await toggleTableColumn(page, name); +}); + +When('I hide the {string} table column', async ({ page }, name) => { + await toggleTableColumn(page, name); +}); + +Then('the table has a {string} column reading {int} for {string}', async ({ page }, name, value, title) => { + const fieldId = TABLE_FIELD_ID[name]; + + await expect(page.getByTestId(`timeline-table-header-${fieldId}`)).toContainText(name); + await expect(page.getByTestId(`timeline-table-cell-${rowId(page, title)}-${fieldId}`)).toContainText(String(value)); +}); + +Then('the docked table is {int} px wider', async ({ page }, delta) => { + const before = scenario(page).sidebarWidthBefore ?? 0; + + await expect + .poll(async () => (await TimelineSelectors.sidebarCells(page).first().boundingBox())?.width ?? 0) + .toBe(before + delta); +}); + +When('I set the {string} column calculation to {string}', async ({ page }, name, calculation) => { + await page.getByTestId(`timeline-calculation-${TABLE_FIELD_ID[name]}`).click(); + await page.getByRole('menuitem', { name: calculation, exact: true }).click(); +}); + +Then('the {string} column calculation reads {string}', async ({ page }, name, text) => { + await expect(page.getByTestId(`timeline-calculation-${TABLE_FIELD_ID[name]}`)).toContainText(text, { + timeout: 10_000, + }); +}); + +Then('the table has no {string} column', async ({ page }, name) => { + await expect(page.getByTestId(`timeline-table-header-${TABLE_FIELD_ID[name]}`)).toHaveCount(0); +}); + Then('the timeline draws {int} dependency arrow', async ({ page }, count) => { await expect(TimelineSelectors.arrows(page)).toHaveCount(count, { timeout: 15_000 }); }); diff --git a/playwright/support/selectors.ts b/playwright/support/selectors.ts index 1d302890a..f4ef10015 100644 --- a/playwright/support/selectors.ts +++ b/playwright/support/selectors.ts @@ -338,8 +338,7 @@ export const DatabaseFeedSelectors = { creatorByRowId: (page: Page, rowId: string) => page.getByTestId(`feed-card-creator-${rowId}`), coverByRowId: (page: Page, rowId: string) => page.getByTestId(`feed-card-cover-${rowId}`), documentPreviewByRowId: (page: Page, rowId: string) => page.getByTestId(`feed-document-preview-${rowId}`), - documentPreviewToggleByRowId: (page: Page, rowId: string) => - page.getByTestId(`feed-document-preview-toggle-${rowId}`), + documentPreviewToggleByRowId: (page: Page, rowId: string) => page.getByTestId(`feed-document-preview-toggle-${rowId}`), actionsByRowId: (page: Page, rowId: string) => page.getByTestId(`feed-card-actions-${rowId}`), moreButtonByRowId: (page: Page, rowId: string) => page.getByTestId(`feed-card-more-${rowId}`), reactionButtonByRowId: (page: Page, rowId: string) => page.getByTestId(`feed-card-reaction-button-${rowId}`), @@ -757,7 +756,8 @@ export const CalendarSelectors = { monthViewOption: (page: Page) => page.getByRole('menuitemradio', { name: /^Month(?:\s|$)/ }), weekViewOption: (page: Page) => page.getByRole('menuitemradio', { name: /^Week(?:\s|$)/ }), numberOfDaysMenu: (page: Page) => page.getByRole('menuitem', { name: 'Number of days', exact: true }), - customDayOption: (page: Page, days: number) => page.getByRole('menuitemradio', { name: new RegExp(`^${days} days(?:\\s|$)`) }), + customDayOption: (page: Page, days: number) => + page.getByRole('menuitemradio', { name: new RegExp(`^${days} days(?:\\s|$)`) }), title: (page: Page) => page.getByTestId('calendar-title'), dayCell: (page: Page) => page.locator('.fc-daygrid-day'), dayCellByDate: (page: Page, dateStr: string) => page.locator(`[data-date="${dateStr}"]`), @@ -881,7 +881,8 @@ export const TimelineSelectors = { todayLine: (page: Page) => page.getByTestId('timeline-today-line'), bars: (page: Page) => page.locator('[data-testid^="timeline-bar-"]'), bar: (page: Page, rowId: string) => page.getByTestId(`timeline-bar-${rowId}`), - barByTitle: (page: Page, title: string) => page.locator('[data-testid^="timeline-bar-"]').filter({ hasText: title }).first(), + barByTitle: (page: Page, title: string) => + page.locator('[data-testid^="timeline-bar-"]').filter({ hasText: title }).first(), barButton: (page: Page, title: string) => page.locator('[data-testid^="timeline-bar-"]').filter({ hasText: title }).first().locator('[role="button"]'), handleStart: (page: Page, rowId: string) => page.getByTestId(`timeline-handle-start-${rowId}`), @@ -892,6 +893,7 @@ export const TimelineSelectors = { hoverCard: (page: Page) => page.getByRole('tooltip').getByTestId('timeline-bar-hover-card'), row: (page: Page, rowId: string) => page.getByTestId(`timeline-row-${rowId}`), sidebarRows: (page: Page) => page.locator('[data-testid^="timeline-sidebar-row-"]'), + sidebarCells: (page: Page) => page.locator('[data-testid^="timeline-sidebar-cell-"]'), sidebarRow: (page: Page, rowId: string) => page.getByTestId(`timeline-sidebar-row-${rowId}`), openRow: (page: Page, rowId: string) => page.getByTestId(`timeline-open-row-${rowId}`), emptyRows: (page: Page) => page.locator('[data-testid^="timeline-row-empty-"]'), diff --git a/src/@types/translations/en.json b/src/@types/translations/en.json index d5a70d53b..b845adf9c 100644 --- a/src/@types/translations/en.json +++ b/src/@types/translations/en.json @@ -4367,6 +4367,7 @@ "shiftNever": "Never", "avoidWeekends": "Avoid weekends", "endDateField": "End date", + "tableProperties": "Table properties", "progress": "Progress" }, "zoom": { diff --git a/src/application/database-yjs/__tests__/timeline-layout.test.ts b/src/application/database-yjs/__tests__/timeline-layout.test.ts index 1d6c1e8a9..ef1dd9cf3 100644 --- a/src/application/database-yjs/__tests__/timeline-layout.test.ts +++ b/src/application/database-yjs/__tests__/timeline-layout.test.ts @@ -41,6 +41,7 @@ test('missing setting falls back to month scale, docked table, and the user week dependencyShift: TimelineDependencyShift.OverlapOnly, avoidWeekends: false, progressFieldId: '', + tableFieldIds: [], use24Hour: false, }); }); @@ -89,6 +90,7 @@ test('integers written by the server as BigInt decode like web numbers', () => { dependencyShift: TimelineDependencyShift.OverlapOnly, avoidWeekends: false, progressFieldId: '', + tableFieldIds: [], use24Hour: false, }); }); @@ -133,6 +135,10 @@ test('dependency shift, avoid-weekends and the end field round-trip like the cal updateTimelineLayoutSetting(view, { endFieldId: '', dependencyShift: 99 as TimelineDependencyShift }) ); expect(setting.has(YjsDatabaseKey.end_field_id)).toBe(false); + doc.transact(() => updateTimelineLayoutSetting(view, { tableFieldIds: ['num', 'sel'] })); + expect(readTimelineLayoutSetting(database, 'timeline', 0, false).tableFieldIds).toEqual(['num', 'sel']); + doc.transact(() => updateTimelineLayoutSetting(view, { tableFieldIds: [] })); + expect(setting.has(YjsDatabaseKey.table_field_ids)).toBe(false); // Out-of-range wire values fall back to Notion's default. expect(readTimelineLayoutSetting(database, 'timeline', 0, false).dependencyShift).toBe( TimelineDependencyShift.OverlapOnly @@ -169,6 +175,7 @@ test('the store notifies on remote changes only for this view and tolerates bad dependencyShift: TimelineDependencyShift.OverlapOnly, avoidWeekends: false, progressFieldId: '', + tableFieldIds: [], use24Hour: false, }); diff --git a/src/application/database-yjs/database.type.ts b/src/application/database-yjs/database.type.ts index f7ca91885..7e3d85aec 100644 --- a/src/application/database-yjs/database.type.ts +++ b/src/application/database-yjs/database.type.ts @@ -148,6 +148,8 @@ export interface TimelineLayoutSetting { avoidWeekends: boolean; /// Number field holding 0–100 progress drawn as a fill inside the bar. progressFieldId: string; + /// Properties shown as columns of the docked table, in order (separate from bar chips). + tableFieldIds: string[]; } export interface CalendarLayoutSetting { diff --git a/src/application/database-yjs/timeline-layout.ts b/src/application/database-yjs/timeline-layout.ts index fc696926c..388766933 100644 --- a/src/application/database-yjs/timeline-layout.ts +++ b/src/application/database-yjs/timeline-layout.ts @@ -18,6 +18,16 @@ export const DEFAULT_TIMELINE_LAYOUT = TimelineLayout.Month; export const DEFAULT_TIMELINE_SHOW_TABLE = true; export const DEFAULT_TIMELINE_DEPENDENCY_SHIFT = TimelineDependencyShift.OverlapOnly; +const EMPTY_IDS: string[] = []; + +/** A plain array of ids as Yjs / Yrs hand it back, or nothing. */ +function idList(value: unknown): string[] { + if (!Array.isArray(value)) return EMPTY_IDS; + const ids = value.filter((id): id is string => typeof id === 'string' && id !== ''); + + return ids.length === 0 ? EMPTY_IDS : ids; +} + function integer(value: unknown, min: number, max: number): number | undefined { if (typeof value !== 'number' && typeof value !== 'bigint') return undefined; const number = Number(value); @@ -64,6 +74,7 @@ export function readTimelineLayoutSetting( dependencyShift: dependencyShift ?? DEFAULT_TIMELINE_DEPENDENCY_SHIFT, avoidWeekends: typeof avoidWeekends === 'boolean' ? avoidWeekends : false, progressFieldId: setting?.get(YjsDatabaseKey.progress_field_id) ?? '', + tableFieldIds: idList(setting?.get(YjsDatabaseKey.table_field_ids)), }; } @@ -116,6 +127,10 @@ export function updateTimelineLayoutSetting(view: YDatabaseView, settings: Timel if (settings.dependencyShift !== undefined) setting.set(YjsDatabaseKey.dependency_shift_ty, settings.dependencyShift); if (settings.avoidWeekends !== undefined) setting.set(YjsDatabaseKey.avoid_weekends, settings.avoidWeekends); + if (settings.tableFieldIds !== undefined) { + if (settings.tableFieldIds.length > 0) setting.set(YjsDatabaseKey.table_field_ids, [...settings.tableFieldIds]); + else setting.delete(YjsDatabaseKey.table_field_ids); + } } /** @@ -155,10 +170,15 @@ export function createTimelineLayoutStore( use24Hour ); let snapshot = read(); + const sameIds = (a: string[], b: string[]) => a.length === b.length && a.every((id, index) => id === b[index]); const getSnapshot = () => { const next = read(); - if ((Object.keys(next) as (keyof TimelineLayoutSetting)[]).some((key) => next[key] !== snapshot[key])) + if ( + (Object.keys(next) as (keyof TimelineLayoutSetting)[]).some((key) => + key === 'tableFieldIds' ? !sameIds(next.tableFieldIds, snapshot.tableFieldIds) : next[key] !== snapshot[key] + ) + ) snapshot = next; return snapshot; }; diff --git a/src/application/types.ts b/src/application/types.ts index 90323f1fd..617cc38dd 100644 --- a/src/application/types.ts +++ b/src/application/types.ts @@ -674,6 +674,8 @@ export enum YjsDatabaseKey { dependency_shift_ty = 'dependency_shift_ty', /// Timeline layout setting: shifted dependents skip Saturdays and Sundays. avoid_weekends = 'avoid_weekends', + /// Timeline layout setting: properties shown as columns of the docked table. + table_field_ids = 'table_field_ids', icon = 'icon', is_inline = 'is_inline', embedded = 'embedded', @@ -1036,6 +1038,7 @@ export interface YDatabaseTimelineLayoutSetting extends Y.Map { | YjsDatabaseKey.dependency_shift_ty ): number | bigint | null | undefined; get(key: YjsDatabaseKey.show_table | YjsDatabaseKey.avoid_weekends): boolean | undefined; + get(key: YjsDatabaseKey.table_field_ids): unknown; } export interface YDatabaseChartLayoutSetting extends Y.Map { diff --git a/src/components/database/components/grid/grid-cell/GridCalculateRowCell.tsx b/src/components/database/components/grid/grid-cell/GridCalculateRowCell.tsx index e7fc34bb5..6fd3dd63a 100644 --- a/src/components/database/components/grid/grid-cell/GridCalculateRowCell.tsx +++ b/src/components/database/components/grid/grid-cell/GridCalculateRowCell.tsx @@ -1,26 +1,29 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useContext, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { useDatabaseView, useFieldCellsByRowsSelector, useReadOnly } from '@/application/database-yjs'; +import { Row, useDatabaseView, useFieldCellsByRowsSelector, useReadOnly } from '@/application/database-yjs'; import { CalculationType } from '@/application/database-yjs/database.type'; import { useCalculateFieldDispatch, useClearCalculate, useUpdateCalculate } from '@/application/database-yjs/dispatch'; import { YjsDatabaseKey } from '@/application/types'; import { ReactComponent as DropdownIcon } from '@/assets/icons/alt_arrow_down.svg'; import { CalculationCell, ICalculationCell } from '@/components/database/components/grid/grid-calculation-cell'; import CalcationMenu from '@/components/database/components/grid/grid-calculation-cell/CalcationMenu'; -import { useGridContext } from '@/components/database/grid/useGridContext'; +import { GridContext } from '@/components/database/grid/useGridContext'; import { cn } from '@/lib/utils'; export interface GridCalculateRowCellProps { fieldId: string; + /** Rows to calculate over; defaults to the surrounding grid's rows (the timeline passes its own). */ + rowOrders?: Row[]; } -export function GridCalculateRowCell ({ fieldId }: GridCalculateRowCellProps) { +export function GridCalculateRowCell ({ fieldId, rowOrders: rowOrdersProp }: GridCalculateRowCellProps) { const databaseView = useDatabaseView(); const [calculation, setCalculation] = useState(); const readOnly = useReadOnly(); const calculate = useCalculateFieldDispatch(fieldId); - const { rowOrders } = useGridContext(); + const gridRowOrders = useContext(GridContext)?.rowOrders; + const rowOrders = rowOrdersProp ?? gridRowOrders; const { cells } = useFieldCellsByRowsSelector(fieldId, rowOrders); const calculations = databaseView?.get(YjsDatabaseKey.calculations); diff --git a/src/components/database/components/settings/TimelineLayoutSettings.tsx b/src/components/database/components/settings/TimelineLayoutSettings.tsx index c5b57ccf5..4d8b28a81 100644 --- a/src/components/database/components/settings/TimelineLayoutSettings.tsx +++ b/src/components/database/components/settings/TimelineLayoutSettings.tsx @@ -8,6 +8,7 @@ import { TimelineDependencyShift, useDatabase, useDatabaseFields, + usePrimaryFieldId, usePropertiesSelector, useTimelineLayoutSetting, } from '@/application/database-yjs'; @@ -57,6 +58,12 @@ function TimelineLayoutSettings() { () => allProperties.filter((property) => DATE_FIELD_TYPES.includes(property.type)), [allProperties] ); + // Every non-primary property may become a table column (the title is always the first column). + const primaryFieldId = usePrimaryFieldId(); + const tableProperties = useMemo( + () => allProperties.filter((property) => property.id !== primaryFieldId), + [allProperties, primaryFieldId] + ); // Notion's "separate start and end dates": any other date field can end the bar. const endDateProperties = useMemo( () => dateProperties.filter((property) => property.id !== setting.fieldId), @@ -174,6 +181,46 @@ function TimelineLayoutSettings() { + {/* Notion configures the table's columns separately from the bar's properties. */} + + + {t('timeline.settings.tableProperties', { defaultValue: 'Table properties' })} + {setting.tableFieldIds.length} + + + + {tableProperties.map((property) => { + const shown = setting.tableFieldIds.includes(property.id); + + return ( + { + e.preventDefault(); + updateSetting({ + tableFieldIds: shown + ? setting.tableFieldIds.filter((id) => id !== property.id) + : // Keep the view's property order rather than click order. + tableProperties + .filter( + (candidate) => + candidate.id === property.id || setting.tableFieldIds.includes(candidate.id) + ) + .map((candidate) => candidate.id), + }); + }} + > + + + + ); + })} + + + + {renderOptionalField( diff --git a/src/components/database/timeline/TimelineRow.tsx b/src/components/database/timeline/TimelineRow.tsx index 655befb18..b47c68132 100644 --- a/src/components/database/timeline/TimelineRow.tsx +++ b/src/components/database/timeline/TimelineRow.tsx @@ -36,6 +36,8 @@ interface TimelineRowProps { formatTime: (date: Date) => string; /** View-ordered rows for the table's insert / reorder actions. */ rowOrders: Row[]; + /** Properties shown as table columns after the title. */ + tableFieldIds: string[]; onOpen?: (rowId: string) => void; onSelect?: (rowId: string | null) => void; onScrollTo?: (x: number) => void; @@ -100,6 +102,7 @@ export const TimelineRow = memo( anyDragging, formatTime, rowOrders, + tableFieldIds, onOpen, onSelect, onScrollTo, @@ -149,6 +152,7 @@ export const TimelineRow = memo( editable={editable} selected={selected} rowOrders={rowOrders} + tableFieldIds={tableFieldIds} dropTargetRef={rowRef} onOpen={onOpen} onSelect={onSelect} diff --git a/src/components/database/timeline/TimelineSidebarRow.tsx b/src/components/database/timeline/TimelineSidebarRow.tsx index 1f25bb884..dc78d6e4b 100644 --- a/src/components/database/timeline/TimelineSidebarRow.tsx +++ b/src/components/database/timeline/TimelineSidebarRow.tsx @@ -5,6 +5,7 @@ import { Row, useRowMetaSelector } from '@/application/database-yjs'; import { ReactComponent as ExpandIcon } from '@/assets/icons/expand.svg'; import { DropRowIndicator } from '@/components/database/components/drag-and-drop/DropRowIndicator'; import { type Edge, useRowDnd } from '@/components/database/components/drag-and-drop/useRowDnd'; +import { CardField } from '@/components/database/components/field/CardField'; import { GalleryRowIcon } from '@/components/database/gallery/GalleryRowIcon'; import { ListRowActions } from '@/components/database/list/ListRowActions'; import { useListHasSorts } from '@/components/database/list/ListSortState'; @@ -12,6 +13,7 @@ import { Button } from '@/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { cn } from '@/lib/utils'; +import { TIMELINE_TABLE_COLUMN_WIDTH } from './constants'; import { TimelineRowModel } from './hooks/useTimelineRows'; export const TIMELINE_ROW_DRAG_TYPE = 'database-timeline-row'; @@ -23,6 +25,8 @@ interface TimelineSidebarRowProps { selected?: boolean; /** View-ordered rows, needed by "insert above". */ rowOrders: Row[]; + /** Properties shown as columns after the title. */ + tableFieldIds: string[]; /** The whole timeline row, so a drop anywhere along it counts. */ dropTargetRef: MutableRefObject; onOpen?: (rowId: string) => void; @@ -42,6 +46,7 @@ export const TimelineSidebarRow = memo( editable, selected, rowOrders, + tableFieldIds, dropTargetRef, onOpen, onSelect, @@ -90,7 +95,7 @@ export const TimelineSidebarRow = memo( )}
diff --git a/src/components/database/timeline/TimelineView.tsx b/src/components/database/timeline/TimelineView.tsx index 4c910fcc0..d8429fc8c 100644 --- a/src/components/database/timeline/TimelineView.tsx +++ b/src/components/database/timeline/TimelineView.tsx @@ -13,6 +13,7 @@ import { useDatabaseContext, useDatabaseViewId, useFieldSelector, + useDatabaseFields, useFieldsSelector, useNavigateToRow, usePrimaryFieldId, @@ -26,6 +27,8 @@ import { ReactComponent as CollapseIcon } from '@/assets/icons/double_arrow_left import { ReactComponent as ExpandIcon } from '@/assets/icons/double_arrow_right.svg'; import { ReactComponent as PlusIcon } from '@/assets/icons/plus.svg'; import { useAIEnabled } from '@/components/app/app.hooks'; +import { FieldDisplay } from '@/components/database/components/field'; +import { GridCalculateRowCell } from '@/components/database/components/grid/grid-cell/GridCalculateRowCell'; import { type Edge } from '@/components/database/components/drag-and-drop/useRowDnd'; import { useTimeFormat } from '@/components/database/fullcalendar/hooks/useTimeFormat'; import { shouldUseFixedDatabaseViewport } from '@/components/database/layout'; @@ -41,6 +44,7 @@ import { TIMELINE_HEADER_HEIGHT, TIMELINE_ROW_HEIGHT, TIMELINE_SIDEBAR_WIDTH, + TIMELINE_TABLE_COLUMN_WIDTH, TIMELINE_TODAY_ANCHOR, } from './constants'; import { useScrollWindow } from './hooks/useScrollWindow'; @@ -123,7 +127,15 @@ export function TimelineView({ setting }: { setting: TimelineLayoutSetting }) { const localOverride = permissions.readOnly && localSetting?.viewId === viewId ? localSetting : undefined; const layout = localOverride?.layout ?? setting.layout; const showSidebar = localOverride?.showTable ?? setting.showTable; - const sidebarWidth = showSidebar ? TIMELINE_SIDEBAR_WIDTH : TIMELINE_COLLAPSED_SIDEBAR_WIDTH; + // Only columns whose field still exists are shown, in the setting's order. + const databaseFields = useDatabaseFields(); + const tableFieldIds = useMemo( + () => setting.tableFieldIds.filter((fieldId) => fieldId !== primaryFieldId && databaseFields?.has(fieldId)), + [databaseFields, primaryFieldId, setting.tableFieldIds] + ); + const sidebarWidth = showSidebar + ? TIMELINE_SIDEBAR_WIDTH + tableFieldIds.length * TIMELINE_TABLE_COLUMN_WIDTH + : TIMELINE_COLLAPSED_SIDEBAR_WIDTH; const { rows, emptyEvents, rowOrders, hasEndField } = useTimelineRows(showSidebar); const reorderRow = useReorderRowDispatch(); @@ -459,8 +471,8 @@ export function TimelineView({ setting }: { setting: TimelineLayoutSetting }) { void newRow({ tailing: true, openAfterCreate: true }).catch(() => undefined); }, [newRow]); - const bodyHeight = - virtualizer.getTotalSize() + (permissions.readOnly ? 0 : TIMELINE_ROW_HEIGHT) + TIMELINE_BOTTOM_PADDING; + const footerRows = (permissions.readOnly ? 0 : 1) + (showSidebar ? 1 : 0); + const bodyHeight = virtualizer.getTotalSize() + footerRows * TIMELINE_ROW_HEIGHT + TIMELINE_BOTTOM_PADDING; const virtualItems = virtualizer.getVirtualItems(); const firstVisibleIndex = virtualItems[0]?.index ?? 0; const lastVisibleIndex = virtualItems[virtualItems.length - 1]?.index ?? -1; @@ -503,7 +515,21 @@ export function TimelineView({ setting }: { setting: TimelineLayoutSetting }) { // 40px hover gutter when the table is editable. style={{ width: sidebarWidth, paddingLeft: showSidebar ? (permissions.editable ? 44 : 12) : undefined }} > - {showSidebar ? {primaryFieldName} : null} + {showSidebar ? ( + {primaryFieldName} + ) : null} + {showSidebar + ? tableFieldIds.map((fieldId) => ( +
+ +
+ )) + : null}
) : null} + + {showSidebar ? ( + // Calculations footer under the table, one cell per column, as in the grid. +
+
+
+ {primaryFieldId ? : null} +
+ {tableFieldIds.map((fieldId) => ( +
+ +
+ ))} +
+
+ ) : null}
diff --git a/src/components/database/timeline/constants.ts b/src/components/database/timeline/constants.ts index 5e12fad37..71ef0f923 100644 --- a/src/components/database/timeline/constants.ts +++ b/src/components/database/timeline/constants.ts @@ -8,6 +8,8 @@ export const TIMELINE_HEADER_HEIGHT = 36; export const TIMELINE_SIDEBAR_WIDTH = 280; /** Width reserved for the expand toggle when the table is hidden. */ export const TIMELINE_COLLAPSED_SIDEBAR_WIDTH = 32; +/** Width of each extra property column in the docked table. */ +export const TIMELINE_TABLE_COLUMN_WIDTH = 140; /** Vertical inset of a bar inside its row: 36px rows hold the calendar's 22px event chips. */ export const TIMELINE_BAR_INSET = 7; /** Extra blank rows below the last row so the canvas can be scrolled past it. */ From 87482456f5e705cf24d370d77c90db3c5bae73b4 Mon Sep 17 00:00:00 2001 From: Nathan Date: Sun, 13 Sep 2026 16:38:56 +0000 Subject: [PATCH 08/21] feat(timeline): group by a property MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group the timeline the way the List groups: a header row per visible group (the List's header — collapse toggle, value tag, count, hide / remove-grouping menu, quick add) with a tinted band across the canvas, the group's rows beneath it, and a per-group "+ New row" footer that creates rows already holding the group's value. Collapsed and hidden groups drop out of the canvas; bar and arrow geometry stays index-based over the mixed item list. The grouping selector, hide-empty toggle and layout-setting accessor now understand DatabaseViewLayout.Timeline (`layout_settings['8']`), a TimelineGroupingProvider wraps the view in DatabaseViews, and the settings menu gains the shared Group submenu. Manual row reordering is off while grouped; inserts from the row gutter stay in their group. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NoEH4HcEiojEcioVSLE3QM --- .../bdd/features/database/timeline.feature | 15 ++++ playwright/bdd/steps/timeline.steps.ts | 88 +++++++++++++++++++ src/application/database-yjs/dispatch.ts | 21 ++++- src/application/database-yjs/selector.ts | 8 +- src/application/types.ts | 4 +- src/components/database/DatabaseViews.tsx | 4 + .../components/settings/GridSettingGroup.tsx | 2 +- .../settings/TimelineSettingGroup.tsx | 39 ++++++++ .../components/settings/TimelineSettings.tsx | 2 + src/components/database/list/ListGroup.tsx | 10 ++- .../database/timeline/TimelineGroupRow.tsx | 86 ++++++++++++++++++ .../timeline/TimelineGroupingContext.tsx | 29 ++++++ .../database/timeline/TimelineRow.tsx | 7 ++ .../database/timeline/TimelineSidebarRow.tsx | 7 ++ .../database/timeline/TimelineView.tsx | 81 ++++++++++++++--- .../timeline/hooks/useTimelineItems.ts | 41 +++++++++ 16 files changed, 423 insertions(+), 21 deletions(-) create mode 100644 src/components/database/components/settings/TimelineSettingGroup.tsx create mode 100644 src/components/database/timeline/TimelineGroupRow.tsx create mode 100644 src/components/database/timeline/TimelineGroupingContext.tsx create mode 100644 src/components/database/timeline/hooks/useTimelineItems.ts diff --git a/playwright/bdd/features/database/timeline.feature b/playwright/bdd/features/database/timeline.feature index 2c1542d48..8b4fc37ab 100644 --- a/playwright/bdd/features/database/timeline.feature +++ b/playwright/bdd/features/database/timeline.feature @@ -239,3 +239,18 @@ Feature: Timeline view interactions Then the "Progress" column calculation reads "Sum40" When I hide the "Progress" table column Then the table has no "Progress" column + + Scenario: Grouping by a select field stacks the rows under group headers + Given a "Status" select field where "Design" is "Doing" and "Build" is "Done" + When I group the timeline by "Status" + Then the timeline shows groups "Doing, Done" with 1 row each + And the timeline shows 2 bars + When I collapse the timeline group "Doing" + Then the timeline shows 1 bars + And the table does not list "Design" + When I expand the timeline group "Doing" + And I add a row from the timeline group "Done" footer + Then the timeline group "Done" has 2 rows + When I remove the timeline grouping + Then the timeline has no group headers + And the table lists "Design, Build, Untitled" in that order diff --git a/playwright/bdd/steps/timeline.steps.ts b/playwright/bdd/steps/timeline.steps.ts index 9cf904dca..6903d45c5 100644 --- a/playwright/bdd/steps/timeline.steps.ts +++ b/playwright/bdd/steps/timeline.steps.ts @@ -625,6 +625,94 @@ Then('the table has no {string} column', async ({ page }, name) => { await expect(page.getByTestId(`timeline-table-header-${TABLE_FIELD_ID[name]}`)).toHaveCount(0); }); +// --- Grouping --------------------------------------------------------------- + +const STATUS_OPTIONS = [ + { id: 'opt-doing', name: 'Doing', color: 'Purple' }, + { id: 'opt-done', name: 'Done', color: 'Green' }, +]; + +function groupHeader(page: Page, name: string) { + return page.locator('[data-testid^="timeline-group-"]:not([data-testid^="timeline-group-new-row-"])').filter({ + has: page.locator('[data-testid^="list-group-header-"]').filter({ hasText: name }), + }); +} + +Given( + 'a {string} select field where {string} is {string} and {string} is {string}', + async ({ page }, name, firstTitle, firstValue, secondTitle, secondValue) => { + await injectFieldDirect(page, { + fieldId: 'status', + name, + fieldType: FieldType.SingleSelect, + typeOption: { content: JSON.stringify({ options: STATUS_OPTIONS, disable_color: false }) }, + }); + for (const [title, value] of [ + [firstTitle, firstValue], + [secondTitle, secondValue], + ]) { + const option = STATUS_OPTIONS.find((candidate) => candidate.name === value); + + if (!option) throw new Error(`Unknown option ${value}`); + await setTextCellDirect(page, rowId(page, title), 'status', FieldType.SingleSelect, option.id); + } + } +); + +When('I group the timeline by {string}', async ({ page }, name) => { + await page.getByTestId('database-actions-settings').click(); + await page.getByTestId('timeline-group-settings-trigger').click(); + await page.locator('[data-testid^="timeline-group-by-field-"]').filter({ hasText: name }).click(); + await page.keyboard.press('Escape'); + await page.keyboard.press('Escape'); + await expect(page.locator('[data-testid^="list-group-header-"]').first()).toBeVisible({ timeout: 15_000 }); +}); + +Then('the timeline shows groups {string} with {int} row each', async ({ page }, list, count) => { + for (const name of list.split(',').map((item: string) => item.trim())) { + const header = groupHeader(page, name); + + await expect(header).toHaveCount(1); + await expect(header.getByTestId('list-group-row-count')).toHaveText(String(count)); + } +}); + +When('I collapse the timeline group {string}', async ({ page }, name) => { + await groupHeader(page, name).getByTestId('list-group-collapse-toggle').click(); +}); + +When('I expand the timeline group {string}', async ({ page }, name) => { + await groupHeader(page, name).getByTestId('list-group-collapse-toggle').click(); +}); + +Then('the table does not list {string}', async ({ page }, title) => { + await expect(TimelineSelectors.sidebarRows(page).filter({ hasText: title })).toHaveCount(0); +}); + +When('I add a row from the timeline group {string} footer', async ({ page }, name) => { + const option = STATUS_OPTIONS.find((candidate) => candidate.name === name); + + if (!option) throw new Error(`Unknown option ${name}`); + await page.getByTestId(`timeline-group-new-row-${option.id}`).click(); + // The new row opens in its detail modal; close it to see the table. + await closeRowDetailWithEscape(page); +}); + +Then('the timeline group {string} has {int} rows', async ({ page }, name, count) => { + await expect(groupHeader(page, name).getByTestId('list-group-row-count')).toHaveText(String(count), { + timeout: 15_000, + }); +}); + +When('I remove the timeline grouping', async ({ page }) => { + await page.locator('[data-testid="list-group-actions"]').first().click(); + await page.getByTestId('list-remove-grouping').click(); +}); + +Then('the timeline has no group headers', async ({ page }) => { + await expect(page.locator('[data-testid^="list-group-header-"]')).toHaveCount(0, { timeout: 15_000 }); +}); + Then('the timeline draws {int} dependency arrow', async ({ page }, count) => { await expect(TimelineSelectors.arrows(page)).toHaveCount(count, { timeout: 15_000 }); }); diff --git a/src/application/database-yjs/dispatch.ts b/src/application/database-yjs/dispatch.ts index fef6bcb86..628d32f2c 100644 --- a/src/application/database-yjs/dispatch.ts +++ b/src/application/database-yjs/dispatch.ts @@ -136,6 +136,7 @@ import { YDatabaseGridLayoutSetting, YDatabaseLayoutSettings, YDatabaseListLayoutSetting, + YDatabaseTimelineLayoutSetting, YDatabaseRow, YDatabaseRowOrders, YDatabaseSort, @@ -1040,10 +1041,13 @@ function getOrCreateBoardLayoutSetting(view: YDatabaseView) { return layoutSetting; } +/** Layouts whose grouping options live under `layout_settings[String(layout)]`. */ +export type GroupableDatabaseLayout = DatabaseViewLayout.Grid | DatabaseViewLayout.List | DatabaseViewLayout.Timeline; + function getOrCreateDatabaseGroupingLayoutSetting( view: YDatabaseView, - layout: DatabaseViewLayout.Grid | DatabaseViewLayout.List -): YDatabaseGridLayoutSetting | YDatabaseListLayoutSetting { + layout: GroupableDatabaseLayout +): YDatabaseGridLayoutSetting | YDatabaseListLayoutSetting | YDatabaseTimelineLayoutSetting { let layoutSettings = view.get(YjsDatabaseKey.layout_settings); if (!layoutSettings) { @@ -1051,7 +1055,12 @@ function getOrCreateDatabaseGroupingLayoutSetting( view.set(YjsDatabaseKey.layout_settings, layoutSettings); } - let layoutSetting = layout === DatabaseViewLayout.List ? layoutSettings.get('4') : layoutSettings.get('0'); + let layoutSetting = + layout === DatabaseViewLayout.List + ? layoutSettings.get('4') + : layout === DatabaseViewLayout.Timeline + ? layoutSettings.get('8') + : layoutSettings.get('0'); if (!layoutSetting) { layoutSetting = new Y.Map() as YDatabaseGridLayoutSetting | YDatabaseListLayoutSetting; @@ -1061,7 +1070,7 @@ function getOrCreateDatabaseGroupingLayoutSetting( return layoutSetting; } -export function useToggleDatabaseHideEmptyGroups(layout: DatabaseViewLayout.Grid | DatabaseViewLayout.List) { +export function useToggleDatabaseHideEmptyGroups(layout: GroupableDatabaseLayout) { const view = useDatabaseView(); const sharedRoot = useSharedRoot(); @@ -1090,6 +1099,10 @@ export function useToggleListHideEmptyGroups() { return useToggleDatabaseHideEmptyGroups(DatabaseViewLayout.List); } +export function useToggleTimelineHideEmptyGroups() { + return useToggleDatabaseHideEmptyGroups(DatabaseViewLayout.Timeline); +} + export function useSetDatabaseGroupVisibilityDispatch(groupId?: string, fieldId?: string) { const view = useDatabaseView(); const fields = useDatabaseFields(); diff --git a/src/application/database-yjs/selector.ts b/src/application/database-yjs/selector.ts index db3f0e6b8..6bd67ab44 100644 --- a/src/application/database-yjs/selector.ts +++ b/src/application/database-yjs/selector.ts @@ -1775,7 +1775,7 @@ export function useDatabaseGroupingSelector(layout: DatabaseViewLayout): Databas const inlineRowOrders = getInlineViewRowOrders(database); const { cachedRowDocs, getCachedRowDocs, subscribeToCachedRowDocChanges } = useBackgroundRowDocLoader( Boolean(fieldId), - `${layout === DatabaseViewLayout.List ? 'list' : 'grid'}-grouping` + `${layout === DatabaseViewLayout.List ? 'list' : layout === DatabaseViewLayout.Timeline ? 'timeline' : 'grid'}-grouping` ); const groupingRows = useMemo(() => { const next = { ...cachedRowDocs }; @@ -2027,6 +2027,8 @@ export function useDatabaseGroupingSelector(layout: DatabaseViewLayout): Databas const layoutSetting = layout === DatabaseViewLayout.List ? view?.get(YjsDatabaseKey.layout_settings)?.get('4') + : layout === DatabaseViewLayout.Timeline + ? view?.get(YjsDatabaseKey.layout_settings)?.get('8') : view?.get(YjsDatabaseKey.layout_settings)?.get('0'); const storedHideEmpty = layoutSetting?.get(YjsDatabaseKey.hide_empty_groups); const hideEmptyGroups = storedHideEmpty === undefined ? true : Boolean(storedHideEmpty); @@ -2194,6 +2196,10 @@ export function useListGroupingSelector(): DatabaseGrouping { return useDatabaseGroupingSelector(DatabaseViewLayout.List); } +export function useTimelineGroupingSelector(): DatabaseGrouping { + return useDatabaseGroupingSelector(DatabaseViewLayout.Timeline); +} + /** * Hook to get sorted and filtered row orders. * diff --git a/src/application/types.ts b/src/application/types.ts index 617cc38dd..95284d655 100644 --- a/src/application/types.ts +++ b/src/application/types.ts @@ -1037,7 +1037,9 @@ export interface YDatabaseTimelineLayoutSetting extends Y.Map { | YjsDatabaseKey.first_day_of_week_v2 | YjsDatabaseKey.dependency_shift_ty ): number | bigint | null | undefined; - get(key: YjsDatabaseKey.show_table | YjsDatabaseKey.avoid_weekends): boolean | undefined; + get( + key: YjsDatabaseKey.show_table | YjsDatabaseKey.avoid_weekends | YjsDatabaseKey.hide_empty_groups + ): boolean | undefined; get(key: YjsDatabaseKey.table_field_ids): unknown; } diff --git a/src/components/database/DatabaseViews.tsx b/src/components/database/DatabaseViews.tsx index f60150a08..53dd8e206 100644 --- a/src/components/database/DatabaseViews.tsx +++ b/src/components/database/DatabaseViews.tsx @@ -26,6 +26,7 @@ import { shouldUseFixedDatabaseViewport, } from '@/components/database/layout'; import { ListGroupingProvider } from '@/components/database/list/ListGroupingContext'; +import { TimelineGroupingProvider } from '@/components/database/timeline/TimelineGroupingContext'; import { ElementFallbackRender } from '@/components/error/ElementFallbackRender'; import { cn } from '@/lib/utils'; import { @@ -488,6 +489,9 @@ function DatabaseViews({ case DatabaseViewLayout.List: groupedContent = {content}; break; + case DatabaseViewLayout.Timeline: + groupedContent = {content}; + break; } return ( diff --git a/src/components/database/components/settings/GridSettingGroup.tsx b/src/components/database/components/settings/GridSettingGroup.tsx index 6f35125ba..7059327f8 100644 --- a/src/components/database/components/settings/GridSettingGroup.tsx +++ b/src/components/database/components/settings/GridSettingGroup.tsx @@ -239,7 +239,7 @@ interface DatabaseSettingGroupProps { setAllVisibility: (groupIds: string[], visible: boolean) => void; updateDateCondition: (condition: DateGroupCondition) => void; updateNumberConfiguration: (configuration: NumberGroupConfiguration) => void; - testIdPrefix: 'grid' | 'list'; + testIdPrefix: 'grid' | 'list' | 'timeline'; } export function DatabaseSettingGroup({ diff --git a/src/components/database/components/settings/TimelineSettingGroup.tsx b/src/components/database/components/settings/TimelineSettingGroup.tsx new file mode 100644 index 000000000..368daaec6 --- /dev/null +++ b/src/components/database/components/settings/TimelineSettingGroup.tsx @@ -0,0 +1,39 @@ +import { + useClearGroupByFieldDispatch, + useGroupByFieldDispatch, + useSetAllListGroupsVisibilityDispatch, + useSetListGroupVisibilityDispatch, + useToggleTimelineHideEmptyGroups, + useUpdateDateGroupConditionDispatch, +} from '@/application/database-yjs'; +import { useUpdateNumberGroupConfigurationDispatch } from '@/application/database-yjs/dispatch'; +import { DatabaseSettingGroup } from '@/components/database/components/settings/GridSettingGroup'; +import { useTimelineGrouping } from '@/components/database/timeline/TimelineGroupingContext'; + +/** The List's group menu (field, hide empty, per-group visibility) bound to the timeline. */ +function TimelineSettingGroup() { + const grouping = useTimelineGrouping(); + const groupBy = useGroupByFieldDispatch(); + const clearGrouping = useClearGroupByFieldDispatch(); + const toggleHideEmpty = useToggleTimelineHideEmptyGroups(); + const setVisibility = useSetListGroupVisibilityDispatch(grouping.groupId, grouping.fieldId); + const setAllVisibility = useSetAllListGroupsVisibilityDispatch(grouping.groupId, grouping.fieldId); + const updateDateCondition = useUpdateDateGroupConditionDispatch(); + const updateNumberConfiguration = useUpdateNumberGroupConfigurationDispatch(); + + return ( + + ); +} + +export default TimelineSettingGroup; diff --git a/src/components/database/components/settings/TimelineSettings.tsx b/src/components/database/components/settings/TimelineSettings.tsx index 60751e8de..6861721d6 100644 --- a/src/components/database/components/settings/TimelineSettings.tsx +++ b/src/components/database/components/settings/TimelineSettings.tsx @@ -4,6 +4,7 @@ import { DatabaseViewLayout } from '@/application/types'; import Layout from '@/components/database/components/settings/Layout'; import Properties from '@/components/database/components/settings/Properties'; import TimelineLayoutSettings from '@/components/database/components/settings/TimelineLayoutSettings'; +import TimelineSettingGroup from '@/components/database/components/settings/TimelineSettingGroup'; import { DropdownMenu, DropdownMenuContent, @@ -27,6 +28,7 @@ function TimelineSettings({ children }: { children: React.ReactNode }) { + diff --git a/src/components/database/list/ListGroup.tsx b/src/components/database/list/ListGroup.tsx index 90887c2a7..86ddb4389 100644 --- a/src/components/database/list/ListGroup.tsx +++ b/src/components/database/list/ListGroup.tsx @@ -21,7 +21,7 @@ import { cn } from '@/lib/utils'; import { getListGroupCellsData } from './ListRowActions'; -function useCreateListGroupRow(groupFieldId?: string, groupId?: string, openAfterCreate = false) { +export function useCreateListGroupRow(groupFieldId?: string, groupId?: string, openAfterCreate = false) { const fields = useDatabaseFields(); const view = useDatabaseView(); const createRow = useNewRowDispatch(); @@ -41,12 +41,15 @@ export function ListGroupHeader({ fieldType, group, groupConfigId, + className, }: { fieldId?: string; fieldName?: string; fieldType?: FieldType; group: GridGroup; groupConfigId?: string; + /** Overrides the List's height and gutter, e.g. for the timeline's 36px rows. */ + className?: string; }) { const { t } = useTranslation(); const readOnly = useReadOnly(); @@ -61,7 +64,10 @@ export function ListGroupHeader({ return (
diff --git a/src/components/database/timeline/TimelineGroupRow.tsx b/src/components/database/timeline/TimelineGroupRow.tsx new file mode 100644 index 000000000..cc35cdc55 --- /dev/null +++ b/src/components/database/timeline/TimelineGroupRow.tsx @@ -0,0 +1,86 @@ +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { FieldType, GridGroup } from '@/application/database-yjs'; +import { ReactComponent as PlusIcon } from '@/assets/icons/plus.svg'; +import { ListGroupHeader, useCreateListGroupRow } from '@/components/database/list/ListGroup'; +import { cn } from '@/lib/utils'; + +interface TimelineGroupRowProps { + group: GridGroup; + fieldId?: string; + fieldName?: string; + fieldType?: FieldType; + groupConfigId?: string; + sidebarWidth: number; + showSidebar: boolean; +} + +/** + * A group's header row: the List's group header in the docked table (toggle, + * value, count, actions) and a tinted band across the canvas, as in Notion. + */ +export const TimelineGroupRow = memo( + ({ group, fieldId, fieldName, fieldType, groupConfigId, sidebarWidth, showSidebar }: TimelineGroupRowProps) => ( +
+
+ +
+
+
+ ) +); + +TimelineGroupRow.displayName = 'TimelineGroupRow'; + +interface TimelineGroupFooterProps { + group: GridGroup; + fieldId?: string; + sidebarWidth: number; + showSidebar: boolean; +} + +/** Notion's per-group "+ New": creates a row already holding the group's value. */ +export const TimelineGroupFooter = memo(({ group, fieldId, sidebarWidth, showSidebar }: TimelineGroupFooterProps) => { + const { t } = useTranslation(); + const createRow = useCreateListGroupRow(fieldId, group.id, true); + + return ( +
+
void createRow()} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + void createRow(); + } + }} + > + + {showSidebar ? t('grid.row.newRow', { defaultValue: 'New row' }) : null} +
+
+
+ ); +}); + +TimelineGroupFooter.displayName = 'TimelineGroupFooter'; diff --git a/src/components/database/timeline/TimelineGroupingContext.tsx b/src/components/database/timeline/TimelineGroupingContext.tsx new file mode 100644 index 000000000..fc89a74ed --- /dev/null +++ b/src/components/database/timeline/TimelineGroupingContext.tsx @@ -0,0 +1,29 @@ +import { createContext, type ReactNode, useContext } from 'react'; + +import { useTimelineGroupingSelector } from '@/application/database-yjs'; +import type { DatabaseGrouping } from '@/application/database-yjs'; +import { useSyncListGroupingMetadata } from '@/components/database/list/ListGroupingContext'; + +const TimelineGroupingContext = createContext(undefined); + +export function useTimelineGrouping() { + const grouping = useContext(TimelineGroupingContext); + + if (!grouping) throw new Error('useTimelineGrouping must be used within TimelineGroupingProvider'); + + return grouping; +} + +/** + * One grouping selector shared by the timeline renderer and its settings. + * Group metadata (ids, order, collapsed state) is stored on the view exactly + * as the List and Grid store theirs, so their sync hook is reused. + */ +export function TimelineGroupingProvider({ children, value }: { children: ReactNode; value?: DatabaseGrouping }) { + const selectedGrouping = useTimelineGroupingSelector(); + const grouping = value ?? selectedGrouping; + + useSyncListGroupingMetadata(grouping); + + return {children}; +} diff --git a/src/components/database/timeline/TimelineRow.tsx b/src/components/database/timeline/TimelineRow.tsx index b47c68132..4e3ac43d9 100644 --- a/src/components/database/timeline/TimelineRow.tsx +++ b/src/components/database/timeline/TimelineRow.tsx @@ -38,6 +38,9 @@ interface TimelineRowProps { rowOrders: Row[]; /** Properties shown as table columns after the title. */ tableFieldIds: string[]; + /** When grouped: the group field and this row's group, so inserts land in the same group. */ + groupFieldId?: string; + groupId?: string; onOpen?: (rowId: string) => void; onSelect?: (rowId: string | null) => void; onScrollTo?: (x: number) => void; @@ -103,6 +106,8 @@ export const TimelineRow = memo( formatTime, rowOrders, tableFieldIds, + groupFieldId, + groupId, onOpen, onSelect, onScrollTo, @@ -153,6 +158,8 @@ export const TimelineRow = memo( selected={selected} rowOrders={rowOrders} tableFieldIds={tableFieldIds} + groupFieldId={groupFieldId} + groupId={groupId} dropTargetRef={rowRef} onOpen={onOpen} onSelect={onSelect} diff --git a/src/components/database/timeline/TimelineSidebarRow.tsx b/src/components/database/timeline/TimelineSidebarRow.tsx index dc78d6e4b..fd75bc1b6 100644 --- a/src/components/database/timeline/TimelineSidebarRow.tsx +++ b/src/components/database/timeline/TimelineSidebarRow.tsx @@ -27,6 +27,9 @@ interface TimelineSidebarRowProps { rowOrders: Row[]; /** Properties shown as columns after the title. */ tableFieldIds: string[]; + /** When grouped: inserted rows inherit this group's value. */ + groupFieldId?: string; + groupId?: string; /** The whole timeline row, so a drop anywhere along it counts. */ dropTargetRef: MutableRefObject; onOpen?: (rowId: string) => void; @@ -47,6 +50,8 @@ export const TimelineSidebarRow = memo( selected, rowOrders, tableFieldIds, + groupFieldId, + groupId, dropTargetRef, onOpen, onSelect, @@ -89,6 +94,8 @@ export const TimelineSidebarRow = memo( reorderable={Boolean(onDropRow)} rowId={row.rowId} rowOrders={rowOrders} + groupFieldId={groupFieldId} + groupId={groupId} /> ) : (
diff --git a/src/components/database/timeline/TimelineView.tsx b/src/components/database/timeline/TimelineView.tsx index d8429fc8c..589cfaef6 100644 --- a/src/components/database/timeline/TimelineView.tsx +++ b/src/components/database/timeline/TimelineView.tsx @@ -49,6 +49,7 @@ import { } from './constants'; import { useScrollWindow } from './hooks/useScrollWindow'; import { TimelineDragMode, TimelineDragPreview, TimelineDragSpan, useTimelineDrag } from './hooks/useTimelineDrag'; +import { useTimelineItems } from './hooks/useTimelineItems'; import { useTimelineLinkDrag } from './hooks/useTimelineLinkDrag'; import { parseProgressPercent, parseRelationRowIds, useTimelineFieldValues } from './hooks/useTimelineFieldValues'; import { useTimelinePermissions } from './hooks/useTimelinePermissions'; @@ -74,6 +75,8 @@ import { TimelineBarDragLabel } from './TimelineBar'; import { TimelineToolbar } from './TimelineToolbar'; import { TimelineGrid } from './TimelineGrid'; import { TimelineHeader } from './TimelineHeader'; +import { TimelineGroupFooter, TimelineGroupRow } from './TimelineGroupRow'; +import { useTimelineGrouping } from './TimelineGroupingContext'; import { TimelineRow } from './TimelineRow'; // Calendar cards carry only the title; a timeline bar adds chips solely for @@ -138,6 +141,11 @@ export function TimelineView({ setting }: { setting: TimelineLayoutSetting }) { : TIMELINE_COLLAPSED_SIDEBAR_WIDTH; const { rows, emptyEvents, rowOrders, hasEndField } = useTimelineRows(showSidebar); + const grouping = useTimelineGrouping(); + // Rows, or group headers / rows / "+ New" footers when the view is grouped. + const items = useTimelineItems(rows, grouping, permissions.editable); + // Bars and arrows are addressed by item index; non-row items carry no id. + const itemRowIds = useMemo(() => items.map((item) => (item.kind === 'row' ? item.row.rowId : '')), [items]); const reorderRow = useReorderRowDispatch(); // Same reorder semantics as the List view: drop above / below a row, then // tell the view which row now precedes the moved one. @@ -357,15 +365,22 @@ export function TimelineView({ setting }: { setting: TimelineLayoutSetting }) { ); const virtualizer = useVirtualizer({ - count: rows.length, + count: items.length, getScrollElement: () => scrollerRef.current, estimateSize: () => TIMELINE_ROW_HEIGHT, overscan: 8, scrollMargin: TIMELINE_HEADER_HEIGHT, - getItemKey: (index) => rows[index]?.rowId ?? index, + getItemKey: (index) => items[index]?.key ?? index, }); - const rowIndexById = useMemo(() => new Map(rows.map((row, index) => [row.rowId, index] as const)), [rows]); + const rowIndexById = useMemo(() => { + const map = new Map(); + + items.forEach((item, index) => { + if (item.kind === 'row') map.set(item.row.rowId, index); + }); + return map; + }, [items]); const updateRelationCell = useUpdateRelationCellDispatch(); const graphRef = useRef(graph); @@ -403,8 +418,13 @@ export function TimelineView({ setting }: { setting: TimelineLayoutSetting }) { // Base rects only change with the data or the scale; a drag overlays the few // rows it moves so every other row keeps its rect reference (and its memo). const baseRects = useMemo( - () => rows.map((row) => (row.start ? getBarRect(geometry, row.start, row.end, row.allDay) : null)), - [geometry, rows] + () => + items.map((item) => + item.kind === 'row' && item.row.start + ? getBarRect(geometry, item.row.start, item.row.end, item.row.allDay) + : null + ), + [geometry, items] ); const rects = useMemo(() => { if (!preview || preview.mode === 'progress') return baseRects; @@ -471,7 +491,9 @@ export function TimelineView({ setting }: { setting: TimelineLayoutSetting }) { void newRow({ tailing: true, openAfterCreate: true }).catch(() => undefined); }, [newRow]); - const footerRows = (permissions.readOnly ? 0 : 1) + (showSidebar ? 1 : 0); + // Grouped views create rows from their group footers instead of one global footer. + const showNewRowFooter = !permissions.readOnly && !grouping.isGrouped; + const footerRows = (showNewRowFooter ? 1 : 0) + (showSidebar ? 1 : 0); const bodyHeight = virtualizer.getTotalSize() + footerRows * TIMELINE_ROW_HEIGHT + TIMELINE_BOTTOM_PADDING; const virtualItems = virtualizer.getVirtualItems(); const firstVisibleIndex = virtualItems[0]?.index ?? 0; @@ -572,7 +594,7 @@ export function TimelineView({ setting }: { setting: TimelineLayoutSetting }) { {graph.predecessors.size > 0 || link ? ( { - const row = rows[virtualRow.index]; + const item = items[virtualRow.index]; + + if (!item) return null; + if (item.kind !== 'row') { + return ( +
+ {item.kind === 'group' ? ( + + ) : ( + + )} +
+ ); + } - if (!row) return null; + const { row } = item; const isDragged = preview?.rowId === row.rowId; const rect = rects[virtualRow.index]; // Booleans, not pixels, so a scroll frame only re-renders rows whose pill state flips. @@ -626,7 +681,9 @@ export function TimelineView({ setting }: { setting: TimelineLayoutSetting }) { onScrollTo={handleScrollToX} onBarPointerDown={handleBarPointerDown} onEmptyClick={handleEmptyClick} - onDropRow={permissions.editable ? handleDropRow : undefined} + onDropRow={permissions.editable && !grouping.isGrouped ? handleDropRow : undefined} + groupFieldId={grouping.isGrouped ? grouping.fieldId : undefined} + groupId={item.groupId} linkable={permissions.editable && Boolean(setting.dependencyFieldId)} linkTarget={link?.targetRowId === row.rowId} onLinkPointerDown={handleLinkPointerDown} @@ -635,7 +692,7 @@ export function TimelineView({ setting }: { setting: TimelineLayoutSetting }) { ); })} - {!permissions.readOnly ? ( + {showNewRowFooter ? (
{ + if (!grouping.isGrouped) return rows.map((row) => ({ kind: 'row' as const, key: row.rowId, row })); + const byId = new Map(rows.map((row) => [row.rowId, row] as const)); + const items: TimelineItem[] = []; + + grouping.visibleGroups.forEach((group) => { + items.push({ kind: 'group', key: `group:${group.id}`, group }); + if (group.collapsed) return; + group.rows.forEach(({ id }) => { + const row = byId.get(id); + + if (row) items.push({ kind: 'row', key: id, row, groupId: group.id }); + }); + if (withFooters) items.push({ kind: 'footer', key: `footer:${group.id}`, group }); + }); + + return items; + }, [grouping.isGrouped, grouping.visibleGroups, rows, withFooters]); +} From ec0bbe522c0aff871f1e1566f84ee6f042422635 Mon Sep 17 00:00:00 2001 From: Nathan Date: Sun, 13 Sep 2026 16:45:18 +0000 Subject: [PATCH 09/21] test(timeline): pin the e2e dependency spec to the keep-gap shift mode The default became "only when dates overlap"; the e2e spec asserts frappe's move_dependencies behaviour, which the BDD suite now covers per mode. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NoEH4HcEiojEcioVSLE3QM --- playwright/e2e/database/timeline.spec.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/playwright/e2e/database/timeline.spec.ts b/playwright/e2e/database/timeline.spec.ts index 09f076d05..9236ce9fc 100644 --- a/playwright/e2e/database/timeline.spec.ts +++ b/playwright/e2e/database/timeline.spec.ts @@ -143,6 +143,9 @@ test.describe('Timeline dependencies and progress', () => { await chooseTimelineSettingsOption(page, 'timeline-dependency-field-rel-deps'); await expect(page.locator('[data-testid="timeline-arrow"]')).toHaveCount(1, { timeout: 15_000 }); + // This spec asserts frappe's move_dependencies behaviour ("keep the time + // between items"); the default "only when dates overlap" is covered by BDD. + await chooseTimelineSettingsOption(page, 'timeline-shift-1'); await chooseTimelineSettingsOption(page, 'timeline-progress-field-num-progress'); await expect(page.getByTestId(`timeline-progress-${designId}`)).toHaveAttribute('style', /width: 40%/, { From f5cc369036d378577ed0d7b76677f5943eea68b3 Mon Sep 17 00:00:00 2001 From: Nathan Date: Mon, 14 Sep 2026 00:37:12 +0000 Subject: [PATCH 10/21] feat(timeline): one-click dependencies, link types, lag and reverse binding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Set up dependencies (settings item, or the first connector drop on a view without them): creates a two-way "Blocked by" / "Blocking" self-relation pair (or reuses an existing one), binds it, shows both as table columns and keeps them off the bars — Notion's one click. - The bound field may list either side: "Field lists: Blocked by / Blocking" (`dependency_direction`) flips the edges so a "Blocking" property draws the same arrows and writes go to the right cell. - Per-link type and lag: clicking an arrow opens an editor with finish-to-start / start-to-start / finish-to-finish / start-to-finish, a lag in days (negative = lead) and Remove dependency. Stored in `dependency_links` keyed "predecessor:successor"; missing = FS, lag 0. - Arrows route per type (FS keeps frappe's route; the others run orthogonally between the matching edges, detouring along the row boundary), and the shift math and drag clamps honour type and lag: start-type links bound a bar's start, end-type links its end. The finish-to-start clamp now means "cannot start before its dependency finishes" (was: before it starts). Tests: 7 new jest cases (direction, constraintStart per type/lag, SS + lag shifting, minEnd clamp, per-type routing); BDD scenarios for setup, connector-driven setup, Blocking-side binding, and editing type / lag / removal from the arrow; e2e clamp expectation updated. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NoEH4HcEiojEcioVSLE3QM --- .../bdd/features/database/timeline.feature | 38 +++- playwright/bdd/steps/timeline.steps.ts | 110 ++++++++++++ playwright/e2e/database/timeline.spec.ts | 5 +- src/@types/translations/en.json | 15 ++ .../__tests__/timeline-layout.test.ts | 41 ++++- src/application/database-yjs/database.type.ts | 32 ++++ .../database-yjs/dispatch/relation.ts | 2 +- .../dispatch/timeline-dependencies.ts | 120 +++++++++++++ .../database-yjs/timeline-layout.ts | 73 +++++++- src/application/types.ts | 7 +- .../settings/TimelineLayoutSettings.tsx | 43 +++++ .../database/timeline/TimelineArrows.tsx | 73 ++++++-- .../database/timeline/TimelineLinkEditor.tsx | 167 ++++++++++++++++++ .../database/timeline/TimelineView.tsx | 153 ++++++++++++++-- .../timeline/__tests__/dependencies.test.ts | 153 +++++++++++++++- .../timeline/hooks/useTimelineDrag.ts | 63 +++++-- .../database/timeline/scale/dependencies.ts | 109 +++++++++++- 17 files changed, 1137 insertions(+), 67 deletions(-) create mode 100644 src/application/database-yjs/dispatch/timeline-dependencies.ts create mode 100644 src/components/database/timeline/TimelineLinkEditor.tsx diff --git a/playwright/bdd/features/database/timeline.feature b/playwright/bdd/features/database/timeline.feature index 8b4fc37ab..d998dd29f 100644 --- a/playwright/bdd/features/database/timeline.feature +++ b/playwright/bdd/features/database/timeline.feature @@ -85,7 +85,7 @@ Feature: Timeline view interactions Then the "Build" bar is back where it started And the "Design" bar is back where it started When I drag the "Build" bar 6 columns earlier - Then the "Build" bar starts where the "Design" bar starts + Then the "Build" bar starts 1 columns after the "Design" bar Scenario: A progress field renders a fill that the progress handle drags Given "Design" has a progress field at 40 percent @@ -254,3 +254,39 @@ Feature: Timeline view interactions When I remove the timeline grouping Then the timeline has no group headers And the table lists "Design, Build, Untitled" in that order + + Scenario: Setting up dependencies creates the Blocked by and Blocking properties + When I set up dependencies from the timeline settings + Then the table has "Blocked by" and "Blocking" columns + When I drag the connector of "Design" onto the "Build" bar + Then the timeline draws 1 dependency arrow + And the "Blocking" cell of "Design" reads "Build" + And the "Blocked by" cell of "Build" reads "Design" + + Scenario: The first connector on a bare view sets dependencies up by itself + When I drag the connector of "Design" onto the "Build" bar + Then the table has "Blocked by" and "Blocking" columns + And the timeline draws 1 dependency arrow + + Scenario: Binding the Blocking side keeps the arrow pointing the same way + When I set up dependencies from the timeline settings + And I drag the connector of "Design" onto the "Build" bar + Then the arrow runs from "Design" to "Build" + When I bind the "Blocking" property as the dependency field listing "Blocking" + Then the timeline draws 1 dependency arrow + And the arrow runs from "Design" to "Build" + + Scenario: Clicking an arrow edits the link type and lag, and can remove the dependency + Given "Build" depends on "Design" through a relation field + When I click the arrow from "Design" to "Build" + Then the link editor shows "Design → Build" + When I choose the "SS" link type + And I drag the "Design" bar 4 columns later + Then the "Build" bar moved 2 columns later + When I click the arrow from "Design" to "Build" + And I set the link lag to 2 days + And I drag the "Design" bar 1 columns later + Then the "Build" bar moved 3 columns later + When I click the arrow from "Design" to "Build" + And I remove the dependency from the link editor + Then the timeline draws 0 dependency arrow diff --git a/playwright/bdd/steps/timeline.steps.ts b/playwright/bdd/steps/timeline.steps.ts index 6903d45c5..78a3ff223 100644 --- a/playwright/bdd/steps/timeline.steps.ts +++ b/playwright/bdd/steps/timeline.steps.ts @@ -713,6 +713,116 @@ Then('the timeline has no group headers', async ({ page }) => { await expect(page.locator('[data-testid^="list-group-header-"]')).toHaveCount(0, { timeout: 15_000 }); }); +// --- Dependency setup, direction and link editing --------------------------- + +Then('the {string} bar starts {int} columns after the {string} bar', async ({ page }, title, columns, other) => { + await expectBarX(page, title, (await barBox(page, other)).x + columns * MONTH_COLUMN_WIDTH); +}); + +When('I set up dependencies from the timeline settings', async ({ page }) => { + await chooseTimelineSettingsOption(page, 'timeline-set-up-dependencies'); +}); + +/** Field id of a docked-table column, found by its header text. */ +async function tableFieldIdByName(page: Page, name: string): Promise { + const header = page.locator('[data-testid^="timeline-table-header-"]').filter({ hasText: name }).first(); + + await expect(header).toBeVisible({ timeout: 15_000 }); + const testId = (await header.getAttribute('data-testid')) ?? ''; + + return testId.replace('timeline-table-header-', ''); +} + +Then('the table has {string} and {string} columns', async ({ page }, first, second) => { + await tableFieldIdByName(page, first); + await tableFieldIdByName(page, second); +}); + +Then('the {string} cell of {string} reads {string}', async ({ page }, column, title, text) => { + const fieldId = await tableFieldIdByName(page, column); + + await expect(page.getByTestId(`timeline-table-cell-${rowId(page, title)}-${fieldId}`)).toContainText(text, { + timeout: 15_000, + }); +}); + +Then('the arrow runs from {string} to {string}', async ({ page }, from, to) => { + await expect(TimelineSelectors.arrows(page)).toHaveCount(1, { timeout: 15_000 }); + await expect(TimelineSelectors.arrows(page)).toHaveAttribute('data-link', `${rowId(page, from)}:${rowId(page, to)}`); +}); + +When('I bind the {string} property as the dependency field listing {string}', async ({ page }, column, direction) => { + const fieldId = await tableFieldIdByName(page, column); + + await chooseTimelineSettingsOption(page, `timeline-dependency-field-${fieldId}`); + await chooseTimelineSettingsOption(page, `timeline-dependency-direction-${direction === 'Blocking' ? 1 : 0}`); +}); + +/** Click on the first vertical segment of an arrow's path, which every route has. */ +async function clickArrow(page: Page, from: string, to: string) { + const hit = page.getByTestId(`timeline-arrow-hit-${rowId(page, from)}:${rowId(page, to)}`); + + await expect(hit).toHaveCount(1, { timeout: 15_000 }); + const d = (await hit.getAttribute('d')) ?? ''; + const svgBox = await page.getByTestId('timeline-arrows').boundingBox(); + const tokens = d.split(/\s+/); + let x = 0; + let y = 0; + let point: { x: number; y: number } | null = null; + + for (let i = 0; i < tokens.length && !point; i += 1) { + const command = tokens[i]; + + if (command === 'M') { + x = Number(tokens[i + 1]); + y = Number(tokens[i + 2]); + i += 2; + } else if (command === 'H') { + x = Number(tokens[i + 1]); + i += 1; + } else if (command === 'V') { + point = { x, y: (y + Number(tokens[i + 1])) / 2 }; + } else if (command === 'v') { + point = { x, y: y + Number(tokens[i + 1]) / 2 }; + } + } + + if (!point || !svgBox) throw new Error(`No vertical segment found in arrow path: ${d}`); + await page.mouse.click(svgBox.x + point.x, svgBox.y + point.y); + await expect(page.getByTestId('timeline-link-editor')).toBeVisible({ timeout: 10_000 }); +} + +When('I click the arrow from {string} to {string}', async ({ page }, from, to) => { + await clickArrow(page, from, to); +}); + +Then('the link editor shows {string}', async ({ page }, title) => { + await expect(page.getByTestId('timeline-link-editor-title')).toHaveText(title); +}); + +const LINK_TYPE_INDEX: Record = { FS: 0, SS: 1, FF: 2, SF: 3 }; + +When('I choose the {string} link type', async ({ page }, type) => { + await page.getByTestId(`timeline-link-type-${LINK_TYPE_INDEX[type]}`).click(); + await expect(page.getByTestId(`timeline-link-type-${LINK_TYPE_INDEX[type]}`)).toHaveAttribute('aria-checked', 'true'); + await page.keyboard.press('Escape'); + await expect(page.getByTestId('timeline-link-editor')).toHaveCount(0); +}); + +When('I set the link lag to {int} days', async ({ page }, days) => { + const input = page.getByTestId('timeline-link-lag'); + + await input.fill(String(days)); + await input.press('Enter'); + await page.keyboard.press('Escape'); + await expect(page.getByTestId('timeline-link-editor')).toHaveCount(0); +}); + +When('I remove the dependency from the link editor', async ({ page }) => { + await page.getByTestId('timeline-link-remove').click(); + await expect(page.getByTestId('timeline-link-editor')).toHaveCount(0); +}); + Then('the timeline draws {int} dependency arrow', async ({ page }, count) => { await expect(TimelineSelectors.arrows(page)).toHaveCount(count, { timeout: 15_000 }); }); diff --git a/playwright/e2e/database/timeline.spec.ts b/playwright/e2e/database/timeline.spec.ts index 9236ce9fc..a5eeaa05a 100644 --- a/playwright/e2e/database/timeline.spec.ts +++ b/playwright/e2e/database/timeline.spec.ts @@ -170,12 +170,13 @@ test.describe('Timeline dependencies and progress', () => { await expect.poll(async () => (await barBox(page, 'Design')).box.x, { timeout: 10_000 }).toBeCloseTo(designBefore.box.x, 0); await expect(page.locator('[data-testid="timeline-arrow"]')).toHaveCount(1); - // The dependent cannot be dragged before its dependency's start. + // A finish-to-start dependent cannot be dragged before its dependency ends + // (Design is one day long, so Build stops one column after Design starts). await dragBy(page, buildBefore.box.x + buildBefore.box.width / 2, buildBefore.box.y + buildBefore.box.height / 2, -columnWidth * 6); // Compare against Design's live position: the drag may auto-scroll the canvas. await expect .poll(async () => (await barBox(page, 'Build')).box.x - (await barBox(page, 'Design')).box.x, { timeout: 10_000 }) - .toBeCloseTo(0, 0); + .toBeCloseTo(columnWidth, 0); // Resize Design to three days so the progress handle has room, then drag it. const endHandle = page.getByTestId(`timeline-handle-end-${designId}`); diff --git a/src/@types/translations/en.json b/src/@types/translations/en.json index b845adf9c..e40d4fe3e 100644 --- a/src/@types/translations/en.json +++ b/src/@types/translations/en.json @@ -4391,6 +4391,21 @@ }, "dependencyBlocked": "Cannot start before its dependencies", "linkHandle": "Drag to add a dependency", + "blockedBy": "Blocked by", + "blocking": "Blocking", + "setUpDependencies": "Set up dependencies", + "setUpDependenciesHint": "Adds \"Blocked by\" and \"Blocking\" properties and draws arrows between linked items.", + "dependencyDirection": "Field lists", + "link": { + "title": "Dependency", + "type": "Type", + "finishToStart": "Finish to start", + "startToStart": "Start to start", + "finishToFinish": "Finish to finish", + "startToFinish": "Start to finish", + "lag": "Lag (days)", + "remove": "Remove dependency" + }, "openRow": "Open" } } diff --git a/src/application/database-yjs/__tests__/timeline-layout.test.ts b/src/application/database-yjs/__tests__/timeline-layout.test.ts index ef1dd9cf3..acf305c2a 100644 --- a/src/application/database-yjs/__tests__/timeline-layout.test.ts +++ b/src/application/database-yjs/__tests__/timeline-layout.test.ts @@ -2,7 +2,12 @@ import * as Y from 'yjs'; import { YDatabase, YDatabaseView, YjsDatabaseKey, YjsEditorKey } from '@/application/types'; -import { TimelineDependencyShift, TimelineLayout } from '../database.type'; +import { + TimelineDependencyDirection, + TimelineDependencyShift, + TimelineDependencyType, + TimelineLayout, +} from '../database.type'; import { createTimelineLayoutStore, initializeTimelineLayoutSetting, @@ -38,6 +43,8 @@ test('missing setting falls back to month scale, docked table, and the user week firstDayOfWeek: 1, endFieldId: '', dependencyFieldId: '', + dependencyDirection: TimelineDependencyDirection.BlockedBy, + dependencyLinks: {}, dependencyShift: TimelineDependencyShift.OverlapOnly, avoidWeekends: false, progressFieldId: '', @@ -87,6 +94,8 @@ test('integers written by the server as BigInt decode like web numbers', () => { firstDayOfWeek: 1, endFieldId: '', dependencyFieldId: '', + dependencyDirection: TimelineDependencyDirection.BlockedBy, + dependencyLinks: {}, dependencyShift: TimelineDependencyShift.OverlapOnly, avoidWeekends: false, progressFieldId: '', @@ -172,6 +181,8 @@ test('the store notifies on remote changes only for this view and tolerates bad firstDayOfWeek: 1, endFieldId: '', dependencyFieldId: '', + dependencyDirection: TimelineDependencyDirection.BlockedBy, + dependencyLinks: {}, dependencyShift: TimelineDependencyShift.OverlapOnly, avoidWeekends: false, progressFieldId: '', @@ -225,3 +236,31 @@ test('the scale is stored under layout_ty, the key the calendar uses for its mod expect(setting.get(YjsDatabaseKey.layout_ty)).toBe(TimelineLayout.Quarter); expect(setting.has('zoom')).toBe(false); }); + +test('dependency direction and per-link type / lag round-trip; default links need no entry', () => { + const { doc, view, database } = createFixture(); + + doc.transact(() => + updateTimelineLayoutSetting(view, { + fieldId: 'date', + dependencyDirection: TimelineDependencyDirection.Blocking, + dependencyLinks: { + 'a:b': { type: TimelineDependencyType.StartToStart, lag: 2 }, + 'b:c': { type: TimelineDependencyType.FinishToStart, lag: 0 }, + 'c:d': { type: TimelineDependencyType.FinishToStart, lag: -1 }, + }, + }) + ); + const read = readTimelineLayoutSetting(database, 'timeline', 0, false); + + expect(read.dependencyDirection).toBe(TimelineDependencyDirection.Blocking); + expect(read.dependencyLinks).toEqual({ + 'a:b': { type: TimelineDependencyType.StartToStart, lag: 2 }, + 'c:d': { type: TimelineDependencyType.FinishToStart, lag: -1 }, + }); + const setting = view.get(YjsDatabaseKey.layout_settings).get(TIMELINE_LAYOUT_KEY); + + expect(setting.get(YjsDatabaseKey.dependency_links)).toEqual({ 'a:b': { ty: 1, lag: 2 }, 'c:d': { ty: 0, lag: -1 } }); + doc.transact(() => updateTimelineLayoutSetting(view, { dependencyLinks: {} })); + expect(setting.has(YjsDatabaseKey.dependency_links)).toBe(false); +}); diff --git a/src/application/database-yjs/database.type.ts b/src/application/database-yjs/database.type.ts index 7e3d85aec..e6ea383b7 100644 --- a/src/application/database-yjs/database.type.ts +++ b/src/application/database-yjs/database.type.ts @@ -129,6 +129,34 @@ export enum TimelineDependencyShift { Never = 2, } +/** Which side of the relation the bound dependency field lists, stored as `dependency_direction`. */ +export enum TimelineDependencyDirection { + /** The field's cells list the rows this row depends on ("Blocked by"). */ + BlockedBy = 0, + /** The field's cells list the rows that depend on this row ("Blocking"). */ + Blocking = 1, +} + +/** Classic scheduling link types, stored per link as `ty` in `dependency_links`. */ +export enum TimelineDependencyType { + /** The successor starts once the predecessor finishes (default). */ + FinishToStart = 0, + StartToStart = 1, + FinishToFinish = 2, + StartToFinish = 3, +} + +export interface TimelineDependencyLink { + type: TimelineDependencyType; + /** Whole days the successor is held after the constraint is met; negative = lead. */ + lag: number; +} + +/** Key of `dependency_links` for the link from `predecessorId` to `successorId`. */ +export function timelineLinkKey(predecessorId: string, successorId: string) { + return `${predecessorId}:${successorId}`; +} + export interface TimelineLayoutSetting { /// DateTime field plotted on the timeline. fieldId: string; @@ -142,6 +170,10 @@ export interface TimelineLayoutSetting { endFieldId: string; /// Relation field (pointing at this database) whose linked rows are the row's dependencies. dependencyFieldId: string; + /// Whether `dependencyFieldId` lists predecessors ("Blocked by") or successors ("Blocking"). + dependencyDirection: TimelineDependencyDirection; + /// Per-link type and lag, keyed by `timelineLinkKey`; a missing entry is finish-to-start, no lag. + dependencyLinks: Record; /// How dependents move when the bar they depend on is dragged. dependencyShift: TimelineDependencyShift; /// Shifted dependents never land on a Saturday or Sunday. diff --git a/src/application/database-yjs/dispatch/relation.ts b/src/application/database-yjs/dispatch/relation.ts index a2e6e3264..b0ac39ed4 100644 --- a/src/application/database-yjs/dispatch/relation.ts +++ b/src/application/database-yjs/dispatch/relation.ts @@ -222,7 +222,7 @@ function setRelationTypeOption(field: YDatabaseField, option: RelationTypeOption field.set(YjsDatabaseKey.last_modified, String(dayjs().unix())); } -function addFieldToAllViews(database: YDatabase, fieldId: FieldId) { +export function addFieldToAllViews(database: YDatabase, fieldId: FieldId) { const views = database.get(YjsDatabaseKey.views); const viewIds = Object.keys(views?.toJSON() ?? {}); diff --git a/src/application/database-yjs/dispatch/timeline-dependencies.ts b/src/application/database-yjs/dispatch/timeline-dependencies.ts new file mode 100644 index 000000000..61908e74b --- /dev/null +++ b/src/application/database-yjs/dispatch/timeline-dependencies.ts @@ -0,0 +1,120 @@ +import { nanoid } from 'nanoid'; +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import * as Y from 'yjs'; + +import { useDatabase, useDatabaseViewId, useReadOnly, useSharedRoot } from '@/application/database-yjs/context'; +import { FieldType, FieldVisibility, TimelineDependencyDirection } from '@/application/database-yjs/database.type'; +import { addFieldToAllViews } from '@/application/database-yjs/dispatch/relation'; +import { parseRelationTypeOption } from '@/application/database-yjs/fields/relation/parse'; +import { createRelationField } from '@/application/database-yjs/fields/relation/utils'; +import { executeDatabaseOperations as executeOperations } from '@/application/database-yjs/history'; +import { readTimelineLayoutSetting, updateTimelineLayoutSetting } from '@/application/database-yjs/timeline-layout'; +import { YDatabaseField, YDatabaseFieldSetting, YjsDatabaseKey } from '@/application/types'; + +/** + * Notion's one-click dependencies: bind a two-way self-relation pair + * ("Blocked by" / "Blocking") to the timeline, creating the pair when the + * database has none. Both properties become table columns; neither is drawn + * as a bar chip. Resolves with the bound "Blocked by" field id. + */ +export function useSetUpTimelineDependenciesDispatch() { + const { t } = useTranslation(); + const database = useDatabase(); + const viewId = useDatabaseViewId(); + const readOnly = useReadOnly(); + const sharedRoot = useSharedRoot(); + + return useCallback((): string | undefined => { + const view = database?.get(YjsDatabaseKey.views)?.get(viewId); + const fields = database?.get(YjsDatabaseKey.fields); + const databaseId = database?.get(YjsDatabaseKey.id) as string | undefined; + + if (readOnly || !view || !fields || !databaseId) return undefined; + const current = readTimelineLayoutSetting(database, viewId, 0, false); + + if (current.dependencyFieldId && fields.has(current.dependencyFieldId)) return current.dependencyFieldId; + + // Reuse an existing two-way self-relation pair rather than adding a second one. + let blockedById = ''; + let blockingId = ''; + + fields.forEach((field: YDatabaseField, fieldId: string) => { + if (blockedById || Number(field.get(YjsDatabaseKey.type)) !== FieldType.Relation) return; + const option = parseRelationTypeOption(field); + + if ( + option.database_id === databaseId && + option.is_two_way && + option.reciprocal_field_id && + fields.has(option.reciprocal_field_id) + ) { + blockedById = fieldId; + blockingId = option.reciprocal_field_id; + } + }); + + const creating = !blockedById; + + if (creating) { + blockedById = nanoid(6); + blockingId = nanoid(6); + } + + executeOperations( + sharedRoot, + [ + () => { + if (creating) { + fields.set( + blockedById, + createRelationField(blockedById, { + name: t('timeline.blockedBy', { defaultValue: 'Blocked by' }), + database_id: databaseId, + is_two_way: true, + reciprocal_field_id: blockingId, + }) + ); + fields.set( + blockingId, + createRelationField(blockingId, { + name: t('timeline.blocking', { defaultValue: 'Blocking' }), + database_id: databaseId, + is_two_way: true, + reciprocal_field_id: blockedById, + }) + ); + addFieldToAllViews(database, blockedById); + addFieldToAllViews(database, blockingId); + } + + // Keep the pair off this view's bars; the arrows already show it. + const fieldSettings = view.get(YjsDatabaseKey.field_settings); + + [blockedById, blockingId].forEach((fieldId) => { + if (!fieldSettings) return; + let setting = fieldSettings.get(fieldId); + + if (!setting) { + setting = new Y.Map() as YDatabaseFieldSetting; + fieldSettings.set(fieldId, setting); + } + + setting.set(YjsDatabaseKey.visibility, FieldVisibility.AlwaysHidden); + }); + + const tableFieldIds = current.tableFieldIds.filter((id) => id !== blockedById && id !== blockingId); + + updateTimelineLayoutSetting(view, { + dependencyFieldId: blockedById, + dependencyDirection: TimelineDependencyDirection.BlockedBy, + tableFieldIds: [...tableFieldIds, blockedById, blockingId], + }); + }, + ], + 'setUpTimelineDependencies' + ); + + return blockedById; + }, [database, readOnly, sharedRoot, t, viewId]); +} diff --git a/src/application/database-yjs/timeline-layout.ts b/src/application/database-yjs/timeline-layout.ts index 388766933..05e421f59 100644 --- a/src/application/database-yjs/timeline-layout.ts +++ b/src/application/database-yjs/timeline-layout.ts @@ -9,7 +9,14 @@ import { YjsEditorKey, } from '@/application/types'; -import { TimelineDependencyShift, TimelineLayout, TimelineLayoutSetting } from './database.type'; +import { + TimelineDependencyDirection, + TimelineDependencyLink, + TimelineDependencyShift, + TimelineDependencyType, + TimelineLayout, + TimelineLayoutSetting, +} from './database.type'; /** Layout-settings key for `DatabaseViewLayout.Timeline`. */ export const TIMELINE_LAYOUT_KEY = '8'; @@ -19,6 +26,36 @@ export const DEFAULT_TIMELINE_SHOW_TABLE = true; export const DEFAULT_TIMELINE_DEPENDENCY_SHIFT = TimelineDependencyShift.OverlapOnly; const EMPTY_IDS: string[] = []; +const EMPTY_LINKS: Record = {}; + +/** + * Per-link metadata as stored (a plain map of `{ ty, lag }` records). Unknown + * types fall back to finish-to-start and lag is clamped to whole days. + */ +function linkMap(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return EMPTY_LINKS; + const result: Record = {}; + + Object.entries(value as Record).forEach(([key, raw]) => { + if (!raw || typeof raw !== 'object') return; + const record = raw as { ty?: unknown; lag?: unknown }; + const type = integer(record.ty, TimelineDependencyType.FinishToStart, TimelineDependencyType.StartToFinish); + const lag = typeof record.lag === 'number' || typeof record.lag === 'bigint' ? Math.trunc(Number(record.lag)) : 0; + + result[key] = { type: type ?? TimelineDependencyType.FinishToStart, lag: Number.isFinite(lag) ? lag : 0 }; + }); + + return Object.keys(result).length === 0 ? EMPTY_LINKS : result; +} + +function sameLinks(a: Record, b: Record) { + const keys = Object.keys(a); + + return ( + keys.length === Object.keys(b).length && + keys.every((key) => b[key] !== undefined && a[key].type === b[key].type && a[key].lag === b[key].lag) + ); +} /** A plain array of ids as Yjs / Yrs hand it back, or nothing. */ function idList(value: unknown): string[] { @@ -59,6 +96,11 @@ export function readTimelineLayoutSetting( TimelineDependencyShift.OverlapOnly, TimelineDependencyShift.Never ); + const dependencyDirection = integer( + setting?.get(YjsDatabaseKey.dependency_direction), + TimelineDependencyDirection.BlockedBy, + TimelineDependencyDirection.Blocking + ); const weekday = integer(setting?.get(YjsDatabaseKey.first_day_of_week_v2), 0, 6) ?? integer(setting?.get(YjsDatabaseKey.first_day_of_week), 0, 6); @@ -71,6 +113,8 @@ export function readTimelineLayoutSetting( use24Hour, endFieldId: setting?.get(YjsDatabaseKey.end_field_id) ?? '', dependencyFieldId: setting?.get(YjsDatabaseKey.dependency_field_id) ?? '', + dependencyDirection: dependencyDirection ?? TimelineDependencyDirection.BlockedBy, + dependencyLinks: linkMap(setting?.get(YjsDatabaseKey.dependency_links)), dependencyShift: dependencyShift ?? DEFAULT_TIMELINE_DEPENDENCY_SHIFT, avoidWeekends: typeof avoidWeekends === 'boolean' ? avoidWeekends : false, progressFieldId: setting?.get(YjsDatabaseKey.progress_field_id) ?? '', @@ -126,6 +170,27 @@ export function updateTimelineLayoutSetting(view: YDatabaseView, settings: Timel } if (settings.dependencyShift !== undefined) setting.set(YjsDatabaseKey.dependency_shift_ty, settings.dependencyShift); + if (settings.dependencyDirection !== undefined) { + setting.set(YjsDatabaseKey.dependency_direction, settings.dependencyDirection); + } + + if (settings.dependencyLinks !== undefined) { + // Stored as a plain map so Yrs reads it as nested `Any` maps; finish-to-start + // links with no lag are the default and need no entry. + const entries = Object.entries(settings.dependencyLinks).filter( + ([, link]) => link.type !== TimelineDependencyType.FinishToStart || link.lag !== 0 + ); + + if (entries.length > 0) { + setting.set( + YjsDatabaseKey.dependency_links, + Object.fromEntries(entries.map(([key, link]) => [key, { ty: link.type, lag: link.lag }])) + ); + } else { + setting.delete(YjsDatabaseKey.dependency_links); + } + } + if (settings.avoidWeekends !== undefined) setting.set(YjsDatabaseKey.avoid_weekends, settings.avoidWeekends); if (settings.tableFieldIds !== undefined) { if (settings.tableFieldIds.length > 0) setting.set(YjsDatabaseKey.table_field_ids, [...settings.tableFieldIds]); @@ -176,7 +241,11 @@ export function createTimelineLayoutStore( if ( (Object.keys(next) as (keyof TimelineLayoutSetting)[]).some((key) => - key === 'tableFieldIds' ? !sameIds(next.tableFieldIds, snapshot.tableFieldIds) : next[key] !== snapshot[key] + key === 'tableFieldIds' + ? !sameIds(next.tableFieldIds, snapshot.tableFieldIds) + : key === 'dependencyLinks' + ? !sameLinks(next.dependencyLinks, snapshot.dependencyLinks) + : next[key] !== snapshot[key] ) ) snapshot = next; diff --git a/src/application/types.ts b/src/application/types.ts index 95284d655..4ef77fb79 100644 --- a/src/application/types.ts +++ b/src/application/types.ts @@ -672,6 +672,10 @@ export enum YjsDatabaseKey { end_field_id = 'end_field_id', /// Timeline layout setting: how dependents move with a dragged bar (`TimelineDependencyShift`). dependency_shift_ty = 'dependency_shift_ty', + /// Timeline layout setting: which side of the relation `dependency_field_id` lists. + dependency_direction = 'dependency_direction', + /// Timeline layout setting: per-link type and lag, keyed "predecessor:successor". + dependency_links = 'dependency_links', /// Timeline layout setting: shifted dependents skip Saturdays and Sundays. avoid_weekends = 'avoid_weekends', /// Timeline layout setting: properties shown as columns of the docked table. @@ -1036,11 +1040,12 @@ export interface YDatabaseTimelineLayoutSetting extends Y.Map { | YjsDatabaseKey.first_day_of_week | YjsDatabaseKey.first_day_of_week_v2 | YjsDatabaseKey.dependency_shift_ty + | YjsDatabaseKey.dependency_direction ): number | bigint | null | undefined; get( key: YjsDatabaseKey.show_table | YjsDatabaseKey.avoid_weekends | YjsDatabaseKey.hide_empty_groups ): boolean | undefined; - get(key: YjsDatabaseKey.table_field_ids): unknown; + get(key: YjsDatabaseKey.table_field_ids | YjsDatabaseKey.dependency_links): unknown; } export interface YDatabaseChartLayoutSetting extends Y.Map { diff --git a/src/components/database/components/settings/TimelineLayoutSettings.tsx b/src/components/database/components/settings/TimelineLayoutSettings.tsx index 4d8b28a81..f4cb60a83 100644 --- a/src/components/database/components/settings/TimelineLayoutSettings.tsx +++ b/src/components/database/components/settings/TimelineLayoutSettings.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'; import { FieldType, parseRelationTypeOption, + TimelineDependencyDirection, TimelineDependencyShift, useDatabase, useDatabaseFields, @@ -13,7 +14,9 @@ import { useTimelineLayoutSetting, } from '@/application/database-yjs'; import { useUpdateTimelineSetting } from '@/application/database-yjs/dispatch'; +import { useSetUpTimelineDependenciesDispatch } from '@/application/database-yjs/dispatch/timeline-dependencies'; import { YjsDatabaseKey } from '@/application/types'; +import { ReactComponent as PlusIcon } from '@/assets/icons/plus.svg'; import { ReactComponent as TimelineIcon } from '@/assets/icons/timeline.svg'; import { FieldDisplay } from '@/components/database/components/field'; import { @@ -30,6 +33,12 @@ import { Switch } from '@/components/ui/switch'; const DATE_FIELD_TYPES = [FieldType.DateTime, FieldType.LastEditedTime, FieldType.CreatedTime]; +// Which side of the relation the bound field lists. +const DIRECTION_OPTIONS = [ + { value: TimelineDependencyDirection.BlockedBy, labelKey: 'timeline.blockedBy', fallback: 'Blocked by' }, + { value: TimelineDependencyDirection.Blocking, labelKey: 'timeline.blocking', fallback: 'Blocking' }, +]; + // Notion's "Shift dependents" choices, in its order. const SHIFT_OPTIONS = [ { @@ -49,6 +58,7 @@ function TimelineLayoutSettings() { const { t } = useTranslation(); const setting = useTimelineLayoutSetting(); const updateSetting = useUpdateTimelineSetting(); + const setUpDependencies = useSetUpTimelineDependenciesDispatch(); const database = useDatabase(); const fields = useDatabaseFields(); const databaseId = database?.get(YjsDatabaseKey.id); @@ -231,8 +241,41 @@ function TimelineLayoutSettings() { (dependencyFieldId) => updateSetting({ dependencyFieldId }) )} + {!setting.dependencyFieldId ? ( + // Notion's one click: creates "Blocked by" / "Blocking" and binds them. + { + e.preventDefault(); + setUpDependencies(); + }} + > + + {t('timeline.setUpDependencies', { defaultValue: 'Set up dependencies' })} + + ) : null} + {setting.dependencyFieldId ? ( <> + {t('timeline.dependencyDirection', { defaultValue: 'Field lists' })} + {DIRECTION_OPTIONS.map((option) => ( + { + e.preventDefault(); + updateSetting({ dependencyDirection: option.value }); + }} + > + {t(option.labelKey, { defaultValue: option.fallback })} + {setting.dependencyDirection === option.value && } + + ))} {t('timeline.settings.shiftDependents', { defaultValue: 'Shift dependents' })} diff --git a/src/components/database/timeline/TimelineArrows.tsx b/src/components/database/timeline/TimelineArrows.tsx index 4e357f47f..54842db5e 100644 --- a/src/components/database/timeline/TimelineArrows.tsx +++ b/src/components/database/timeline/TimelineArrows.tsx @@ -1,10 +1,18 @@ -import { memo, useMemo } from 'react'; +import { memo, MouseEvent, useMemo } from 'react'; import { TIMELINE_BAR_INSET, TIMELINE_ROW_HEIGHT } from './constants'; import { TimelineLinkDrag } from './hooks/useTimelineLinkDrag'; -import { DependencyGraph, dependencyArrowPath } from './scale/dependencies'; +import { DependencyGraph, dependencyLinkPath, linkOf } from './scale/dependencies'; import { BarRect } from './scale/geometry'; +export interface TimelineLinkSelection { + predecessorId: string; + successorId: string; + /** Click position in canvas coordinates, where the link editor anchors. */ + x: number; + y: number; +} + interface TimelineArrowsProps { rowIds: string[]; rects: (BarRect | null)[]; @@ -18,6 +26,10 @@ interface TimelineArrowsProps { left: number; /** A connector being dragged from a bar's link handle. */ pending?: TimelineLinkDrag | null; + /** Arrows are clickable (editors only): opens the link editor. */ + onSelectLink?: (selection: TimelineLinkSelection) => void; + /** The link currently open in the editor, drawn highlighted. */ + selectedKey?: string; } /** @@ -35,10 +47,12 @@ export const TimelineArrows = memo( bodyHeight, left, pending, + onSelectLink, + selectedKey, }: TimelineArrowsProps) => { const paths = useMemo(() => { const indexOf = new Map(rowIds.map((rowId, index) => [rowId, index] as const)); - const result: { key: string; d: string }[] = []; + const result: { key: string; d: string; predecessorId: string; successorId: string }[] = []; graph.predecessors.forEach((predecessors, rowId) => { const toIndex = indexOf.get(rowId); @@ -56,8 +70,11 @@ export const TimelineArrows = memo( if (!touchesWindow) return; result.push({ - key: `${predecessor}->${rowId}`, - d: dependencyArrowPath( + key: `${predecessor}:${rowId}`, + predecessorId: predecessor, + successorId: rowId, + d: dependencyLinkPath( + linkOf(graph, predecessor, rowId).type, { rect: fromRect, index: fromIndex }, { rect: toRect, index: toIndex }, { rowHeight: TIMELINE_ROW_HEIGHT, barInset: TIMELINE_BAR_INSET } @@ -71,24 +88,52 @@ export const TimelineArrows = memo( if (paths.length === 0 && !pending) return null; + const handleClick = (event: MouseEvent, path: (typeof paths)[number]) => { + if (!onSelectLink) return; + event.stopPropagation(); + const bounds = event.currentTarget.ownerSVGElement?.getBoundingClientRect(); + + onSelectLink({ + predecessorId: path.predecessorId, + successorId: path.successorId, + x: event.clientX - (bounds?.left ?? 0), + y: event.clientY - (bounds?.top ?? 0), + }); + }; + return ( + // Above the bars so links can be clicked; only the strokes take pointer events. {paths.map((path) => ( - + + + {onSelectLink ? ( + handleClick(event, path)} + /> + ) : null} + ))} {pending ? ( diff --git a/src/components/database/timeline/TimelineLinkEditor.tsx b/src/components/database/timeline/TimelineLinkEditor.tsx new file mode 100644 index 000000000..9ec881c99 --- /dev/null +++ b/src/components/database/timeline/TimelineLinkEditor.tsx @@ -0,0 +1,167 @@ +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { TimelineDependencyLink, TimelineDependencyType } from '@/application/database-yjs'; +import { ReactComponent as DeleteIcon } from '@/assets/icons/delete.svg'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'; +import { cn } from '@/lib/utils'; + +import { TimelineLinkSelection } from './TimelineArrows'; + +const LINK_TYPES: { value: TimelineDependencyType; short: string; labelKey: string; fallback: string }[] = [ + { + value: TimelineDependencyType.FinishToStart, + short: 'FS', + labelKey: 'timeline.link.finishToStart', + fallback: 'Finish to start', + }, + { + value: TimelineDependencyType.StartToStart, + short: 'SS', + labelKey: 'timeline.link.startToStart', + fallback: 'Start to start', + }, + { + value: TimelineDependencyType.FinishToFinish, + short: 'FF', + labelKey: 'timeline.link.finishToFinish', + fallback: 'Finish to finish', + }, + { + value: TimelineDependencyType.StartToFinish, + short: 'SF', + labelKey: 'timeline.link.startToFinish', + fallback: 'Start to finish', + }, +]; + +interface TimelineLinkEditorProps { + selection: TimelineLinkSelection | null; + link: TimelineDependencyLink; + predecessorTitle: string; + successorTitle: string; + readOnly: boolean; + onChange: (link: TimelineDependencyLink) => void; + onRemove: () => void; + onClose: () => void; +} + +/** + * Popover opened by clicking a dependency arrow: the link's type (finish / + * start-to-start / finish), its lag in days (negative = lead) and removal. + * Anchored at the click point inside the canvas body. + */ +export function TimelineLinkEditor({ + selection, + link, + predecessorTitle, + successorTitle, + readOnly, + onChange, + onRemove, + onClose, +}: TimelineLinkEditorProps) { + const { t } = useTranslation(); + const [lagText, setLagText] = useState(String(link.lag)); + + useEffect(() => { + setLagText(String(link.lag)); + }, [link.lag, selection?.predecessorId, selection?.successorId]); + + const commitLag = () => { + const lag = Math.trunc(Number(lagText)); + + if (!Number.isFinite(lag) || lag === link.lag) { + setLagText(String(link.lag)); + return; + } + + onChange({ ...link, lag }); + }; + + return ( + !open && onClose()}> + + + + event.preventDefault()} + > +
+ {t('timeline.link.title', { defaultValue: 'Dependency' })} +
+
+ {predecessorTitle} → {successorTitle} +
+ +
{t('timeline.link.type', { defaultValue: 'Type' })}
+
+ {LINK_TYPES.map((option) => ( + + ))} +
+ + + setLagText(event.target.value)} + onBlur={commitLag} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + commitLag(); + } + }} + /> + + {!readOnly ? ( + + ) : null} +
+ + ); +} diff --git a/src/components/database/timeline/TimelineView.tsx b/src/components/database/timeline/TimelineView.tsx index 589cfaef6..d2c64e124 100644 --- a/src/components/database/timeline/TimelineView.tsx +++ b/src/components/database/timeline/TimelineView.tsx @@ -8,7 +8,11 @@ import { useTranslation } from 'react-i18next'; import { FieldVisibility, isAIFieldType, + TimelineDependencyDirection, + TimelineDependencyLink, + TimelineDependencyType, TimelineLayoutSetting, + timelineLinkKey, TimelineLayout, useDatabaseContext, useDatabaseViewId, @@ -20,6 +24,7 @@ import { } from '@/application/database-yjs'; import { useUpdateAnyCellDispatch, useUpdateStartEndTimeCell } from '@/application/database-yjs/dispatch/cell'; import { useUpdateRelationCellDispatch } from '@/application/database-yjs/dispatch/relation'; +import { useSetUpTimelineDependenciesDispatch } from '@/application/database-yjs/dispatch/timeline-dependencies'; import { useNewRowDispatch, useReorderRowDispatch } from '@/application/database-yjs/dispatch/row'; import { useUpdateTimelineSetting } from '@/application/database-yjs/dispatch'; import { YjsDatabaseKey } from '@/application/types'; @@ -48,14 +53,20 @@ import { TIMELINE_TODAY_ANCHOR, } from './constants'; import { useScrollWindow } from './hooks/useScrollWindow'; -import { TimelineDragMode, TimelineDragPreview, TimelineDragSpan, useTimelineDrag } from './hooks/useTimelineDrag'; +import { + constraintStart, + TimelineDragMode, + TimelineDragPreview, + TimelineDragSpan, + useTimelineDrag, +} from './hooks/useTimelineDrag'; import { useTimelineItems } from './hooks/useTimelineItems'; import { useTimelineLinkDrag } from './hooks/useTimelineLinkDrag'; import { parseProgressPercent, parseRelationRowIds, useTimelineFieldValues } from './hooks/useTimelineFieldValues'; import { useTimelinePermissions } from './hooks/useTimelinePermissions'; import { useTimelineRange } from './hooks/useTimelineRange'; import { TimelineRowModel, useTimelineRows } from './hooks/useTimelineRows'; -import { buildDependencyGraph, collectDependents } from './scale/dependencies'; +import { buildDependencyGraph, collectDependents, linkOf } from './scale/dependencies'; import { buildHeaderColumns, buildHeaderSegments, @@ -70,7 +81,8 @@ import { totalWidth, xToDate, } from './scale/geometry'; -import { TimelineArrows } from './TimelineArrows'; +import { TimelineArrows, TimelineLinkSelection } from './TimelineArrows'; +import { TimelineLinkEditor } from './TimelineLinkEditor'; import { TimelineBarDragLabel } from './TimelineBar'; import { TimelineToolbar } from './TimelineToolbar'; import { TimelineGrid } from './TimelineGrid'; @@ -172,7 +184,14 @@ export function TimelineView({ setting }: { setting: TimelineLayoutSetting }) { const relations = useTimelineFieldValues(setting.dependencyFieldId, parseRelationRowIds); const progressValues = useTimelineFieldValues(setting.progressFieldId, parseProgressPercent); const rowIds = useMemo(() => rows.map((row) => row.rowId), [rows]); - const graph = useMemo(() => buildDependencyGraph(rowIds, relations), [relations, rowIds]); + const graph = useMemo( + () => + buildDependencyGraph(rowIds, relations, { + direction: setting.dependencyDirection, + links: setting.dependencyLinks, + }), + [relations, rowIds, setting.dependencyDirection, setting.dependencyLinks] + ); const { geometry, handleScroll, scrollToDate, scrollByColumns } = useTimelineRange({ layout, scrollerRef, @@ -320,19 +339,40 @@ export function TimelineView({ setting }: { setting: TimelineLayoutSetting }) { rowId: dependent.rowId, allDay: dependent.allDay, ...getBarSpan(dependent.start, dependent.end, dependent.allDay), - predecessors: (graph.predecessors.get(dependentId) ?? []).filter((id) => movingSet.has(id)), + predecessors: (graph.predecessors.get(dependentId) ?? []) + .filter((id) => movingSet.has(id)) + .map((id) => ({ rowId: id, ...linkOf(graph, id, dependentId) })), }, ]; }); - // A bar cannot start before any of its dependencies start. - const minStart = (graph.predecessors.get(row.rowId) ?? []).reduce((latest, predecessorId) => { - const predecessor = byId.get(predecessorId); + // A bar cannot violate its links: start-type links bound its start, + // end-type links its end (each including the link's lag). + const dragged = { rowId: row.rowId, allDay: row.allDay, ...span }; + let minStart: Date | undefined; + let minEnd: Date | undefined; - if (!predecessor?.start) return latest; - const predecessorStart = getBarSpan(predecessor.start, predecessor.end, predecessor.allDay).start; + (graph.predecessors.get(row.rowId) ?? []).forEach((predecessorId) => { + const predecessor = byId.get(predecessorId); - return !latest || predecessorStart > latest ? predecessorStart : latest; - }, undefined); + if (!predecessor?.start) return; + const link = linkOf(graph, predecessorId, row.rowId); + const predecessorSpan = { + rowId: predecessorId, + allDay: predecessor.allDay, + ...getBarSpan(predecessor.start, predecessor.end, predecessor.allDay), + }; + const earliestStart = constraintStart(link, predecessorSpan, dragged); + const endType = + link.type === TimelineDependencyType.FinishToFinish || link.type === TimelineDependencyType.StartToFinish; + + if (endType) { + const earliestEnd = new Date(earliestStart.getTime() + (span.endExclusive.getTime() - span.start.getTime())); + + if (!minEnd || earliestEnd > minEnd) minEnd = earliestEnd; + } else if (!minStart || earliestStart > minStart) { + minStart = earliestStart; + } + }); startDrag( event, @@ -344,6 +384,7 @@ export function TimelineView({ setting }: { setting: TimelineLayoutSetting }) { shift: setting.dependencyShift, avoidWeekends: setting.avoidWeekends, minStart, + minEnd, progress: progressValues.get(row.rowId) ?? 0, }, mode @@ -388,19 +429,81 @@ export function TimelineView({ setting }: { setting: TimelineLayoutSetting }) { graphRef.current = graph; // Dropping a bar's connector on another bar makes the target depend on the // source: the source row id is appended to the target's relation cell. + // The relation cell that stores a link depends on which side the bound + // field lists: "Blocked by" writes the predecessor into the successor's + // cell, "Blocking" the successor into the predecessor's. + const setUpDependencies = useSetUpTimelineDependenciesDispatch(); + const writeLink = useCallback( + ( + predecessorId: string, + successorId: string, + change: 'insert' | 'remove', + binding: { fieldId: string; direction: TimelineDependencyDirection } = { + fieldId: setting.dependencyFieldId, + direction: setting.dependencyDirection, + } + ) => { + if (!binding.fieldId) return; + const listsSuccessors = binding.direction === TimelineDependencyDirection.Blocking; + const [ownerId, otherId] = listsSuccessors ? [predecessorId, successorId] : [successorId, predecessorId]; + + void updateRelationCell(ownerId, binding.fieldId, { + [change === 'insert' ? 'insertedRowIds' : 'removedRowIds']: [otherId], + }).catch(() => undefined); + }, + [setting.dependencyDirection, setting.dependencyFieldId, updateRelationCell] + ); const handleLinkCommit = useCallback( (sourceRowId: string, targetRowId: string) => { const current = graphRef.current; - if (!setting.dependencyFieldId || sourceRowId === targetRowId) return; + if (sourceRowId === targetRowId) return; if (current.predecessors.get(targetRowId)?.includes(sourceRowId)) return; // Refuse a link that would close a cycle (the source already depends on the target). if (collectDependents(targetRowId, current).includes(sourceRowId)) return; - void updateRelationCell(targetRowId, setting.dependencyFieldId, { insertedRowIds: [sourceRowId] }).catch( - () => undefined - ); + if (setting.dependencyFieldId) { + writeLink(sourceRowId, targetRowId, 'insert'); + return; + } + + // First connector on a view without dependencies: set the pair up, like Notion. + const fieldId = setUpDependencies(); + + if (fieldId) { + writeLink(sourceRowId, targetRowId, 'insert', { fieldId, direction: TimelineDependencyDirection.BlockedBy }); + } + }, + [setUpDependencies, setting.dependencyFieldId, writeLink] + ); + + // Clicking an arrow opens the link editor (type, lag, remove). + const [selectedLink, setSelectedLink] = useState(null); + const selectedLinkKey = selectedLink ? timelineLinkKey(selectedLink.predecessorId, selectedLink.successorId) : ''; + const selectedLinkMeta = selectedLink ? linkOf(graph, selectedLink.predecessorId, selectedLink.successorId) : null; + const handleLinkChange = useCallback( + (next: TimelineDependencyLink) => { + if (!selectedLink) return; + updateSetting({ + dependencyLinks: { + ...setting.dependencyLinks, + [timelineLinkKey(selectedLink.predecessorId, selectedLink.successorId)]: next, + }, + }); }, - [setting.dependencyFieldId, updateRelationCell] + [selectedLink, setting.dependencyLinks, updateSetting] + ); + const handleLinkRemove = useCallback(() => { + if (!selectedLink) return; + const { [selectedLinkKey]: removed, ...rest } = setting.dependencyLinks; + + void removed; + if (selectedLinkKey in setting.dependencyLinks) updateSetting({ dependencyLinks: rest }); + writeLink(selectedLink.predecessorId, selectedLink.successorId, 'remove'); + setSelectedLink(null); + }, [selectedLink, selectedLinkKey, setting.dependencyLinks, updateSetting, writeLink]); + const rowTitle = useCallback( + (rowId: string) => rowsRef.current.find((row) => row.rowId === rowId)?.title || t('grid.row.titlePlaceholder'), + [t] ); const { link, startLink } = useTimelineLinkDrag({ scrollerRef, sidebarWidth, onCommit: handleLinkCommit }); const handleLinkPointerDown = useCallback( @@ -602,6 +705,20 @@ export function TimelineView({ setting }: { setting: TimelineLayoutSetting }) { canvasWidth={canvasWidth} bodyHeight={bodyHeight} left={sidebarWidth} + onSelectLink={setSelectedLink} + selectedKey={selectedLinkKey} + /> + ) : null} + {selectedLink && selectedLinkMeta ? ( + setSelectedLink(null)} /> ) : null} @@ -684,7 +801,7 @@ export function TimelineView({ setting }: { setting: TimelineLayoutSetting }) { onDropRow={permissions.editable && !grouping.isGrouped ? handleDropRow : undefined} groupFieldId={grouping.isGrouped ? grouping.fieldId : undefined} groupId={item.groupId} - linkable={permissions.editable && Boolean(setting.dependencyFieldId)} + linkable={permissions.editable} linkTarget={link?.targetRowId === row.rowId} onLinkPointerDown={handleLinkPointerDown} /> diff --git a/src/components/database/timeline/__tests__/dependencies.test.ts b/src/components/database/timeline/__tests__/dependencies.test.ts index c4360eb9e..b1029d9a5 100644 --- a/src/components/database/timeline/__tests__/dependencies.test.ts +++ b/src/components/database/timeline/__tests__/dependencies.test.ts @@ -1,7 +1,18 @@ -import { TimelineDependencyShift, TimelineLayout } from '@/application/database-yjs'; - -import { applyDragDelta } from '../hooks/useTimelineDrag'; -import { buildDependencyGraph, collectDependents, dependencyArrowPath } from '../scale/dependencies'; +import { + TimelineDependencyDirection, + TimelineDependencyShift, + TimelineDependencyType, + TimelineLayout, +} from '@/application/database-yjs'; + +import { applyDragDelta, constraintStart } from '../hooks/useTimelineDrag'; +import { + buildDependencyGraph, + collectDependents, + dependencyArrowPath, + dependencyLinkPath, + linkOf, +} from '../scale/dependencies'; import { TimelineGeometry } from '../scale/geometry'; import { getTimelinePreset } from '../scale/presets'; @@ -77,6 +88,8 @@ describe('dependency arrow path', () => { }); }); +const fs = (rowId: string, lag = 0) => ({ rowId, type: TimelineDependencyType.FinishToStart, lag }); + describe('applyDragDelta with dependencies and progress', () => { const span = (rowId: string, day: number, days: number) => ({ rowId, @@ -147,8 +160,8 @@ describe('applyDragDelta with dependencies and progress', () => { test('"only when overlapping" (default) moves a follower just past its dependency and cascades', () => { // a: Nov 5–7, b (depends on a): Nov 9–10, c (depends on b): Nov 11 const followers = [ - { ...span('b', 9, 2), predecessors: ['a'] }, - { ...span('c', 11, 1), predecessors: ['b'] }, + { ...span('b', 9, 2), predecessors: [fs('a')] }, + { ...span('c', 11, 1), predecessors: [fs('b')] }, ]; const small = applyDragDelta(geometry, { ...span('a', 5, 3), mode: 'move', followers }, columnWidth); @@ -172,7 +185,7 @@ describe('applyDragDelta with dependencies and progress', () => { test('"only when overlapping" also applies when the end handle grows into a follower', () => { const grow = applyDragDelta( geometry, - { ...span('a', 5, 3), mode: 'resize-end', followers: [{ ...span('b', 9, 2), predecessors: ['a'] }] }, + { ...span('a', 5, 3), mode: 'resize-end', followers: [{ ...span('b', 9, 2), predecessors: [fs('a')] }] }, columnWidth * 3 ); @@ -188,7 +201,7 @@ describe('applyDragDelta with dependencies and progress', () => { mode: 'move', shift: TimelineDependencyShift.Never, minStart: local(2020, 11, 8), - followers: [{ ...span('c', 14, 1), predecessors: ['b'] }], + followers: [{ ...span('c', 14, 1), predecessors: [fs('b')] }], }, -columnWidth * 5 ); @@ -205,7 +218,7 @@ describe('applyDragDelta with dependencies and progress', () => { ...span('a', 5, 3), mode: 'move', avoidWeekends: true, - followers: [{ ...span('b', 9, 2), predecessors: ['a'] }], + followers: [{ ...span('b', 9, 2), predecessors: [fs('a')] }], }, columnWidth * 6 ); @@ -237,3 +250,125 @@ describe('applyDragDelta with dependencies and progress', () => { expect(preview.followers).toEqual([]); }); }); + +describe('dependency direction and per-link metadata', () => { + test('a "Blocking" field flips the edges and links carry their type and lag', () => { + const graph = buildDependencyGraph(['a', 'b'], new Map([['a', ['b']]]), { + direction: TimelineDependencyDirection.Blocking, + links: { 'a:b': { type: TimelineDependencyType.StartToStart, lag: 3 } }, + }); + + expect(graph.predecessors.get('b')).toEqual(['a']); + expect(graph.dependents.get('a')).toEqual(['b']); + expect(linkOf(graph, 'a', 'b')).toEqual({ type: TimelineDependencyType.StartToStart, lag: 3 }); + expect(linkOf(graph, 'b', 'a')).toEqual({ type: TimelineDependencyType.FinishToStart, lag: 0 }); + }); + + test('constraintStart applies each link type and its lag to the successor', () => { + const predecessor = { rowId: 'p', allDay: true, start: local(2020, 11, 5), endExclusive: local(2020, 11, 8) }; + const successor = { rowId: 's', allDay: true, start: local(2020, 11, 1), endExclusive: local(2020, 11, 3) }; + const at = (type: TimelineDependencyType, lag: number) => constraintStart({ type, lag }, predecessor, successor); + + expect(at(TimelineDependencyType.FinishToStart, 0)).toEqual(local(2020, 11, 8)); + expect(at(TimelineDependencyType.FinishToStart, 2)).toEqual(local(2020, 11, 10)); + expect(at(TimelineDependencyType.FinishToStart, -1)).toEqual(local(2020, 11, 7)); + expect(at(TimelineDependencyType.StartToStart, 0)).toEqual(local(2020, 11, 5)); + // The successor is two days long: its end must reach the predecessor's end. + expect(at(TimelineDependencyType.FinishToFinish, 0)).toEqual(local(2020, 11, 6)); + expect(at(TimelineDependencyType.StartToFinish, 1)).toEqual(local(2020, 11, 4)); + }); + + test('"only when overlapping" honours lag and start-to-start links when shifting', () => { + const span = (rowId: string, day: number, days: number) => ({ + rowId, + allDay: true, + start: local(2020, 11, day), + endExclusive: local(2020, 11, day + days), + }); + const lagged = applyDragDelta( + geometry, + { ...span('a', 5, 3), mode: 'move', followers: [{ ...span('b', 9, 2), predecessors: [fs('a', 2)] }] }, + columnWidth * 2 + ); + + // a: Nov 7–9 → with a 2-day lag b must start on the 12th. + expect(lagged.followers[0].start).toEqual(local(2020, 11, 12)); + + const startToStart = applyDragDelta( + geometry, + { + ...span('a', 5, 3), + mode: 'move', + followers: [ + { ...span('b', 6, 1), predecessors: [{ rowId: 'a', type: TimelineDependencyType.StartToStart, lag: 0 }] }, + ], + }, + columnWidth * 4 + ); + + // a now starts on the 9th; b only has to start with it, not after it. + expect(startToStart.followers[0].start).toEqual(local(2020, 11, 9)); + }); + + test('end-type links bound a moved bar through minEnd', () => { + const span = (rowId: string, day: number, days: number) => ({ + rowId, + allDay: true, + start: local(2020, 11, day), + endExclusive: local(2020, 11, day + days), + }); + const preview = applyDragDelta( + geometry, + { ...span('b', 12, 2), mode: 'move', minEnd: local(2020, 11, 10) }, + -columnWidth * 8 + ); + + // The 2-day bar may not end before the 10th, so it stops at the 8th. + expect(preview.start).toEqual(local(2020, 11, 8)); + }); +}); + +describe('dependencyLinkPath', () => { + const options = { rowHeight: 36, barInset: 4 }; + + test("finish-to-start delegates to frappe's route", () => { + const from = { rect: { left: 0, width: 100 }, index: 0 }; + const to = { rect: { left: 160, width: 80 }, index: 2 }; + + expect(dependencyLinkPath(TimelineDependencyType.FinishToStart, from, to, options)).toBe( + dependencyArrowPath(from, to, options) + ); + }); + + test('start-to-start leaves and enters the left edges with a right-pointing head', () => { + const path = dependencyLinkPath( + TimelineDependencyType.StartToStart, + { rect: { left: 100, width: 100 }, index: 0 }, + { rect: { left: 160, width: 80 }, index: 1 }, + options + ); + + expect(path.startsWith('M 100 18 H 82 V 54 H 147')).toBe(true); + expect(path.endsWith('m -5 -5 l 5 5 l -5 5')).toBe(true); + }); + + test('finish-to-finish enters the right edge with a left-pointing head, detouring when needed', () => { + const direct = dependencyLinkPath( + TimelineDependencyType.FinishToFinish, + { rect: { left: 100, width: 100 }, index: 0 }, + { rect: { left: 40, width: 60 }, index: 1 }, + options + ); + + expect(direct).toBe('M 200 18 H 218 V 54 H 113 m 5 -5 l -5 5 l 5 5'); + + const detour = dependencyLinkPath( + TimelineDependencyType.FinishToFinish, + { rect: { left: 0, width: 50 }, index: 0 }, + { rect: { left: 100, width: 100 }, index: 1 }, + options + ); + + expect(detour).toBe('M 50 18 H 68 V 36 H 231 V 54 H 213 m 5 -5 l -5 5 l 5 5'); + }); +}); diff --git a/src/components/database/timeline/hooks/useTimelineDrag.ts b/src/components/database/timeline/hooks/useTimelineDrag.ts index 4a15c422a..5864d8b53 100644 --- a/src/components/database/timeline/hooks/useTimelineDrag.ts +++ b/src/components/database/timeline/hooks/useTimelineDrag.ts @@ -15,7 +15,7 @@ */ import { PointerEvent as ReactPointerEvent, useCallback, useEffect, useRef, useState } from 'react'; -import { TimelineDependencyShift } from '@/application/database-yjs'; +import { TimelineDependencyLink, TimelineDependencyShift, TimelineDependencyType } from '@/application/database-yjs'; import { calendarDaysBetween, @@ -29,6 +29,11 @@ import { export type TimelineDragMode = 'move' | 'resize-start' | 'resize-end' | 'progress'; +/** A follower's link to one of its predecessors inside the moving set. */ +export interface TimelineDragLink extends TimelineDependencyLink { + rowId: string; +} + export interface TimelineDragSpan { rowId: string; /** Bar start; a local midnight for all-day rows. */ @@ -36,8 +41,8 @@ export interface TimelineDragSpan { /** Exclusive bar end (the day after the last covered day for all-day rows). */ endExclusive: Date; allDay: boolean; - /** For followers: the rows it depends on, limited to the dragged bar and other followers. */ - predecessors?: string[]; + /** For followers: the links to rows it depends on, limited to the dragged bar and other followers. */ + predecessors?: TimelineDragLink[]; } export interface TimelineDragOrigin extends TimelineDragSpan { @@ -47,8 +52,10 @@ export interface TimelineDragOrigin extends TimelineDragSpan { shift?: TimelineDependencyShift; /** Shifted followers never land on a Saturday or Sunday. */ avoidWeekends?: boolean; - /** Earliest start allowed, e.g. the latest start among its dependencies. */ + /** Earliest start allowed by the bar's start-type links (finish/start-to-start). */ minStart?: Date; + /** Earliest end allowed by the bar's end-type links (finish/start-to-finish). */ + minEnd?: Date; /** Current 0–100 progress, required for the progress mode. */ progress?: number; } @@ -116,6 +123,34 @@ function skipWeekend(date: Date): Date { return next; } +const MS_PER_DAY = 86_400_000; + +/** + * Earliest start of `successor` that satisfies `link` given `predecessor`'s + * dates: the classic finish/start-to-start/finish rules plus a lag in days + * (negative = lead). End-type links are expressed through the successor's + * current length. + */ +export function constraintStart( + link: TimelineDependencyLink, + predecessor: TimelineDragSpan, + successor: TimelineDragSpan +): Date { + const lagMs = link.lag * MS_PER_DAY; + const duration = successor.endExclusive.getTime() - successor.start.getTime(); + + switch (link.type) { + case TimelineDependencyType.StartToStart: + return new Date(predecessor.start.getTime() + lagMs); + case TimelineDependencyType.FinishToFinish: + return new Date(predecessor.endExclusive.getTime() + lagMs - duration); + case TimelineDependencyType.StartToFinish: + return new Date(predecessor.start.getTime() + lagMs - duration); + default: + return new Date(predecessor.endExclusive.getTime() + lagMs); + } +} + /** Move a span so it starts at `start`, keeping its length (calendar days for all-day rows). */ function moveSpanTo(span: TimelineDragSpan, start: Date): TimelineDragSpan { const endExclusive = new Date(start.getTime()); @@ -149,10 +184,10 @@ function resolveOverlaps( const span = current.get(follower.rowId) ?? follower; let required = 0; - (follower.predecessors ?? []).forEach((predecessorId) => { - const predecessor = current.get(predecessorId); + (follower.predecessors ?? []).forEach((link) => { + const predecessor = current.get(link.rowId); - if (predecessor) required = Math.max(required, predecessor.endExclusive.getTime()); + if (predecessor) required = Math.max(required, constraintStart(link, predecessor, span).getTime()); }); if (required <= span.start.getTime()) return; let start = new Date(required); @@ -216,11 +251,17 @@ export function applyDragDelta( if (drag.mode === 'move') { let moved = shiftSpan(geometry, drag, deltaPx); let effectiveDelta = deltaPx; - - if (minStart && moved.start < minStart) { + // Moving keeps the length, so an end-type link bounds the start too. + const minEnd = drag.shift === TimelineDependencyShift.Never ? undefined : drag.minEnd; + const endFloor = minEnd + ? new Date(minEnd.getTime() - (drag.endExclusive.getTime() - drag.start.getTime())) + : undefined; + const floor = !minStart ? endFloor : !endFloor || minStart > endFloor ? minStart : endFloor; + + if (floor && moved.start < floor) { // Clamp to the dependency and re-derive the pixel delta so followers // keep the offset the bar actually travelled. - effectiveDelta = dateToX(geometry, minStart) - dateToX(geometry, drag.start); + effectiveDelta = dateToX(geometry, floor) - dateToX(geometry, drag.start); moved = shiftSpan(geometry, drag, effectiveDelta); } @@ -237,7 +278,9 @@ export function applyDragDelta( } let endExclusive = shiftDate(geometry, drag.endExclusive, deltaPx); + const minEnd = drag.shift === TimelineDependencyShift.Never ? undefined : drag.minEnd; + if (minEnd && endExclusive < minEnd) endExclusive = minEnd; if (endExclusive.getTime() - drag.start.getTime() < snapMs) endExclusive = new Date(drag.start.getTime() + snapMs); const effectiveDelta = dateToX(geometry, endExclusive) - dateToX(geometry, drag.endExclusive); const resized = { rowId: drag.rowId, allDay: drag.allDay, start: drag.start, endExclusive }; diff --git a/src/components/database/timeline/scale/dependencies.ts b/src/components/database/timeline/scale/dependencies.ts index e2676fb3e..c8078090c 100644 --- a/src/components/database/timeline/scale/dependencies.ts +++ b/src/components/database/timeline/scale/dependencies.ts @@ -7,6 +7,13 @@ * edge, looping back with two extra bends when the successor starts before * the predecessor ends. */ +import { + TimelineDependencyDirection, + TimelineDependencyLink, + TimelineDependencyType, + timelineLinkKey, +} from '@/application/database-yjs'; + import { BarRect } from './geometry'; export interface DependencyGraph { @@ -14,28 +21,53 @@ export interface DependencyGraph { predecessors: Map; /** rowId → rows that depend on it. */ dependents: Map; + /** Per-link type and lag by `timelineLinkKey`; absent links are finish-to-start with no lag. */ + links: Record; +} + +export const DEFAULT_DEPENDENCY_LINK: TimelineDependencyLink = { type: TimelineDependencyType.FinishToStart, lag: 0 }; + +export function linkOf(graph: DependencyGraph, predecessorId: string, successorId: string): TimelineDependencyLink { + return graph.links[timelineLinkKey(predecessorId, successorId)] ?? DEFAULT_DEPENDENCY_LINK; +} + +export interface BuildDependencyGraphOptions { + /** Whether `relations` lists each row's predecessors (default) or its successors. */ + direction?: TimelineDependencyDirection; + links?: Record; +} + +function pushUnique(map: Map, key: string, value: string) { + const list = map.get(key) ?? []; + + if (!list.includes(value)) list.push(value); + map.set(key, list); } /** Build both directions of the graph, ignoring links to rows outside the view. */ -export function buildDependencyGraph(rowIds: string[], relations: Map): DependencyGraph { +export function buildDependencyGraph( + rowIds: string[], + relations: Map, + options: BuildDependencyGraphOptions = {} +): DependencyGraph { const known = new Set(rowIds); const predecessors = new Map(); const dependents = new Map(); + const listsSuccessors = options.direction === TimelineDependencyDirection.Blocking; rowIds.forEach((rowId) => { const linked = (relations.get(rowId) ?? []).filter((id) => id !== rowId && known.has(id)); - if (linked.length === 0) return; - predecessors.set(rowId, linked); - linked.forEach((predecessor) => { - const list = dependents.get(predecessor) ?? []; + linked.forEach((other) => { + // A "Blocking" field lists the rows this one precedes; flip the edge. + const [predecessor, successor] = listsSuccessors ? [rowId, other] : [other, rowId]; - list.push(rowId); - dependents.set(predecessor, list); + pushUnique(predecessors, successor, predecessor); + pushUnique(dependents, predecessor, successor); }); }); - return { predecessors, dependents }; + return { predecessors, dependents, links: options.links ?? {} }; } /** Every row that transitively depends on `rowId` (frappe's `get_all_dependent_tasks`). */ @@ -72,6 +104,67 @@ export interface ArrowGeometryOptions { curve?: number; } +/** Which bar edge a link leaves and enters, per link type. */ +function linkAnchors(type: TimelineDependencyType): { exit: 'start' | 'finish'; entry: 'start' | 'finish' } { + switch (type) { + case TimelineDependencyType.StartToStart: + return { exit: 'start', entry: 'start' }; + case TimelineDependencyType.FinishToFinish: + return { exit: 'finish', entry: 'finish' }; + case TimelineDependencyType.StartToFinish: + return { exit: 'start', entry: 'finish' }; + default: + return { exit: 'finish', entry: 'start' }; + } +} + +/** + * SVG path for a link of any type. Finish-to-start keeps frappe's route; the + * other types run orthogonally from the exit edge's midpoint to the entry + * edge, detouring along the row boundary when the direct route would cross + * either bar. The head points into the entered edge. + */ +export function dependencyLinkPath( + type: TimelineDependencyType, + from: ArrowEndpoint, + to: ArrowEndpoint, + options: ArrowGeometryOptions +): string { + if (type === TimelineDependencyType.FinishToStart) return dependencyArrowPath(from, to, options); + const { rowHeight } = options; + const padding = options.padding ?? 18; + const { exit, entry } = linkAnchors(type); + const rowMid = (index: number) => index * rowHeight + rowHeight / 2; + const exitX = exit === 'finish' ? from.rect.left + from.rect.width : from.rect.left; + const exitY = rowMid(from.index); + const exitClearX = exit === 'finish' ? exitX + padding : exitX - padding; + // The head tip sits 13px outside the entered edge, as in the finish-to-start route. + const entryX = entry === 'start' ? to.rect.left - 13 : to.rect.left + to.rect.width + 13; + const entryY = rowMid(to.index); + const entryDir = entry === 'start' ? 1 : -1; + const head = entry === 'start' ? 'm -5 -5 l 5 5 l -5 5' : 'm 5 -5 l -5 5 l 5 5'; + const direct = Math.sign(entryX - exitClearX) === entryDir || entryX === exitClearX; + + if (direct) { + return [`M ${exitX} ${exitY}`, `H ${exitClearX}`, `V ${entryY}`, `H ${entryX}`, head].join(' '); + } + + // Detour: leave along the row boundary nearest the successor, then come back + // at the entered edge from the correct side. + const gapY = to.index < from.index ? from.index * rowHeight : (from.index + 1) * rowHeight; + const entryClearX = entryX - entryDir * padding; + + return [ + `M ${exitX} ${exitY}`, + `H ${exitClearX}`, + `V ${gapY}`, + `H ${entryClearX}`, + `V ${entryY}`, + `H ${entryX}`, + head, + ].join(' '); +} + /** SVG path from the end of `from` to the start of `to`, including the arrow head. */ export function dependencyArrowPath(from: ArrowEndpoint, to: ArrowEndpoint, options: ArrowGeometryOptions): string { const { rowHeight, barInset } = options; From 7e888dc53a22b3c97843fc9355dbe429d0871e77 Mon Sep 17 00:00:00 2001 From: Nathan Date: Mon, 14 Sep 2026 00:51:20 +0000 Subject: [PATCH 11/21] fix(timeline): never clamp a dragged bar by its own dependencies A dependent could not be dragged over or before the bar it depends on: the drag stopped at the constraint (frappe's rule). Notion places the dragged bar where it is dropped and re-routes the arrow; only the bar's own dependents shift. Drop the minStart / minEnd clamps and the "Cannot start before its dependencies" string. Also lock in that dragging writes nothing until the drop: a scenario counts Yjs updates on the dragged row and its follower while the pointer is held with both bars visibly moved (zero), then after release (both written). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NoEH4HcEiojEcioVSLE3QM --- .../bdd/features/database/timeline.feature | 18 ++++++-- playwright/bdd/steps/timeline.steps.ts | 44 +++++++++++++++++-- playwright/e2e/database/timeline.spec.ts | 7 +-- playwright/support/timeline-test-helpers.ts | 37 ++++++++++++++++ src/@types/translations/en.json | 1 - .../database/timeline/TimelineView.tsx | 32 -------------- .../timeline/__tests__/dependencies.test.ts | 39 ++++------------ .../timeline/hooks/useTimelineDrag.ts | 32 +++----------- 8 files changed, 110 insertions(+), 100 deletions(-) diff --git a/playwright/bdd/features/database/timeline.feature b/playwright/bdd/features/database/timeline.feature index d998dd29f..8acf6696a 100644 --- a/playwright/bdd/features/database/timeline.feature +++ b/playwright/bdd/features/database/timeline.feature @@ -75,7 +75,7 @@ Feature: Timeline view interactions When I show the timeline table Then the timeline table lists 3 rows - Scenario: Dependencies draw arrows, dependents keep their gap, and a bar cannot start before its dependency + Scenario: Dependencies draw arrows, dependents keep their gap, and a dependent may be dragged over its dependency Given "Build" depends on "Design" through a relation field And dependents shift with "Keep the time between items" Then the timeline draws 1 dependency arrow @@ -85,7 +85,8 @@ Feature: Timeline view interactions Then the "Build" bar is back where it started And the "Design" bar is back where it started When I drag the "Build" bar 6 columns earlier - Then the "Build" bar starts 1 columns after the "Design" bar + Then the "Build" bar starts 4 columns before the "Design" bar + And the timeline draws 1 dependency arrow Scenario: A progress field renders a fill that the progress handle drags Given "Design" has a progress field at 40 percent @@ -174,7 +175,7 @@ Feature: Timeline view interactions When I drag the end handle of "Design" 2 columns later Then the "Build" bar moved 2 columns later - Scenario: With shifting off, dependents stay put and a bar may precede its dependency + Scenario: With shifting off, dependents stay put Given "Build" depends on "Design" through a relation field And dependents shift with "Never" When I drag the "Design" bar 3 columns later @@ -290,3 +291,14 @@ Feature: Timeline view interactions When I click the arrow from "Design" to "Build" And I remove the dependency from the link editor Then the timeline draws 0 dependency arrow + + Scenario: Dragging a bar with dependents writes nothing until it is dropped + Given "Build" depends on "Design" through a relation field + And dependents shift with "Keep the time between items" + When I start counting writes to "Design" and "Build" + And I press the "Design" bar and move it 3 columns later without releasing + Then the "Design" and "Build" bars have moved 3 columns on screen + And no writes have reached "Design" or "Build" + When I release the pointer + Then writes have reached "Design" and "Build" + And the "Build" bar moved 3 columns later diff --git a/playwright/bdd/steps/timeline.steps.ts b/playwright/bdd/steps/timeline.steps.ts index 78a3ff223..5d05b4e35 100644 --- a/playwright/bdd/steps/timeline.steps.ts +++ b/playwright/bdd/steps/timeline.steps.ts @@ -32,6 +32,9 @@ import { injectFieldDirect, loginAndCreateCalendarWithRows, MONTH_COLUMN_WIDTH, + pressAndMoveBar, + readRowWrites, + startCountingRowWrites, readProgressPercent, setTextCellDirect, TIMELINE_SIDEBAR_WIDTH, @@ -715,10 +718,6 @@ Then('the timeline has no group headers', async ({ page }) => { // --- Dependency setup, direction and link editing --------------------------- -Then('the {string} bar starts {int} columns after the {string} bar', async ({ page }, title, columns, other) => { - await expectBarX(page, title, (await barBox(page, other)).x + columns * MONTH_COLUMN_WIDTH); -}); - When('I set up dependencies from the timeline settings', async ({ page }) => { await chooseTimelineSettingsOption(page, 'timeline-set-up-dependencies'); }); @@ -823,6 +822,43 @@ When('I remove the dependency from the link editor', async ({ page }) => { await expect(page.getByTestId('timeline-link-editor')).toHaveCount(0); }); +// --- Writes happen only on drop ---------------------------------------------- + +When('I start counting writes to {string} and {string}', async ({ page }, first, second) => { + await startCountingRowWrites(page, [rowId(page, first), rowId(page, second)]); +}); + +When('I press the {string} bar and move it {int} columns later without releasing', async ({ page }, title, columns) => { + await remember(page, 'Design', 'Build'); + await pressAndMoveBar(page, title, columns * MONTH_COLUMN_WIDTH); +}); + +Then('the {string} and {string} bars have moved {int} columns on screen', async ({ page }, first, second, columns) => { + for (const title of [first, second]) { + await expectBarX(page, title, before(page, title).x + columns * MONTH_COLUMN_WIDTH); + } +}); + +Then('no writes have reached {string} or {string}', async ({ page }, first, second) => { + // Give any stray write time to land before asserting nothing did. + await page.waitForTimeout(500); + const writes = await readRowWrites(page); + + expect(writes[rowId(page, first)]).toBe(0); + expect(writes[rowId(page, second)]).toBe(0); +}); + +When('I release the pointer', async ({ page }) => { + await page.mouse.up(); +}); + +Then('writes have reached {string} and {string}', async ({ page }, first, second) => { + await expect.poll(async () => (await readRowWrites(page))[rowId(page, first)], { timeout: 10_000 }).toBeGreaterThan(0); + await expect + .poll(async () => (await readRowWrites(page))[rowId(page, second)], { timeout: 10_000 }) + .toBeGreaterThan(0); +}); + Then('the timeline draws {int} dependency arrow', async ({ page }, count) => { await expect(TimelineSelectors.arrows(page)).toHaveCount(count, { timeout: 15_000 }); }); diff --git a/playwright/e2e/database/timeline.spec.ts b/playwright/e2e/database/timeline.spec.ts index a5eeaa05a..e514999c2 100644 --- a/playwright/e2e/database/timeline.spec.ts +++ b/playwright/e2e/database/timeline.spec.ts @@ -170,13 +170,14 @@ test.describe('Timeline dependencies and progress', () => { await expect.poll(async () => (await barBox(page, 'Design')).box.x, { timeout: 10_000 }).toBeCloseTo(designBefore.box.x, 0); await expect(page.locator('[data-testid="timeline-arrow"]')).toHaveCount(1); - // A finish-to-start dependent cannot be dragged before its dependency ends - // (Design is one day long, so Build stops one column after Design starts). + // As in Notion, a dependent is never clamped by its dependency: dragged six + // columns earlier from two days after Design, Build lands four columns before it. await dragBy(page, buildBefore.box.x + buildBefore.box.width / 2, buildBefore.box.y + buildBefore.box.height / 2, -columnWidth * 6); // Compare against Design's live position: the drag may auto-scroll the canvas. await expect .poll(async () => (await barBox(page, 'Build')).box.x - (await barBox(page, 'Design')).box.x, { timeout: 10_000 }) - .toBeCloseTo(columnWidth, 0); + .toBeCloseTo(-columnWidth * 4, 0); + await expect(page.locator('[data-testid="timeline-arrow"]')).toHaveCount(1); // Resize Design to three days so the progress handle has room, then drag it. const endHandle = page.getByTestId(`timeline-handle-end-${designId}`); diff --git a/playwright/support/timeline-test-helpers.ts b/playwright/support/timeline-test-helpers.ts index ebfec1ffb..3874c63b8 100644 --- a/playwright/support/timeline-test-helpers.ts +++ b/playwright/support/timeline-test-helpers.ts @@ -265,3 +265,40 @@ export async function clickRowCanvas(page: Page, rowId: string, offset = 120) { if (!rowBox || !viewBox) throw new Error('Timeline row is not visible'); await page.mouse.click(viewBox.x + TIMELINE_SIDEBAR_WIDTH + offset, rowBox.y + rowBox.height / 2); } + +/** Count Yjs updates applied to the given row docs from now on (see `readRowWrites`). */ +export async function startCountingRowWrites(page: Page, rowIds: string[]) { + await page.evaluate(async (rowIds) => { + const win = window as unknown as { __TEST_DATABASE_CONTEXT__: any; __ROW_WRITES__?: Record }; + const ctx = win.__TEST_DATABASE_CONTEXT__; + const counts: Record = {}; + + for (const rowId of rowIds) { + const rowDoc = ctx.rowMap?.[rowId] ?? (await ctx.ensureRow(rowId)); + + counts[rowId] = 0; + rowDoc.on('update', () => { + counts[rowId] += 1; + }); + } + + win.__ROW_WRITES__ = counts; + }, rowIds); +} + +export async function readRowWrites(page: Page): Promise> { + return page.evaluate(() => (window as unknown as { __ROW_WRITES__?: Record }).__ROW_WRITES__ ?? {}); +} + +/** Press a bar and travel `dx` pixels in steps, leaving the pointer down. */ +export async function pressAndMoveBar(page: Page, title: string, dx: number) { + const box = await barBox(page, title); + const x = box.x + box.width / 2; + const y = box.y + box.height / 2; + + await page.mouse.move(x, y); + await page.mouse.down(); + for (let step = 1; step <= 6; step += 1) { + await page.mouse.move(x + (dx * step) / 6, y); + } +} diff --git a/src/@types/translations/en.json b/src/@types/translations/en.json index e40d4fe3e..0a46ab823 100644 --- a/src/@types/translations/en.json +++ b/src/@types/translations/en.json @@ -4389,7 +4389,6 @@ "duration_other": "{{count}} days", "progress": "{{percent}}% complete" }, - "dependencyBlocked": "Cannot start before its dependencies", "linkHandle": "Drag to add a dependency", "blockedBy": "Blocked by", "blocking": "Blocking", diff --git a/src/components/database/timeline/TimelineView.tsx b/src/components/database/timeline/TimelineView.tsx index d2c64e124..a5b6744d9 100644 --- a/src/components/database/timeline/TimelineView.tsx +++ b/src/components/database/timeline/TimelineView.tsx @@ -10,7 +10,6 @@ import { isAIFieldType, TimelineDependencyDirection, TimelineDependencyLink, - TimelineDependencyType, TimelineLayoutSetting, timelineLinkKey, TimelineLayout, @@ -54,7 +53,6 @@ import { } from './constants'; import { useScrollWindow } from './hooks/useScrollWindow'; import { - constraintStart, TimelineDragMode, TimelineDragPreview, TimelineDragSpan, @@ -345,34 +343,6 @@ export function TimelineView({ setting }: { setting: TimelineLayoutSetting }) { }, ]; }); - // A bar cannot violate its links: start-type links bound its start, - // end-type links its end (each including the link's lag). - const dragged = { rowId: row.rowId, allDay: row.allDay, ...span }; - let minStart: Date | undefined; - let minEnd: Date | undefined; - - (graph.predecessors.get(row.rowId) ?? []).forEach((predecessorId) => { - const predecessor = byId.get(predecessorId); - - if (!predecessor?.start) return; - const link = linkOf(graph, predecessorId, row.rowId); - const predecessorSpan = { - rowId: predecessorId, - allDay: predecessor.allDay, - ...getBarSpan(predecessor.start, predecessor.end, predecessor.allDay), - }; - const earliestStart = constraintStart(link, predecessorSpan, dragged); - const endType = - link.type === TimelineDependencyType.FinishToFinish || link.type === TimelineDependencyType.StartToFinish; - - if (endType) { - const earliestEnd = new Date(earliestStart.getTime() + (span.endExclusive.getTime() - span.start.getTime())); - - if (!minEnd || earliestEnd > minEnd) minEnd = earliestEnd; - } else if (!minStart || earliestStart > minStart) { - minStart = earliestStart; - } - }); startDrag( event, @@ -383,8 +353,6 @@ export function TimelineView({ setting }: { setting: TimelineLayoutSetting }) { followers, shift: setting.dependencyShift, avoidWeekends: setting.avoidWeekends, - minStart, - minEnd, progress: progressValues.get(row.rowId) ?? 0, }, mode diff --git a/src/components/database/timeline/__tests__/dependencies.test.ts b/src/components/database/timeline/__tests__/dependencies.test.ts index b1029d9a5..5bf5a3aef 100644 --- a/src/components/database/timeline/__tests__/dependencies.test.ts +++ b/src/components/database/timeline/__tests__/dependencies.test.ts @@ -115,24 +115,21 @@ describe('applyDragDelta with dependencies and progress', () => { ]); }); - test('a bar cannot move or start before its dependencies, and followers only travel the clamped distance', () => { + test('a dependent may be dragged over or before its dependency; only its own followers move', () => { const move = applyDragDelta( geometry, - { ...span('b', 10, 2), mode: 'move', ...keepGap, minStart: local(2020, 11, 8), followers: [span('c', 14, 1)] }, + { ...span('b', 10, 2), mode: 'move', ...keepGap, followers: [span('c', 14, 1)] }, -columnWidth * 5 ); - expect(move.start).toEqual(local(2020, 11, 8)); - expect(move.endExclusive).toEqual(local(2020, 11, 10)); - expect(move.followers[0].start).toEqual(local(2020, 11, 12)); + // No clamp: the bar lands where it is dropped and its follower keeps the gap. + expect(move.start).toEqual(local(2020, 11, 5)); + expect(move.endExclusive).toEqual(local(2020, 11, 7)); + expect(move.followers[0].start).toEqual(local(2020, 11, 9)); - const resize = applyDragDelta( - geometry, - { ...span('b', 10, 2), mode: 'resize-start', minStart: local(2020, 11, 8) }, - -columnWidth * 5 - ); + const resize = applyDragDelta(geometry, { ...span('b', 10, 2), mode: 'resize-start' }, -columnWidth * 5); - expect(resize.start).toEqual(local(2020, 11, 8)); + expect(resize.start).toEqual(local(2020, 11, 5)); expect(resize.followers).toEqual([]); }); @@ -193,14 +190,13 @@ describe('applyDragDelta with dependencies and progress', () => { expect(grow.followers[0].start).toEqual(local(2020, 11, 11)); }); - test('"never" leaves followers alone and lets a dependent be dragged before its dependency', () => { + test('"never" leaves followers alone', () => { const preview = applyDragDelta( geometry, { ...span('b', 10, 2), mode: 'move', shift: TimelineDependencyShift.Never, - minStart: local(2020, 11, 8), followers: [{ ...span('c', 14, 1), predecessors: [fs('b')] }], }, -columnWidth * 5 @@ -309,23 +305,6 @@ describe('dependency direction and per-link metadata', () => { // a now starts on the 9th; b only has to start with it, not after it. expect(startToStart.followers[0].start).toEqual(local(2020, 11, 9)); }); - - test('end-type links bound a moved bar through minEnd', () => { - const span = (rowId: string, day: number, days: number) => ({ - rowId, - allDay: true, - start: local(2020, 11, day), - endExclusive: local(2020, 11, day + days), - }); - const preview = applyDragDelta( - geometry, - { ...span('b', 12, 2), mode: 'move', minEnd: local(2020, 11, 10) }, - -columnWidth * 8 - ); - - // The 2-day bar may not end before the 10th, so it stops at the 8th. - expect(preview.start).toEqual(local(2020, 11, 8)); - }); }); describe('dependencyLinkPath', () => { diff --git a/src/components/database/timeline/hooks/useTimelineDrag.ts b/src/components/database/timeline/hooks/useTimelineDrag.ts index 5864d8b53..4dc64342f 100644 --- a/src/components/database/timeline/hooks/useTimelineDrag.ts +++ b/src/components/database/timeline/hooks/useTimelineDrag.ts @@ -10,8 +10,9 @@ * repeated drags. Rows that depend on the dragged one ("followers") move with * it according to Notion's "Shift dependents" setting: only as far as needed * to avoid overlapping (default), by the same distance like frappe's - * `move_dependencies`, or not at all. Unless shifting is off, a bar cannot - * start before its dependencies do. + * `move_dependencies`, or not at all. The dragged bar itself is never + * constrained by its own dependencies, as in Notion: it lands where it is + * dropped and the arrow re-routes. */ import { PointerEvent as ReactPointerEvent, useCallback, useEffect, useRef, useState } from 'react'; @@ -52,10 +53,6 @@ export interface TimelineDragOrigin extends TimelineDragSpan { shift?: TimelineDependencyShift; /** Shifted followers never land on a Saturday or Sunday. */ avoidWeekends?: boolean; - /** Earliest start allowed by the bar's start-type links (finish/start-to-start). */ - minStart?: Date; - /** Earliest end allowed by the bar's end-type links (finish/start-to-finish). */ - minEnd?: Date; /** Current 0–100 progress, required for the progress mode. */ progress?: number; } @@ -237,8 +234,6 @@ export function applyDragDelta( const { preset } = geometry; const snapMs = preset.snapMinutes * 60_000; const base = { rowId: drag.rowId, mode: drag.mode, allDay: drag.allDay }; - // With shifting off a dependent may be dragged anywhere, as in Notion. - const minStart = drag.shift === TimelineDependencyShift.Never ? undefined : drag.minStart; if (drag.mode === 'progress') { const width = dateToX(geometry, drag.endExclusive) - dateToX(geometry, drag.start); @@ -249,38 +244,21 @@ export function applyDragDelta( } if (drag.mode === 'move') { - let moved = shiftSpan(geometry, drag, deltaPx); - let effectiveDelta = deltaPx; - // Moving keeps the length, so an end-type link bounds the start too. - const minEnd = drag.shift === TimelineDependencyShift.Never ? undefined : drag.minEnd; - const endFloor = minEnd - ? new Date(minEnd.getTime() - (drag.endExclusive.getTime() - drag.start.getTime())) - : undefined; - const floor = !minStart ? endFloor : !endFloor || minStart > endFloor ? minStart : endFloor; - - if (floor && moved.start < floor) { - // Clamp to the dependency and re-derive the pixel delta so followers - // keep the offset the bar actually travelled. - effectiveDelta = dateToX(geometry, floor) - dateToX(geometry, drag.start); - moved = shiftSpan(geometry, drag, effectiveDelta); - } + const moved = shiftSpan(geometry, drag, deltaPx); - return { ...base, ...moved, followers: shiftFollowers(geometry, drag, moved, effectiveDelta) }; + return { ...base, ...moved, followers: shiftFollowers(geometry, drag, moved, deltaPx) }; } if (drag.mode === 'resize-start') { let start = shiftDate(geometry, drag.start, deltaPx); - if (minStart && start < minStart) start = minStart; if (drag.endExclusive.getTime() - start.getTime() < snapMs) start = new Date(drag.endExclusive.getTime() - snapMs); return { ...base, start, endExclusive: drag.endExclusive, followers: [] }; } let endExclusive = shiftDate(geometry, drag.endExclusive, deltaPx); - const minEnd = drag.shift === TimelineDependencyShift.Never ? undefined : drag.minEnd; - if (minEnd && endExclusive < minEnd) endExclusive = minEnd; if (endExclusive.getTime() - drag.start.getTime() < snapMs) endExclusive = new Date(drag.start.getTime() + snapMs); const effectiveDelta = dateToX(geometry, endExclusive) - dateToX(geometry, drag.endExclusive); const resized = { rowId: drag.rowId, allDay: drag.allDay, start: drag.start, endExclusive }; From ef5256fbde8dd1fcf44520c0c3da80796773d3b8 Mon Sep 17 00:00:00 2001 From: Nathan Date: Mon, 14 Sep 2026 01:06:01 +0000 Subject: [PATCH 12/21] fix(timeline): draw dependency lines under the cards and the docked table Making arrows clickable had lifted their SVG above the rows, so lines were painted over cards and, once the canvas scrolled, over the sticky table cells. Put the layer back beneath the rows (cards cover the lines, the table hides them) and hit-test link clicks from the row canvas instead, via isPointInStroke on each arrow's wide invisible twin. The click the browser fires after a drop (on the common ancestor of the press and release targets) is now ignored by the canvas, so releasing a bar over a line no longer opens the link editor or clears the selection. BDD: a scenario asserts the line loses the hit test to the row layer and, after scrolling, to the docked table cell. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NoEH4HcEiojEcioVSLE3QM --- .../bdd/features/database/timeline.feature | 6 ++ playwright/bdd/steps/timeline.steps.ts | 63 +++++++++++++++++ .../database/timeline/TimelineArrows.tsx | 68 ++++++++++--------- .../database/timeline/TimelineRow.tsx | 8 ++- .../database/timeline/TimelineView.tsx | 45 +++++++++--- .../timeline/hooks/useTimelineDrag.ts | 10 ++- .../timeline/hooks/useTimelineLinkDrag.ts | 8 ++- 7 files changed, 162 insertions(+), 46 deletions(-) diff --git a/playwright/bdd/features/database/timeline.feature b/playwright/bdd/features/database/timeline.feature index 8acf6696a..f01148dae 100644 --- a/playwright/bdd/features/database/timeline.feature +++ b/playwright/bdd/features/database/timeline.feature @@ -302,3 +302,9 @@ Feature: Timeline view interactions When I release the pointer Then writes have reached "Design" and "Build" And the "Build" bar moved 3 columns later + + Scenario: Dependency lines run under the cards and slide under the docked table + Given "Build" depends on "Design" through a relation field + Then the dependency line is drawn beneath the row layer + When I scroll the canvas so the dependency line sits under the docked table + Then the dependency line is hidden behind the docked table diff --git a/playwright/bdd/steps/timeline.steps.ts b/playwright/bdd/steps/timeline.steps.ts index 5d05b4e35..4d8879519 100644 --- a/playwright/bdd/steps/timeline.steps.ts +++ b/playwright/bdd/steps/timeline.steps.ts @@ -859,6 +859,69 @@ Then('writes have reached {string} and {string}', async ({ page }, first, second .toBeGreaterThan(0); }); +/** Client point on the first vertical segment of the Design → Build arrow. */ +async function arrowProbePoint(page: Page): Promise<{ x: number; y: number }> { + const hit = page.getByTestId(`timeline-arrow-hit-${rowId(page, 'Design')}:${rowId(page, 'Build')}`); + + await expect(hit).toHaveCount(1, { timeout: 15_000 }); + const d = (await hit.getAttribute('d')) ?? ''; + const svgBox = await page.getByTestId('timeline-arrows').boundingBox(); + const match = /^M (\S+) (\S+) (?:V (\S+)|v (\S+))/.exec(d); + + if (!match || !svgBox) throw new Error(`Unexpected arrow path: ${d}`); + const x = Number(match[1]); + const y = Number(match[2]); + const midY = match[3] !== undefined ? (y + Number(match[3])) / 2 : y + Number(match[4]) / 2; + + return { x: svgBox.x + x, y: svgBox.y + midY }; +} + +Then('the dependency line is drawn beneath the row layer', async ({ page }) => { + const point = await arrowProbePoint(page); + const topmost = await page.evaluate( + ({ x, y }) => document.elementFromPoint(x, y)?.closest('[data-testid^="timeline-row-"], svg')?.tagName ?? '', + point + ); + + // The row (a div) wins the hit test, not the arrows' SVG. + expect(topmost).toBe('DIV'); +}); + +When('I scroll the canvas so the dependency line sits under the docked table', async ({ page }) => { + const point = await arrowProbePoint(page); + const view = await TimelineSelectors.view(page).boundingBox(); + + if (!view) throw new Error('Timeline is not visible'); + // Put the line's exit point in the middle of the sticky table column. + const delta = point.x - (view.x + TIMELINE_SIDEBAR_WIDTH / 2); + + await TimelineSelectors.view(page) + .locator('.appflowy-scroller') + .first() + .evaluate((scroller, delta) => { + scroller.scrollLeft += delta; + }, delta); + await page.waitForTimeout(300); +}); + +Then('the dependency line is hidden behind the docked table', async ({ page }) => { + const point = await arrowProbePoint(page); + const view = await TimelineSelectors.view(page).boundingBox(); + + if (!view) throw new Error('Timeline is not visible'); + // The arrow's exit point has scrolled under the sticky table… + expect(point.x).toBeLessThan(view.x + TIMELINE_SIDEBAR_WIDTH); + // …and the table cell, not the line, is what the pointer would hit there. + const coveredBy = await page.evaluate(({ x, y }) => { + const element = document.elementFromPoint(x, y); + const sidebar = element?.closest('[data-testid^="timeline-sidebar-cell-"]'); + + return sidebar?.getAttribute('data-testid') ?? ''; + }, point); + + expect(coveredBy).toMatch(/^timeline-sidebar-cell-/); +}); + Then('the timeline draws {int} dependency arrow', async ({ page }, count) => { await expect(TimelineSelectors.arrows(page)).toHaveCount(count, { timeout: 15_000 }); }); diff --git a/src/components/database/timeline/TimelineArrows.tsx b/src/components/database/timeline/TimelineArrows.tsx index 54842db5e..279d53d57 100644 --- a/src/components/database/timeline/TimelineArrows.tsx +++ b/src/components/database/timeline/TimelineArrows.tsx @@ -1,4 +1,4 @@ -import { memo, MouseEvent, useMemo } from 'react'; +import { memo, MutableRefObject, useMemo } from 'react'; import { TIMELINE_BAR_INSET, TIMELINE_ROW_HEIGHT } from './constants'; import { TimelineLinkDrag } from './hooks/useTimelineLinkDrag'; @@ -13,6 +13,25 @@ export interface TimelineLinkSelection { y: number; } +/** + * The link whose stroke lies under a client point, if any. The arrows sit + * beneath the rows, so the view calls this from the row canvas's click. + */ +export function hitTestLink(svg: SVGSVGElement | null, clientX: number, clientY: number): TimelineLinkSelection | null { + if (!svg) return null; + const bounds = svg.getBoundingClientRect(); + const point = new DOMPoint(clientX - bounds.left, clientY - bounds.top); + + for (const path of Array.from(svg.querySelectorAll('path[data-hit-link]'))) { + if (!path.isPointInStroke(point)) continue; + const [predecessorId, successorId] = (path.dataset.hitLink ?? '').split(':'); + + if (predecessorId && successorId) return { predecessorId, successorId, x: point.x, y: point.y }; + } + + return null; +} + interface TimelineArrowsProps { rowIds: string[]; rects: (BarRect | null)[]; @@ -26,8 +45,8 @@ interface TimelineArrowsProps { left: number; /** A connector being dragged from a bar's link handle. */ pending?: TimelineLinkDrag | null; - /** Arrows are clickable (editors only): opens the link editor. */ - onSelectLink?: (selection: TimelineLinkSelection) => void; + /** Exposes the SVG so the view can hit-test clicks against the link strokes. */ + svgRef?: MutableRefObject; /** The link currently open in the editor, drawn highlighted. */ selectedKey?: string; } @@ -47,7 +66,7 @@ export const TimelineArrows = memo( bodyHeight, left, pending, - onSelectLink, + svgRef, selectedKey, }: TimelineArrowsProps) => { const paths = useMemo(() => { @@ -88,24 +107,14 @@ export const TimelineArrows = memo( if (paths.length === 0 && !pending) return null; - const handleClick = (event: MouseEvent, path: (typeof paths)[number]) => { - if (!onSelectLink) return; - event.stopPropagation(); - const bounds = event.currentTarget.ownerSVGElement?.getBoundingClientRect(); - - onSelectLink({ - predecessorId: path.predecessorId, - successorId: path.successorId, - x: event.clientX - (bounds?.left ?? 0), - y: event.clientY - (bounds?.top ?? 0), - }); - }; - return ( - // Above the bars so links can be clicked; only the strokes take pointer events. + // Under the bars (and the sticky table), like Notion: cards cover the + // lines, and the table hides them when the canvas scrolls. Clicks on a + // line reach the row's canvas, which asks `hitTestLink` about them. - {onSelectLink ? ( - handleClick(event, path)} - /> - ) : null} + {/* Wide invisible twin used by isPointInStroke when the canvas is clicked. */} + ))} {pending ? ( diff --git a/src/components/database/timeline/TimelineRow.tsx b/src/components/database/timeline/TimelineRow.tsx index 4e3ac43d9..aef25dbe3 100644 --- a/src/components/database/timeline/TimelineRow.tsx +++ b/src/components/database/timeline/TimelineRow.tsx @@ -47,6 +47,8 @@ interface TimelineRowProps { onBarPointerDown?: (event: ReactPointerEvent, row: TimelineRowModel, mode: TimelineDragMode) => void; /** An undated row's canvas was clicked at canvas pixel `x`. */ onEmptyClick?: (row: TimelineRowModel, x: number) => void; + /** The empty canvas of a dated row was clicked (client coordinates). */ + onCanvasClick?: (clientX: number, clientY: number) => void; /** A table row was dropped on this one (undefined = reordering disabled). */ onDropRow?: (sourceRowId: string, targetRowId: string, edge: Edge) => void; /** A dependency field is bound, so bars offer a connector handle. */ @@ -113,6 +115,7 @@ export const TimelineRow = memo( onScrollTo, onBarPointerDown, onEmptyClick, + onCanvasClick, onDropRow, linkable, linkTarget, @@ -133,8 +136,9 @@ export const TimelineRow = memo( const handleCanvasClick = (event: MouseEvent) => { if (!canAssignDate) { - // Clicking the empty grid clears the selection, as in frappe. - onSelect?.(null); + // Clicking the empty grid clears the selection (as in frappe) unless a + // dependency line runs under the pointer — the view decides. + onCanvasClick?.(event.clientX, event.clientY); return; } diff --git a/src/components/database/timeline/TimelineView.tsx b/src/components/database/timeline/TimelineView.tsx index a5b6744d9..e1c7270ce 100644 --- a/src/components/database/timeline/TimelineView.tsx +++ b/src/components/database/timeline/TimelineView.tsx @@ -52,12 +52,7 @@ import { TIMELINE_TODAY_ANCHOR, } from './constants'; import { useScrollWindow } from './hooks/useScrollWindow'; -import { - TimelineDragMode, - TimelineDragPreview, - TimelineDragSpan, - useTimelineDrag, -} from './hooks/useTimelineDrag'; +import { TimelineDragMode, TimelineDragPreview, TimelineDragSpan, useTimelineDrag } from './hooks/useTimelineDrag'; import { useTimelineItems } from './hooks/useTimelineItems'; import { useTimelineLinkDrag } from './hooks/useTimelineLinkDrag'; import { parseProgressPercent, parseRelationRowIds, useTimelineFieldValues } from './hooks/useTimelineFieldValues'; @@ -79,7 +74,7 @@ import { totalWidth, xToDate, } from './scale/geometry'; -import { TimelineArrows, TimelineLinkSelection } from './TimelineArrows'; +import { hitTestLink, TimelineArrows, TimelineLinkSelection } from './TimelineArrows'; import { TimelineLinkEditor } from './TimelineLinkEditor'; import { TimelineBarDragLabel } from './TimelineBar'; import { TimelineToolbar } from './TimelineToolbar'; @@ -310,7 +305,12 @@ export function TimelineView({ setting }: { setting: TimelineLayoutSetting }) { [commitSpan, setting.progressFieldId, updateAnyCell] ); - const { preview, dragging, startDrag } = useTimelineDrag({ + const { + preview, + dragging, + startDrag, + clickAfterDragRef: clickAfterBarDragRef, + } = useTimelineDrag({ geometry, scrollerRef, sidebarWidth, @@ -444,8 +444,31 @@ export function TimelineView({ setting }: { setting: TimelineLayoutSetting }) { [setUpDependencies, setting.dependencyFieldId, writeLink] ); - // Clicking an arrow opens the link editor (type, lag, remove). + const { + link, + startLink, + clickAfterDragRef: clickAfterLinkDragRef, + } = useTimelineLinkDrag({ scrollerRef, sidebarWidth, onCommit: handleLinkCommit }); + // Clicking an arrow opens the link editor (type, lag, remove). The arrows + // are drawn under the rows, so the row canvas hands its clicks here. const [selectedLink, setSelectedLink] = useState(null); + const arrowsRef = useRef(null); + const handleCanvasClick = useCallback( + (clientX: number, clientY: number) => { + // A drop's trailing click is not a click on the canvas. + if (clickAfterBarDragRef.current || clickAfterLinkDragRef.current) return; + const hit = hitTestLink(arrowsRef.current, clientX, clientY); + + if (hit) { + setSelectedLink(hit); + return; + } + + setSelectedRowId(null); + }, + [clickAfterBarDragRef, clickAfterLinkDragRef] + ); + const selectedLinkKey = selectedLink ? timelineLinkKey(selectedLink.predecessorId, selectedLink.successorId) : ''; const selectedLinkMeta = selectedLink ? linkOf(graph, selectedLink.predecessorId, selectedLink.successorId) : null; const handleLinkChange = useCallback( @@ -473,7 +496,6 @@ export function TimelineView({ setting }: { setting: TimelineLayoutSetting }) { (rowId: string) => rowsRef.current.find((row) => row.rowId === rowId)?.title || t('grid.row.titlePlaceholder'), [t] ); - const { link, startLink } = useTimelineLinkDrag({ scrollerRef, sidebarWidth, onCommit: handleLinkCommit }); const handleLinkPointerDown = useCallback( (event: ReactPointerEvent, row: TimelineRowModel, rect: BarRect) => { const index = rowIndexById.get(row.rowId); @@ -673,7 +695,7 @@ export function TimelineView({ setting }: { setting: TimelineLayoutSetting }) { canvasWidth={canvasWidth} bodyHeight={bodyHeight} left={sidebarWidth} - onSelectLink={setSelectedLink} + svgRef={arrowsRef} selectedKey={selectedLinkKey} /> ) : null} @@ -766,6 +788,7 @@ export function TimelineView({ setting }: { setting: TimelineLayoutSetting }) { onScrollTo={handleScrollToX} onBarPointerDown={handleBarPointerDown} onEmptyClick={handleEmptyClick} + onCanvasClick={handleCanvasClick} onDropRow={permissions.editable && !grouping.isGrouped ? handleDropRow : undefined} groupFieldId={grouping.isGrouped ? grouping.fieldId : undefined} groupId={item.groupId} diff --git a/src/components/database/timeline/hooks/useTimelineDrag.ts b/src/components/database/timeline/hooks/useTimelineDrag.ts index 4dc64342f..eb55b1ab3 100644 --- a/src/components/database/timeline/hooks/useTimelineDrag.ts +++ b/src/components/database/timeline/hooks/useTimelineDrag.ts @@ -287,6 +287,10 @@ export function useTimelineDrag({ geometry, scrollerRef, sidebarWidth, onCommit, const geometryRef = useRef(geometry); const lastClientXRef = useRef(0); const autoScrollFrameRef = useRef(0); + // The browser fires a click after the pointer-up that ends a drag (on the + // common ancestor of the press and release targets); it must not be read + // as a click on the canvas. + const clickAfterDragRef = useRef(false); geometryRef.current = geometry; @@ -348,6 +352,10 @@ export function useTimelineDrag({ geometry, scrollerRef, sidebarWidth, onCommit, return; } + clickAfterDragRef.current = true; + window.setTimeout(() => { + clickAfterDragRef.current = false; + }, 0); if (commit && current && current.rowId === drag.rowId) onCommit(current); }, [onClick, onCommit, stopAutoScroll] @@ -421,5 +429,5 @@ export function useTimelineDrag({ geometry, scrollerRef, sidebarWidth, onCommit, [scrollerRef] ); - return { preview, dragging: active, startDrag }; + return { preview, dragging: active, startDrag, clickAfterDragRef }; } diff --git a/src/components/database/timeline/hooks/useTimelineLinkDrag.ts b/src/components/database/timeline/hooks/useTimelineLinkDrag.ts index b59403982..8d3a5044e 100644 --- a/src/components/database/timeline/hooks/useTimelineLinkDrag.ts +++ b/src/components/database/timeline/hooks/useTimelineLinkDrag.ts @@ -39,6 +39,8 @@ function rowIdUnderPointer(clientX: number, clientY: number): string | null { export function useTimelineLinkDrag({ scrollerRef, sidebarWidth, onCommit }: UseTimelineLinkDragOptions) { const [link, setLink] = useState(null); const activeRef = useRef<{ sourceRowId: string; pointerId: number; from: TimelineLinkPoint } | null>(null); + /** True for the click the browser fires right after a connector is released. */ + const clickAfterDragRef = useRef(false); const toCanvas = useCallback( (clientX: number, clientY: number): TimelineLinkPoint => { @@ -80,6 +82,10 @@ export function useTimelineLinkDrag({ scrollerRef, sidebarWidth, onCommit }: Use activeRef.current = null; setLink(null); + clickAfterDragRef.current = true; + window.setTimeout(() => { + clickAfterDragRef.current = false; + }, 0); if (target && target !== active.sourceRowId) onCommit(active.sourceRowId, target); }; @@ -117,5 +123,5 @@ export function useTimelineLinkDrag({ scrollerRef, sidebarWidth, onCommit }: Use [] ); - return { link, startLink }; + return { link, startLink, clickAfterDragRef }; } From b8ef959560b0fbb6429763220dda197e3f54bec4 Mon Sep 17 00:00:00 2001 From: Nathan Date: Mon, 14 Sep 2026 01:09:53 +0000 Subject: [PATCH 13/21] refactor(timeline): react best-practice pass on the dependency work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TimelineLinkEditor derives its lag draft during render (previous-prop pattern) instead of an effect, and is memoized; the view memoizes the anchor selection and close handler so an open editor no longer re-renders on every scroll frame. - readTimelineLayoutSetting caches parsed dependency links and table field ids per stored Yjs value (WeakMap) — getSnapshot runs on every subscriber render and was re-allocating both each time. - buildDependencyGraph dedupes edges with a Set instead of an O(n) includes per insert. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NoEH4HcEiojEcioVSLE3QM --- .../database-yjs/timeline-layout.ts | 21 +++++++++++++++--- .../database/timeline/TimelineLinkEditor.tsx | 14 +++++++----- .../database/timeline/TimelineView.tsx | 10 +++++++-- .../database/timeline/scale/dependencies.ts | 22 +++++++++++-------- 4 files changed, 48 insertions(+), 19 deletions(-) diff --git a/src/application/database-yjs/timeline-layout.ts b/src/application/database-yjs/timeline-layout.ts index 05e421f59..e1396531a 100644 --- a/src/application/database-yjs/timeline-layout.ts +++ b/src/application/database-yjs/timeline-layout.ts @@ -27,6 +27,10 @@ export const DEFAULT_TIMELINE_DEPENDENCY_SHIFT = TimelineDependencyShift.Overlap const EMPTY_IDS: string[] = []; const EMPTY_LINKS: Record = {}; +// `getSnapshot` re-reads the setting on every subscriber render. Yjs hands the +// same object back until the key is rewritten, so parse each stored value once. +const parsedLinks = new WeakMap>(); +const parsedIds = new WeakMap(); /** * Per-link metadata as stored (a plain map of `{ ty, lag }` records). Unknown @@ -34,6 +38,9 @@ const EMPTY_LINKS: Record = {}; */ function linkMap(value: unknown): Record { if (!value || typeof value !== 'object' || Array.isArray(value)) return EMPTY_LINKS; + const cached = parsedLinks.get(value); + + if (cached) return cached; const result: Record = {}; Object.entries(value as Record).forEach(([key, raw]) => { @@ -45,7 +52,10 @@ function linkMap(value: unknown): Record { result[key] = { type: type ?? TimelineDependencyType.FinishToStart, lag: Number.isFinite(lag) ? lag : 0 }; }); - return Object.keys(result).length === 0 ? EMPTY_LINKS : result; + const links = Object.keys(result).length === 0 ? EMPTY_LINKS : result; + + parsedLinks.set(value, links); + return links; } function sameLinks(a: Record, b: Record) { @@ -60,9 +70,14 @@ function sameLinks(a: Record, b: Record typeof id === 'string' && id !== ''); + const cached = parsedIds.get(value); + + if (cached) return cached; + const filtered = value.filter((id): id is string => typeof id === 'string' && id !== ''); + const ids = filtered.length === 0 ? EMPTY_IDS : filtered; - return ids.length === 0 ? EMPTY_IDS : ids; + parsedIds.set(value, ids); + return ids; } function integer(value: unknown, min: number, max: number): number | undefined { diff --git a/src/components/database/timeline/TimelineLinkEditor.tsx b/src/components/database/timeline/TimelineLinkEditor.tsx index 9ec881c99..ff2813a46 100644 --- a/src/components/database/timeline/TimelineLinkEditor.tsx +++ b/src/components/database/timeline/TimelineLinkEditor.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { memo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { TimelineDependencyLink, TimelineDependencyType } from '@/application/database-yjs'; @@ -53,7 +53,7 @@ interface TimelineLinkEditorProps { * start-to-start / finish), its lag in days (negative = lead) and removal. * Anchored at the click point inside the canvas body. */ -export function TimelineLinkEditor({ +export const TimelineLinkEditor = memo(function TimelineLinkEditor({ selection, link, predecessorTitle, @@ -65,10 +65,14 @@ export function TimelineLinkEditor({ }: TimelineLinkEditorProps) { const { t } = useTranslation(); const [lagText, setLagText] = useState(String(link.lag)); + // The draft follows the stored lag (another link selected, or a remote + // edit) — adjusted during render rather than in an effect. + const [shownLag, setShownLag] = useState(link.lag); - useEffect(() => { + if (shownLag !== link.lag) { + setShownLag(link.lag); setLagText(String(link.lag)); - }, [link.lag, selection?.predecessorId, selection?.successorId]); + } const commitLag = () => { const lag = Math.trunc(Number(lagText)); @@ -164,4 +168,4 @@ export function TimelineLinkEditor({ ); -} +}); diff --git a/src/components/database/timeline/TimelineView.tsx b/src/components/database/timeline/TimelineView.tsx index e1c7270ce..bc3bd8cad 100644 --- a/src/components/database/timeline/TimelineView.tsx +++ b/src/components/database/timeline/TimelineView.tsx @@ -471,6 +471,12 @@ export function TimelineView({ setting }: { setting: TimelineLayoutSetting }) { const selectedLinkKey = selectedLink ? timelineLinkKey(selectedLink.predecessorId, selectedLink.successorId) : ''; const selectedLinkMeta = selectedLink ? linkOf(graph, selectedLink.predecessorId, selectedLink.successorId) : null; + // Canvas → body coordinates for the popover anchor; stable across scroll frames. + const editorSelection = useMemo( + () => (selectedLink ? { ...selectedLink, x: selectedLink.x + sidebarWidth, y: selectedLink.y } : null), + [selectedLink, sidebarWidth] + ); + const closeLinkEditor = useCallback(() => setSelectedLink(null), []); const handleLinkChange = useCallback( (next: TimelineDependencyLink) => { if (!selectedLink) return; @@ -701,14 +707,14 @@ export function TimelineView({ setting }: { setting: TimelineLayoutSetting }) { ) : null} {selectedLink && selectedLinkMeta ? ( setSelectedLink(null)} + onClose={closeLinkEditor} /> ) : null} diff --git a/src/components/database/timeline/scale/dependencies.ts b/src/components/database/timeline/scale/dependencies.ts index c8078090c..ba6498a84 100644 --- a/src/components/database/timeline/scale/dependencies.ts +++ b/src/components/database/timeline/scale/dependencies.ts @@ -37,11 +37,11 @@ export interface BuildDependencyGraphOptions { links?: Record; } -function pushUnique(map: Map, key: string, value: string) { - const list = map.get(key) ?? []; +function push(map: Map, key: string, value: string) { + const list = map.get(key); - if (!list.includes(value)) list.push(value); - map.set(key, list); + if (list) list.push(value); + else map.set(key, [value]); } /** Build both directions of the graph, ignoring links to rows outside the view. */ @@ -54,16 +54,20 @@ export function buildDependencyGraph( const predecessors = new Map(); const dependents = new Map(); const listsSuccessors = options.direction === TimelineDependencyDirection.Blocking; + // A cell may repeat an id; each edge is recorded once. + const seen = new Set(); rowIds.forEach((rowId) => { - const linked = (relations.get(rowId) ?? []).filter((id) => id !== rowId && known.has(id)); - - linked.forEach((other) => { + (relations.get(rowId) ?? []).forEach((other) => { + if (other === rowId || !known.has(other)) return; // A "Blocking" field lists the rows this one precedes; flip the edge. const [predecessor, successor] = listsSuccessors ? [rowId, other] : [other, rowId]; + const edge = timelineLinkKey(predecessor, successor); - pushUnique(predecessors, successor, predecessor); - pushUnique(dependents, predecessor, successor); + if (seen.has(edge)) return; + seen.add(edge); + push(predecessors, successor, predecessor); + push(dependents, predecessor, successor); }); }); From f912b00fb23857a2b6148ea28c715186ba85850f Mon Sep 17 00:00:00 2001 From: Nathan Date: Mon, 14 Sep 2026 01:24:48 +0000 Subject: [PATCH 14/21] fix(timeline): align table column headers with their cells The header placed the table toggle after the property columns while each row placed its open button before them, so column edges in the header sat one button-width left of the cells. Rows now keep the open button in the same trailing slot as the toggle, and the calculations footer reserves that slot too; a BDD assertion checks header, cell and calculation x. Also a second react-best-practice pass on the last three commits: - the connector-drag hook bound its window listeners per pointermove (its effect depended on the preview object); it now depends on a boolean like the bar drag does - one memoized row index serves the bar press and the link editor's titles instead of a Map rebuild per press and two rescans per render - the layout-setting comparators short-circuit on identity now that parsed links / ids are cached Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NoEH4HcEiojEcioVSLE3QM --- .../bdd/features/database/timeline.feature | 1 + playwright/bdd/steps/timeline.steps.ts | 12 +++++++ .../database-yjs/timeline-layout.ts | 4 ++- .../database/timeline/TimelineSidebarRow.tsx | 24 +++++++------- .../database/timeline/TimelineView.tsx | 31 ++++++++++++------- .../timeline/hooks/useTimelineLinkDrag.ts | 8 +++-- 6 files changed, 54 insertions(+), 26 deletions(-) diff --git a/playwright/bdd/features/database/timeline.feature b/playwright/bdd/features/database/timeline.feature index f01148dae..46a160757 100644 --- a/playwright/bdd/features/database/timeline.feature +++ b/playwright/bdd/features/database/timeline.feature @@ -236,6 +236,7 @@ Feature: Timeline view interactions When I show "Progress" as a table column Then the table has a "Progress" column reading 40 for "Design" And the docked table is 140 px wider + And the "Progress" column header, cells and calculation line up When I set the "Progress" column calculation to "Sum" Then the "Progress" column calculation reads "Sum40" When I hide the "Progress" table column diff --git a/playwright/bdd/steps/timeline.steps.ts b/playwright/bdd/steps/timeline.steps.ts index 4d8879519..94f1bae9c 100644 --- a/playwright/bdd/steps/timeline.steps.ts +++ b/playwright/bdd/steps/timeline.steps.ts @@ -613,6 +613,18 @@ Then('the docked table is {int} px wider', async ({ page }, delta) => { .toBe(before + delta); }); +Then('the {string} column header, cells and calculation line up', async ({ page }, name) => { + const fieldId = TABLE_FIELD_ID[name]; + const header = await page.getByTestId(`timeline-table-header-${fieldId}`).boundingBox(); + const cell = await page.getByTestId(`timeline-table-cell-${rowId(page, 'Design')}-${fieldId}`).boundingBox(); + const calculation = await page.getByTestId(`timeline-calculation-${fieldId}`).boundingBox(); + + if (!header || !cell || !calculation) throw new Error(`Column ${name} is not fully rendered`); + expect(cell.x).toBeCloseTo(header.x, 0); + expect(calculation.x).toBeCloseTo(header.x, 0); + expect(cell.width).toBeCloseTo(header.width, 0); +}); + When('I set the {string} column calculation to {string}', async ({ page }, name, calculation) => { await page.getByTestId(`timeline-calculation-${TABLE_FIELD_ID[name]}`).click(); await page.getByRole('menuitem', { name: calculation, exact: true }).click(); diff --git a/src/application/database-yjs/timeline-layout.ts b/src/application/database-yjs/timeline-layout.ts index e1396531a..bc560f034 100644 --- a/src/application/database-yjs/timeline-layout.ts +++ b/src/application/database-yjs/timeline-layout.ts @@ -59,6 +59,7 @@ function linkMap(value: unknown): Record { } function sameLinks(a: Record, b: Record) { + if (a === b) return true; const keys = Object.keys(a); return ( @@ -250,7 +251,8 @@ export function createTimelineLayoutStore( use24Hour ); let snapshot = read(); - const sameIds = (a: string[], b: string[]) => a.length === b.length && a.every((id, index) => id === b[index]); + const sameIds = (a: string[], b: string[]) => + a === b || (a.length === b.length && a.every((id, index) => id === b[index])); const getSnapshot = () => { const next = read(); diff --git a/src/components/database/timeline/TimelineSidebarRow.tsx b/src/components/database/timeline/TimelineSidebarRow.tsx index fd75bc1b6..77d7abb47 100644 --- a/src/components/database/timeline/TimelineSidebarRow.tsx +++ b/src/components/database/timeline/TimelineSidebarRow.tsx @@ -113,12 +113,24 @@ export const TimelineSidebarRow = memo( {icon ? : null} {row.title || t('grid.row.titlePlaceholder', { defaultValue: 'Untitled' })} + {tableFieldIds.map((fieldId) => ( +
+ +
+ ))} + {/* Trailing control slot, the same width as the header's table toggle, so + the property columns line up with their headers. */}
diff --git a/src/components/database/timeline/TimelineView.tsx b/src/components/database/timeline/TimelineView.tsx index bc3bd8cad..54de1791a 100644 --- a/src/components/database/timeline/TimelineView.tsx +++ b/src/components/database/timeline/TimelineView.tsx @@ -267,9 +267,11 @@ export function TimelineView({ setting }: { setting: TimelineLayoutSetting }) { [hasEndField, setting.endFieldId, setting.fieldId, updateStartEnd] ); - const rowsRef = useRef(rows); + // Row lookups by id (bar press followers, editor titles) without rescans. + const rowById = useMemo(() => new Map(rows.map((row) => [row.rowId, row] as const)), [rows]); + const rowByIdRef = useRef(rowById); - rowsRef.current = rows; + rowByIdRef.current = rowById; const handleDragCommit = useCallback( (preview: TimelineDragPreview) => { @@ -281,7 +283,7 @@ export function TimelineView({ setting }: { setting: TimelineLayoutSetting }) { return; } - const byId = new Map(rowsRef.current.map((candidate) => [candidate.rowId, candidate] as const)); + const byId = rowByIdRef.current; const row = byId.get(preview.rowId); // A timed row without an end keeps its synthetic length only while moving. const keepSingle = Boolean(row && !row.isRange && preview.mode === 'move'); @@ -322,7 +324,7 @@ export function TimelineView({ setting }: { setting: TimelineLayoutSetting }) { (event: ReactPointerEvent, row: TimelineRowModel, mode: TimelineDragMode) => { if (!permissions.editable || !row.start) return; const span = getBarSpan(row.start, row.end, row.allDay); - const byId = new Map(rowsRef.current.map((candidate) => [candidate.rowId, candidate] as const)); + const byId = rowByIdRef.current; // Dependents move with the bar per the "Shift dependents" setting; each // carries the dependencies it has inside the moving set so "only when // overlapping" can cascade through the chain. @@ -498,10 +500,12 @@ export function TimelineView({ setting }: { setting: TimelineLayoutSetting }) { writeLink(selectedLink.predecessorId, selectedLink.successorId, 'remove'); setSelectedLink(null); }, [selectedLink, selectedLinkKey, setting.dependencyLinks, updateSetting, writeLink]); - const rowTitle = useCallback( - (rowId: string) => rowsRef.current.find((row) => row.rowId === rowId)?.title || t('grid.row.titlePlaceholder'), - [t] - ); + const editorTitles = useMemo(() => { + if (!selectedLink) return null; + const titleOf = (rowId: string) => rowById.get(rowId)?.title || t('grid.row.titlePlaceholder'); + + return { predecessor: titleOf(selectedLink.predecessorId), successor: titleOf(selectedLink.successorId) }; + }, [rowById, selectedLink, t]); const handleLinkPointerDown = useCallback( (event: ReactPointerEvent, row: TimelineRowModel, rect: BarRect) => { const index = rowIndexById.get(row.rowId); @@ -630,7 +634,7 @@ export function TimelineView({ setting }: { setting: TimelineLayoutSetting }) { className={cn( // Grid-style header cell: the primary field name plus the table toggle. 'sticky left-0 z-30 flex h-full shrink-0 items-center border-b border-r border-border-primary bg-background-primary', - showSidebar ? 'justify-between pr-1' : 'justify-center' + showSidebar ? 'justify-between' : 'justify-center' )} // Line the field name up with the row titles, which sit after the // 40px hover gutter when the table is editable. @@ -656,6 +660,7 @@ export function TimelineView({ setting }: { setting: TimelineLayoutSetting }) {
))} + {/* Same trailing control slot as the header toggle and the rows' open button. */} +
) : null} diff --git a/src/components/database/timeline/hooks/useTimelineLinkDrag.ts b/src/components/database/timeline/hooks/useTimelineLinkDrag.ts index 8d3a5044e..329a69362 100644 --- a/src/components/database/timeline/hooks/useTimelineLinkDrag.ts +++ b/src/components/database/timeline/hooks/useTimelineLinkDrag.ts @@ -57,8 +57,12 @@ export function useTimelineLinkDrag({ scrollerRef, sidebarWidth, onCommit }: Use [scrollerRef, sidebarWidth] ); + // Listeners are bound once per drag (a boolean dependency); every move only + // updates the preview state, so nothing is re-registered per pointer event. + const dragging = link !== null; + useEffect(() => { - if (!link) return; + if (!dragging) return; const handleMove = (event: PointerEvent) => { const active = activeRef.current; @@ -109,7 +113,7 @@ export function useTimelineLinkDrag({ scrollerRef, sidebarWidth, onCommit }: Use window.removeEventListener('pointercancel', handleCancel); window.removeEventListener('keydown', handleKey, true); }; - }, [link, onCommit, toCanvas]); + }, [dragging, onCommit, toCanvas]); /** Begin on the handle's pointer-down; `from` is the source bar's right edge in canvas coordinates. */ const startLink = useCallback( From c1f0438781f63118a72ea2438d588ebd851283dd Mon Sep 17 00:00:00 2001 From: Nathan Date: Mon, 14 Sep 2026 02:18:25 +0000 Subject: [PATCH 15/21] refactor(timeline): react best-practice pass over the whole branch Three re-render findings from reviewing the branch against the Vercel React rules, all on hot paths: - TimelineRow handed the memoized TimelineBar a fresh onLinkPointerDown lambda every render, re-rendering every bar's cells and tooltip subtree on scroll-pill flips, drag start/end and selection; it is a stable useCallback now. - The header's drag highlight re-reconciled every column label per drag step; the labels are their own memo child (HeaderColumns) so a step touches one element. - Every table row mounted its own useNewRowDispatch / useDuplicateRowDispatch (each a bundle of database-context subscriptions read only in click handlers). ListRowActions is split into a presentational RowActionsMenu plus the List's hook-owning wrapper; the timeline creates the dispatches once and hands rows a stable actions object (deps behind a ref). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NoEH4HcEiojEcioVSLE3QM --- .../database/list/ListRowActions.tsx | 148 +++++++++--------- .../database/timeline/TimelineHeader.tsx | 93 ++++++----- .../database/timeline/TimelineRow.tsx | 27 +++- .../database/timeline/TimelineSidebarRow.tsx | 31 ++-- .../database/timeline/TimelineView.tsx | 44 +++++- 5 files changed, 215 insertions(+), 128 deletions(-) diff --git a/src/components/database/list/ListRowActions.tsx b/src/components/database/list/ListRowActions.tsx index d2953e3ab..e2e33edb2 100644 --- a/src/components/database/list/ListRowActions.tsx +++ b/src/components/database/list/ListRowActions.tsx @@ -26,81 +26,41 @@ import { useListHasSorts } from './ListSortState'; export const getListGroupCellsData = getGroupRowCellsData; -function useListRowActions({ - groupFieldId, - groupId, - rowId, - rowOrders, -}: { - groupFieldId?: string; - groupId?: string; - rowId: string; - rowOrders: Row[]; -}) { - const fields = useDatabaseFields(); - const view = useDatabaseView(); - const createRow = useNewRowDispatch(); - const duplicateRow = useDuplicateRowDispatch(); - const [loadingAction, setLoadingAction] = useState<'above' | 'below' | 'duplicate' | null>(null); - - const addBelow = useCallback(async () => { - setLoadingAction('below'); - try { - await createRow({ beforeRowId: rowId, cellsData: getListGroupCellsData(fields, groupFieldId, groupId, view) }); - } finally { - setLoadingAction(null); - } - }, [createRow, fields, groupFieldId, groupId, rowId, view]); - - const addAbove = useCallback(async () => { - const rowIndex = rowOrders.findIndex((row) => row.id === rowId); - const previousRowId = rowIndex > 0 ? rowOrders[rowIndex - 1]?.id : undefined; - - setLoadingAction('above'); - try { - await createRow({ - beforeRowId: previousRowId, - cellsData: getListGroupCellsData(fields, groupFieldId, groupId, view), - }); - } finally { - setLoadingAction(null); - } - }, [createRow, fields, groupFieldId, groupId, rowId, rowOrders, view]); - - const duplicate = useCallback(async () => { - setLoadingAction('duplicate'); - try { - await duplicateRow(rowId); - } finally { - setLoadingAction(null); - } - }, [duplicateRow, rowId]); - - return { addAbove, addBelow, duplicate, loadingAction }; +/** The row-creation callbacks a gutter menu needs; how they are produced is the caller's business. */ +export interface RowActionCallbacks { + addAbove: () => Promise; + addBelow: () => Promise; + duplicate: () => Promise; } -export function ListRowActions({ +/** + * The hover `+` / `⋮⋮` gutter with its menu, loading and confirmation state. + * Takes the row's callbacks as props so a view rendering many rows can create + * the dispatch hooks once and hand each row a bound closure, instead of every + * row subscribing to the database context on its own. + */ +export function RowActionsMenu({ dragHandleRef, - groupFieldId, - groupId, reorderable, rowId, - rowOrders, -}: { + addAbove, + addBelow, + duplicate, +}: RowActionCallbacks & { dragHandleRef?: (element: HTMLDivElement | null) => void; - groupFieldId?: string; - groupId?: string; reorderable: boolean; rowId: string; - rowOrders: Row[]; }) { const { t } = useTranslation(); - const { addAbove, addBelow, duplicate, loadingAction } = useListRowActions({ - groupFieldId, - groupId, - rowId, - rowOrders, - }); + const [loadingAction, setLoadingAction] = useState<'above' | 'below' | 'duplicate' | null>(null); + const run = useCallback(async (kind: 'above' | 'below' | 'duplicate', action: () => Promise) => { + setLoadingAction(kind); + try { + await action(); + } finally { + setLoadingAction(null); + } + }, []); const hasSorts = useListHasSorts(); const [menuOpen, setMenuOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false); @@ -127,21 +87,21 @@ export function ListRowActions({ label: t('grid.row.insertRecordAbove'), icon: UpIcon, loading: loadingAction === 'above', - run: () => runAfterSortCheck(() => void addAbove()), + run: () => runAfterSortCheck(() => void run('above', addAbove)), }, { testId: 'row-menu-insert-below', label: t('grid.row.insertRecordBelow'), icon: PlusIcon, loading: loadingAction === 'below', - run: () => runAfterSortCheck(() => void addBelow()), + run: () => runAfterSortCheck(() => void run('below', addBelow)), }, { testId: 'row-menu-duplicate', label: t('grid.row.duplicate'), icon: DuplicateIcon, loading: loadingAction === 'duplicate', - run: () => void duplicate(), + run: () => void run('duplicate', duplicate), }, { testId: 'row-menu-delete', @@ -152,7 +112,7 @@ export function ListRowActions({ destructive: true, }, ], - [addAbove, addBelow, duplicate, loadingAction, runAfterSortCheck, t] + [addAbove, addBelow, duplicate, loadingAction, run, runAfterSortCheck, t] ); return ( @@ -168,7 +128,7 @@ export function ListRowActions({ loading={loadingAction === 'above' || loadingAction === 'below'} onClick={(event) => { event.stopPropagation(); - runAfterSortCheck(() => void addBelow()); + runAfterSortCheck(() => void run('below', addBelow)); }} size='icon-sm' tabIndex={-1} @@ -253,4 +213,52 @@ export function ListRowActions({ ); } +/** The List view's gutter: the same menu, with each row creating its own dispatches. */ +export function ListRowActions({ + dragHandleRef, + groupFieldId, + groupId, + reorderable, + rowId, + rowOrders, +}: { + dragHandleRef?: (element: HTMLDivElement | null) => void; + groupFieldId?: string; + groupId?: string; + reorderable: boolean; + rowId: string; + rowOrders: Row[]; +}) { + const fields = useDatabaseFields(); + const view = useDatabaseView(); + const createRow = useNewRowDispatch(); + const duplicateRow = useDuplicateRowDispatch(); + + const addBelow = useCallback( + () => createRow({ beforeRowId: rowId, cellsData: getListGroupCellsData(fields, groupFieldId, groupId, view) }), + [createRow, fields, groupFieldId, groupId, rowId, view] + ); + const addAbove = useCallback(() => { + const rowIndex = rowOrders.findIndex((row) => row.id === rowId); + const previousRowId = rowIndex > 0 ? rowOrders[rowIndex - 1]?.id : undefined; + + return createRow({ + beforeRowId: previousRowId, + cellsData: getListGroupCellsData(fields, groupFieldId, groupId, view), + }); + }, [createRow, fields, groupFieldId, groupId, rowId, rowOrders, view]); + const duplicate = useCallback(() => duplicateRow(rowId), [duplicateRow, rowId]); + + return ( + + ); +} + export default ListRowActions; diff --git a/src/components/database/timeline/TimelineHeader.tsx b/src/components/database/timeline/TimelineHeader.tsx index 7ed6f04d7..4be08f781 100644 --- a/src/components/database/timeline/TimelineHeader.tsx +++ b/src/components/database/timeline/TimelineHeader.tsx @@ -17,6 +17,59 @@ interface TimelineHeaderProps { stickyOffset: number; } +/** + * The column (or segment) labels alone. Memoized apart from the drag + * highlight, which changes on every drag step while the labels only change + * with the scroll window or the scale. + */ +const HeaderColumns = memo( + ({ mode, segments, columns, stickyOffset }: Omit) => ( + <> + {mode === 'segments' + ? segments.map((segment) => ( +
+ + {segment.label} + +
+ )) + : columns.map((column) => ( +
+ {column.label ? ( + + {column.label} + + ) : null} +
+ ))} + + ) +); + +HeaderColumns.displayName = 'HeaderColumns'; + /** * One header row styled like the calendar's day header: `text-sm` labels, * today as the filled pill. Fine scales label every column; the Year scale @@ -31,45 +84,7 @@ export const TimelineHeader = memo( style={{ width: canvasWidth, height: TIMELINE_HEADER_HEIGHT }} data-testid='timeline-header' > - {mode === 'segments' - ? segments.map((segment) => ( -
- - {segment.label} - -
- )) - : columns.map((column) => ( -
- {column.label ? ( - - {column.label} - - ) : null} -
- ))} + {highlight ? (
Promise; + addBelow: (rowId: string, groupFieldId?: string, groupId?: string) => Promise; + duplicate: (rowId: string) => Promise; +} + interface TimelineRowProps { row: TimelineRowModel; rect: BarRect | null; @@ -34,8 +41,8 @@ interface TimelineRowProps { anyDragging?: boolean; /** User-preference time formatter shared by all bars. */ formatTime: (date: Date) => string; - /** View-ordered rows for the table's insert / reorder actions. */ - rowOrders: Row[]; + /** The table gutter's insert / duplicate actions. */ + rowActions: TimelineRowActions; /** Properties shown as table columns after the title. */ tableFieldIds: string[]; /** When grouped: the group field and this row's group, so inserts land in the same group. */ @@ -106,7 +113,7 @@ export const TimelineRow = memo( progressPreview, anyDragging, formatTime, - rowOrders, + rowActions, tableFieldIds, groupFieldId, groupId, @@ -133,6 +140,14 @@ export const TimelineRow = memo( }, [onBarPointerDown, onSelect, row] ); + // Stable per row so a row re-render (scroll pill, drag state) doesn't + // re-render the memoized bar. + const handleLinkPointerDown = useCallback( + (event: ReactPointerEvent) => { + if (rect) onLinkPointerDown?.(event, row, rect); + }, + [onLinkPointerDown, rect, row] + ); const handleCanvasClick = (event: MouseEvent) => { if (!canAssignDate) { @@ -160,7 +175,7 @@ export const TimelineRow = memo( width={sidebarWidth} editable={editable} selected={selected} - rowOrders={rowOrders} + rowActions={rowActions} tableFieldIds={tableFieldIds} groupFieldId={groupFieldId} groupId={groupId} @@ -209,7 +224,7 @@ export const TimelineRow = memo( formatTime={formatTime} linkable={linkable} linkTarget={linkTarget} - onLinkPointerDown={onLinkPointerDown ? (event) => onLinkPointerDown(event, row, rect) : undefined} + onLinkPointerDown={onLinkPointerDown ? handleLinkPointerDown : undefined} onOpen={onOpen} onPointerDown={handleBarPointerDown} /> diff --git a/src/components/database/timeline/TimelineSidebarRow.tsx b/src/components/database/timeline/TimelineSidebarRow.tsx index 77d7abb47..02b86a6a1 100644 --- a/src/components/database/timeline/TimelineSidebarRow.tsx +++ b/src/components/database/timeline/TimelineSidebarRow.tsx @@ -1,13 +1,13 @@ -import { memo, MutableRefObject, useRef } from 'react'; +import { memo, MutableRefObject, useCallback, useRef } from 'react'; import { useTranslation } from 'react-i18next'; -import { Row, useRowMetaSelector } from '@/application/database-yjs'; +import { useRowMetaSelector } from '@/application/database-yjs'; import { ReactComponent as ExpandIcon } from '@/assets/icons/expand.svg'; import { DropRowIndicator } from '@/components/database/components/drag-and-drop/DropRowIndicator'; import { type Edge, useRowDnd } from '@/components/database/components/drag-and-drop/useRowDnd'; import { CardField } from '@/components/database/components/field/CardField'; import { GalleryRowIcon } from '@/components/database/gallery/GalleryRowIcon'; -import { ListRowActions } from '@/components/database/list/ListRowActions'; +import { RowActionsMenu } from '@/components/database/list/ListRowActions'; import { useListHasSorts } from '@/components/database/list/ListSortState'; import { Button } from '@/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; @@ -15,6 +15,7 @@ import { cn } from '@/lib/utils'; import { TIMELINE_TABLE_COLUMN_WIDTH } from './constants'; import { TimelineRowModel } from './hooks/useTimelineRows'; +import type { TimelineRowActions } from './TimelineRow'; export const TIMELINE_ROW_DRAG_TYPE = 'database-timeline-row'; @@ -23,8 +24,8 @@ interface TimelineSidebarRowProps { width: number; editable: boolean; selected?: boolean; - /** View-ordered rows, needed by "insert above". */ - rowOrders: Row[]; + /** The gutter's insert / duplicate actions, owned by the view. */ + rowActions: TimelineRowActions; /** Properties shown as columns after the title. */ tableFieldIds: string[]; /** When grouped: inserted rows inherit this group's value. */ @@ -48,7 +49,7 @@ export const TimelineSidebarRow = memo( width, editable, selected, - rowOrders, + rowActions, tableFieldIds, groupFieldId, groupId, @@ -63,6 +64,16 @@ export const TimelineSidebarRow = memo( const cellRef = useRef(null); const dragHandleRef = useRef(null); const hasSorts = useListHasSorts(); + // Bound per row so the shared menu needs no row-level dispatch hooks. + const addAbove = useCallback( + () => rowActions.addAbove(row.rowId, groupFieldId, groupId), + [groupFieldId, groupId, row.rowId, rowActions] + ); + const addBelow = useCallback( + () => rowActions.addBelow(row.rowId, groupFieldId, groupId), + [groupFieldId, groupId, row.rowId, rowActions] + ); + const duplicate = useCallback(() => rowActions.duplicate(row.rowId), [row.rowId, rowActions]); const dnd = useRowDnd({ dragHandleRef, dropTargetRef, @@ -87,15 +98,15 @@ export const TimelineSidebarRow = memo( data-testid={`timeline-sidebar-cell-${row.rowId}`} > {editable ? ( - { dragHandleRef.current = element; }} reorderable={Boolean(onDropRow)} rowId={row.rowId} - rowOrders={rowOrders} - groupFieldId={groupFieldId} - groupId={groupId} + addAbove={addAbove} + addBelow={addBelow} + duplicate={duplicate} /> ) : (
diff --git a/src/components/database/timeline/TimelineView.tsx b/src/components/database/timeline/TimelineView.tsx index 54de1791a..a67a14ada 100644 --- a/src/components/database/timeline/TimelineView.tsx +++ b/src/components/database/timeline/TimelineView.tsx @@ -17,6 +17,7 @@ import { useDatabaseViewId, useFieldSelector, useDatabaseFields, + useDatabaseView, useFieldsSelector, useNavigateToRow, usePrimaryFieldId, @@ -24,7 +25,12 @@ import { import { useUpdateAnyCellDispatch, useUpdateStartEndTimeCell } from '@/application/database-yjs/dispatch/cell'; import { useUpdateRelationCellDispatch } from '@/application/database-yjs/dispatch/relation'; import { useSetUpTimelineDependenciesDispatch } from '@/application/database-yjs/dispatch/timeline-dependencies'; -import { useNewRowDispatch, useReorderRowDispatch } from '@/application/database-yjs/dispatch/row'; +import { + useDuplicateRowDispatch, + useNewRowDispatch, + useReorderRowDispatch, +} from '@/application/database-yjs/dispatch/row'; +import { getGroupRowCellsData } from '@/application/database-yjs/group-row'; import { useUpdateTimelineSetting } from '@/application/database-yjs/dispatch'; import { YjsDatabaseKey } from '@/application/types'; import { ReactComponent as CollapseIcon } from '@/assets/icons/double_arrow_left.svg'; @@ -82,7 +88,7 @@ import { TimelineGrid } from './TimelineGrid'; import { TimelineHeader } from './TimelineHeader'; import { TimelineGroupFooter, TimelineGroupRow } from './TimelineGroupRow'; import { useTimelineGrouping } from './TimelineGroupingContext'; -import { TimelineRow } from './TimelineRow'; +import { TimelineRow, TimelineRowActions } from './TimelineRow'; // Calendar cards carry only the title; a timeline bar adds chips solely for // properties the user set to "always shown" in this view's Properties menu. @@ -152,6 +158,38 @@ export function TimelineView({ setting }: { setting: TimelineLayoutSetting }) { // Bars and arrows are addressed by item index; non-row items carry no id. const itemRowIds = useMemo(() => items.map((item) => (item.kind === 'row' ? item.row.rowId : '')), [items]); const reorderRow = useReorderRowDispatch(); + // The row gutter's actions, created once here rather than by every row: + // their dispatch hooks subscribe to the whole database context, and the + // values are only read inside click handlers. + const duplicateRow = useDuplicateRowDispatch(); + const databaseView = useDatabaseView(); + // Everything the actions read at click time lives in a ref, so the actions + // object itself never changes and never invalidates the memoized rows. + const rowActionsDepsRef = useRef({ rowOrders, newRow, duplicateRow, databaseFields, databaseView }); + + rowActionsDepsRef.current = { rowOrders, newRow, duplicateRow, databaseFields, databaseView }; + const rowActions = useMemo(() => { + const cells = (groupFieldId?: string, groupId?: string) => { + const deps = rowActionsDepsRef.current; + + return getGroupRowCellsData(deps.databaseFields, groupFieldId, groupId, deps.databaseView); + }; + + return { + addAbove: (rowId, groupFieldId, groupId) => { + const deps = rowActionsDepsRef.current; + const index = deps.rowOrders.findIndex((row) => row.id === rowId); + + return deps.newRow({ + beforeRowId: index > 0 ? deps.rowOrders[index - 1]?.id : undefined, + cellsData: cells(groupFieldId, groupId), + }); + }, + addBelow: (rowId, groupFieldId, groupId) => + rowActionsDepsRef.current.newRow({ beforeRowId: rowId, cellsData: cells(groupFieldId, groupId) }), + duplicate: (rowId) => rowActionsDepsRef.current.duplicateRow(rowId), + }; + }, []); // Same reorder semantics as the List view: drop above / below a row, then // tell the view which row now precedes the moved one. const handleDropRow = useCallback( @@ -792,7 +830,7 @@ export function TimelineView({ setting }: { setting: TimelineLayoutSetting }) { progressPreview={isDragged && preview?.mode === 'progress' ? preview.progress : undefined} anyDragging={dragging} formatTime={formatTimeDisplay} - rowOrders={rowOrders} + rowActions={rowActions} tableFieldIds={tableFieldIds} onOpen={handleOpen} onSelect={setSelectedRowId} From 89a30acbd46200dc75551028017fb26594a2dc82 Mon Sep 17 00:00:00 2001 From: Nathan Date: Mon, 14 Sep 2026 08:45:41 +0000 Subject: [PATCH 16/21] fix(timeline): keep the hover card clear of the docked table The bar's hover card aligned to the bar's start and clamped only to the viewport, so a bar running under the sticky table opened its card over the table cells. The tooltip now pads its collision boundary on the left up to the table's right edge (measured once per opening), so the card shifts right of the table while still sitting above the bar. Covered by a new step in the hover scenario. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NoEH4HcEiojEcioVSLE3QM --- .../bdd/features/database/timeline.feature | 1 + playwright/bdd/steps/timeline.steps.ts | 20 ++++++++ .../database/timeline/TimelineBar.tsx | 47 +++++++++++++++++-- .../database/timeline/TimelineRow.tsx | 5 ++ .../database/timeline/TimelineView.tsx | 12 ++++- 5 files changed, 80 insertions(+), 5 deletions(-) diff --git a/playwright/bdd/features/database/timeline.feature b/playwright/bdd/features/database/timeline.feature index 46a160757..58e4f0612 100644 --- a/playwright/bdd/features/database/timeline.feature +++ b/playwright/bdd/features/database/timeline.feature @@ -58,6 +58,7 @@ Feature: Timeline view interactions Scenario: Hovering shows the card; table rows select and open When I hover the "Design" bar Then the timeline hover card shows "Design" with a one day duration + And the hover card starts at the "Design" bar and clears the docked table When I click the table row "Build" Then the "Build" row and bar are selected When I click the empty canvas of the "Build" row diff --git a/playwright/bdd/steps/timeline.steps.ts b/playwright/bdd/steps/timeline.steps.ts index 94f1bae9c..7bd8ac9e9 100644 --- a/playwright/bdd/steps/timeline.steps.ts +++ b/playwright/bdd/steps/timeline.steps.ts @@ -325,6 +325,26 @@ Then('the timeline hover card shows {string} with a one day duration', async ({ await expect(card).toContainText('1 day'); }); +Then('the hover card starts at the {string} bar and clears the docked table', async ({ page }, title) => { + // The tooltip role also carries a visually hidden copy; measure the + // positioned content, once its enter animation (a zoom from 95%) is done. + const content = page + .locator('[data-slot="tooltip-content"]') + .filter({ has: page.getByTestId('timeline-bar-hover-card') }); + + await expect(content).toBeVisible(); + await page.waitForTimeout(300); + const card = await content.boundingBox(); + const bar = await barBox(page, title); + const table = await TimelineSelectors.sidebarRow(page, rowId(page, title)).boundingBox(); + + if (!card || !table) throw new Error('hover card and table row must be visible'); + // Aligned to the bar's start (like Notion), above it, and never over the table cells. + expect(Math.abs(card.x - bar.x)).toBeLessThanOrEqual(2); + expect(card.y + card.height).toBeLessThanOrEqual(bar.y + 1); + expect(card.x).toBeGreaterThanOrEqual(table.x + table.width - 1); +}); + When('I click the table row {string}', async ({ page }, title) => { await TimelineSelectors.sidebarRow(page, rowId(page, title)).click(); }); diff --git a/src/components/database/timeline/TimelineBar.tsx b/src/components/database/timeline/TimelineBar.tsx index 738a2e82c..5153aff06 100644 --- a/src/components/database/timeline/TimelineBar.tsx +++ b/src/components/database/timeline/TimelineBar.tsx @@ -1,5 +1,5 @@ import dayjs from 'dayjs'; -import { memo, PointerEvent as ReactPointerEvent } from 'react'; +import { memo, PointerEvent as ReactPointerEvent, ReactNode, useLayoutEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Column } from '@/application/database-yjs'; @@ -44,6 +44,12 @@ interface TimelineBarProps { progressPreview?: number; /** Suppresses the hover card, e.g. during any drag. */ hoverDisabled?: boolean; + /** + * The element the hover card stays inside, and how much of its left edge + * the docked table covers: the card must never sit over the table cells. + */ + hoverCardBoundary?: Element | null; + hoverCardInset?: number; /** User-preference time formatter, owned by the view so bars don't subscribe individually. */ formatTime: (date: Date) => string; /** A dependency field is bound: show the connector handle and accept link drops. */ @@ -55,6 +61,39 @@ interface TimelineBarProps { onPointerDown?: (event: ReactPointerEvent, mode: TimelineDragMode) => void; } +/** + * The tooltip content, kept clear of the docked table: the viewport stays + * the collision boundary (so the card still sits above the bar), padded on + * the left up to the table's right edge. Mounted only while the card is + * open, so the one layout read happens per opening, not per render. + */ +function BarHoverCardContent({ + boundary, + inset, + children, +}: { + boundary?: Element | null; + inset: number; + children: ReactNode; +}) { + const [leftPadding, setLeftPadding] = useState(0); + + useLayoutEffect(() => { + setLeftPadding(boundary ? boundary.getBoundingClientRect().left + inset : 0); + }, [boundary, inset]); + + return ( + + {children} + + ); +} + /** frappe-style hover card: title, dates and duration (plus progress when bound). */ function BarHoverCard({ row, progress }: { row: TimelineRowModel; progress?: number }) { const { t } = useTranslation(); @@ -104,6 +143,8 @@ export const TimelineBar = memo( progress, progressPreview, hoverDisabled, + hoverCardBoundary, + hoverCardInset = 0, formatTime, linkable, linkTarget, @@ -229,9 +270,9 @@ export const TimelineBar = memo( {bar} {hoverDisabled ? null : ( - + - + )} diff --git a/src/components/database/timeline/TimelineRow.tsx b/src/components/database/timeline/TimelineRow.tsx index c149b2f34..2fc48d37b 100644 --- a/src/components/database/timeline/TimelineRow.tsx +++ b/src/components/database/timeline/TimelineRow.tsx @@ -39,6 +39,8 @@ interface TimelineRowProps { progressPreview?: number; /** Any drag is in progress somewhere on the canvas. */ anyDragging?: boolean; + /** The scroller the hover card stays inside (right of the docked table). */ + hoverCardBoundary?: Element | null; /** User-preference time formatter shared by all bars. */ formatTime: (date: Date) => string; /** The table gutter's insert / duplicate actions. */ @@ -112,6 +114,7 @@ export const TimelineRow = memo( progress, progressPreview, anyDragging, + hoverCardBoundary, formatTime, rowActions, tableFieldIds, @@ -221,6 +224,8 @@ export const TimelineRow = memo( progress={progress} progressPreview={progressPreview} hoverDisabled={anyDragging} + hoverCardBoundary={hoverCardBoundary} + hoverCardInset={sidebarWidth} formatTime={formatTime} linkable={linkable} linkTarget={linkTarget} diff --git a/src/components/database/timeline/TimelineView.tsx b/src/components/database/timeline/TimelineView.tsx index a67a14ada..b967d1564 100644 --- a/src/components/database/timeline/TimelineView.tsx +++ b/src/components/database/timeline/TimelineView.tsx @@ -111,7 +111,14 @@ function dragLabelFor(preview: TimelineDragPreview): TimelineBarDragLabel { export function TimelineView({ setting }: { setting: TimelineLayoutSetting }) { const { t } = useTranslation(); - const scrollerRef = useRef(null); + const scrollerRef = useRef(null); + // The element itself, as state, so rows rendered before the scroller + // mounted still get it as their hover-card boundary. + const [scrollerEl, setScrollerEl] = useState(null); + const attachScroller = useCallback((element: HTMLDivElement | null) => { + scrollerRef.current = element; + setScrollerEl(element); + }, []); const { isDocumentBlock, variant, paddingStart, paddingEnd } = useDatabaseContext(); const fixedViewport = shouldUseFixedDatabaseViewport({ isDocumentBlock, variant }); const updateSetting = useUpdateTimelineSetting(); @@ -661,7 +668,7 @@ export function TimelineView({ setting }: { setting: TimelineLayoutSetting }) { emptyEvents={emptyEvents} />
Date: Sun, 13 Sep 2026 21:38:14 +0800 Subject: [PATCH 17/21] feat: import Confluence HTML ZIP exports (#554) * Fix duplicate feed layout import * Add Confluence HTML ZIP import to web * Improve import cancellation, keyboard access, and lazy loading * Test native file chooser activation for page imports * Enable Confluence workspace import from settings * Open imported space home pages from the sidebar --- playwright/e2e/page/import-page.spec.ts | 58 +++++- src/@types/translations/en.json | 9 +- src/@types/translations/zh-CN.json | 6 + .../__tests__/import-api.multipart.test.ts | 118 +++++++++++ .../http/__tests__/import-api.test.ts | 47 ++++- .../services/js-services/http/import-api.ts | 93 ++++++--- src/application/types.ts | 3 + src/assets/icons/confluence.svg | 4 + .../_shared/file-dropzone/FileDropzone.tsx | 52 ++--- .../__tests__/FileDropzone.test.tsx | 74 +++++++ .../importer/ImporterDialogContent.tsx | 121 ++++++------ .../__tests__/ImporterDialogContent.test.tsx | 76 ++++++- src/components/app/import/ImportDialog.tsx | 62 ++++-- .../ImportDialog.confluence.test.tsx | 156 +++++++++++++++ .../app/import/__tests__/import-i18n.test.ts | 18 +- .../__tests__/import-notion-abort.test.ts | 109 ---------- .../app/import/__tests__/import-zip.test.ts | 186 ++++++++++++++++++ src/components/app/import/import-service.ts | 34 +++- src/components/app/outline/SpaceItem.tsx | 27 ++- .../__tests__/SpaceItem.homePage.test.tsx | 155 +++++++++++++++ .../app/settings/ManageDataPanel.tsx | 62 +----- src/components/app/settings/Settings.tsx | 16 +- .../Settings.workspaceImport.test.tsx | 152 ++++++++++++++ src/components/app/workspaces/Workspaces.tsx | 15 +- 24 files changed, 1336 insertions(+), 317 deletions(-) create mode 100644 src/application/services/js-services/http/__tests__/import-api.multipart.test.ts create mode 100644 src/assets/icons/confluence.svg create mode 100644 src/components/_shared/file-dropzone/__tests__/FileDropzone.test.tsx create mode 100644 src/components/app/import/__tests__/ImportDialog.confluence.test.tsx delete mode 100644 src/components/app/import/__tests__/import-notion-abort.test.ts create mode 100644 src/components/app/import/__tests__/import-zip.test.ts create mode 100644 src/components/app/outline/__tests__/SpaceItem.homePage.test.tsx create mode 100644 src/components/app/settings/__tests__/Settings.workspaceImport.test.tsx diff --git a/playwright/e2e/page/import-page.spec.ts b/playwright/e2e/page/import-page.spec.ts index 60a83bf5d..ad8735fd8 100644 --- a/playwright/e2e/page/import-page.spec.ts +++ b/playwright/e2e/page/import-page.spec.ts @@ -13,12 +13,13 @@ import { generateRandomEmail } from '../../support/test-config'; /** * Import — BDD scenarios for the sidebar "+" → Import flow. * - * Two formats are supported: + * Four formats are supported: * - Text & Markdown — fully client-side: parses MD locally, creates an empty * Document via PageService.add, fetches its collab, mutates the Y.Doc, * and PUTs the encoded update back. * - CSV — server flow: createDatabaseCsvImportTask → upload to presigned * URL → poll status until Completed (mocked here for hermetic tests). + * - Notion and Confluence — upload exported ZIP files and queue a server import. * * The dialog is owned by Outline.tsx (a persistent ancestor) so it survives * the dropdown unmount that happens when the Import menu item is clicked. @@ -72,6 +73,61 @@ test.describe('Feature: Import', () => { }); }); + test('Scenario: Import buttons open native file choosers with the expected file types', async ({ page, request }) => { + await signInAndWaitForApp(page, request, testEmail); + await page.evaluate(() => { + delete (window as Window & { Cypress?: boolean }).Cypress; + }); + await expect(PageSelectors.names(page).first()).toBeVisible({ timeout: 30000 }); + await openImportDialogFromAddMenu(page); + + const formats = [ + { format: 'markdown', accept: '.md,.markdown,.txt,text/markdown,text/plain', multiple: false }, + { format: 'csv', accept: '.csv,text/csv', multiple: true }, + { + format: 'notion', + accept: '.zip,application/zip,application/x-zip,application/x-zip-compressed', + multiple: false, + }, + { + format: 'confluence', + accept: '.zip,application/zip,application/x-zip,application/x-zip-compressed', + multiple: false, + }, + ]; + + for (const { format, accept, multiple } of formats) { + await test.step(`Clicking ${format} requests a native file chooser`, async () => { + // Waiting for a browser chooser catches regressions that directly + // assigning files to the hidden input cannot detect. + const [chooser] = await Promise.all([ + page.waitForEvent('filechooser', { timeout: 10000 }), + page.getByTestId(`import-${format}`).click(), + ]); + + expect(await chooser.element().getAttribute('data-testid')).toBe(`import-${format}-input`); + expect(await chooser.element().getAttribute('accept')).toBe(accept); + expect(chooser.isMultiple()).toBe(multiple); + await chooser.setFiles([]); + await expect(ImportSelectors.dialog(page)).toBeVisible(); + }); + } + + for (const key of ['Enter', 'Space']) { + await test.step(`${key} opens the Confluence native file chooser`, async () => { + const button = page.getByTestId('import-confluence'); + + await button.focus(); + const [chooser] = await Promise.all([page.waitForEvent('filechooser', { timeout: 10000 }), button.press(key)]); + + expect(await chooser.element().getAttribute('data-testid')).toBe('import-confluence-input'); + expect(chooser.isMultiple()).toBe(false); + await chooser.setFiles([]); + await expect(ImportSelectors.dialog(page)).toBeVisible(); + }); + } + }); + test('Scenario: Importing a Markdown file creates a Document page with the file content', async ({ page, request, diff --git a/src/@types/translations/en.json b/src/@types/translations/en.json index 0a46ab823..c6bd40455 100644 --- a/src/@types/translations/en.json +++ b/src/@types/translations/en.json @@ -282,6 +282,7 @@ "documentFromV010": "Document from v0.1.0", "databaseFromV010": "Database from v0.1.0", "notionZip": "Notion Exported Zip File", + "confluenceZip": "Confluence HTML Export (.zip)", "csv": "CSV", "database": "Database", "success": "Imported successfully", @@ -290,6 +291,7 @@ "partialSuccess_one": "Imported {{success}} of {{count}} file", "partialSuccess_other": "Imported {{success}} of {{count}} files", "notionImportStarted": "Notion import started. Pages will appear in this view when ready.", + "confluenceImportStarted": "Confluence import started. Pages will appear in this view when ready.", "failed": "Import failed", "failedFile": "Could not import {{name}}: {{reason}}", "failedFiles": "Could not import: {{names}}", @@ -870,7 +872,7 @@ "description": "Manage your AppFlowy workspace data", "importWorkspace": { "title": "Import your workspace", - "tooltip": "Import your workspace from an AppFlowy backup ZIP file", + "tooltip": "Create a new workspace from an AppFlowy backup, Notion export, or Confluence HTML export ZIP file", "button": "Import", "success": "Import started. We will send a confirmation email once the import is complete.", "failed": "Failed to import workspace" @@ -3626,12 +3628,15 @@ "importNotion": "Import from Notion", "importWorkspace": "Import workspace", "importFromAppFlowy": "Import from AppFlowy", + "importCreatesWorkspace": "Each import creates a new workspace.", + "importFromConfluence": "Import from Confluence", "importFromNotion": "Import from Notion", "import": "Import", "importSuccess": "Uploaded successfully", - "importSuccessMessage": "We'll notify you when the import is complete. After that, you can view your imported pages in the sidebar.", + "importSuccessMessage": "We'll notify you when the import is complete. Select the new workspace from the workspace switcher to view your imported pages.", "importFailed": "Import failed, please check the file format", "dropNotionFile": "Drop your Notion zip file here to upload, or click to browse", + "dropConfluenceFile": "Drop your Confluence HTML export (.zip) here to upload, or click to browse", "dropAppFlowyFile": "Drop your AppFlowy zip file here to upload, or click to browse", "error": { "pageNameIsEmpty": "The page name is empty, please try another one" diff --git a/src/@types/translations/zh-CN.json b/src/@types/translations/zh-CN.json index c3d94bfb4..26d2b61d3 100644 --- a/src/@types/translations/zh-CN.json +++ b/src/@types/translations/zh-CN.json @@ -202,6 +202,8 @@ "documentFromV010": "来自 v0.1.0 的文档", "databaseFromV010": "来自 v0.1.0 的数据库", "notionZip": "Notion 导出的 Zip 文件", + "confluenceZip": "Confluence HTML 导出文件 (.zip)", + "confluenceImportStarted": "Confluence 导入已开始。完成后,页面将显示在此视图中。", "csv": "CSV", "database": "数据库" }, @@ -1963,6 +1965,10 @@ "saveThisPage": "使用此模板创建" }, "web": { + "importSuccessMessage": "导入完成后我们会通知你。请从工作区切换器中选择新工作区,查看导入的页面。", + "importCreatesWorkspace": "每次导入都会创建一个新工作区。", + "importFromConfluence": "从 Confluence 导入", + "dropConfluenceFile": "将 Confluence HTML 导出文件 (.zip) 拖放到此处上传,或点击浏览", "continueWithGoogle": "使用 Google 账户登录", "continueWithGithub": "使用 GitHub 账户登录", "continueWithDiscord": "使用 Discord 账户登录" diff --git a/src/application/services/js-services/http/__tests__/import-api.multipart.test.ts b/src/application/services/js-services/http/__tests__/import-api.multipart.test.ts new file mode 100644 index 000000000..4edf6b5fa --- /dev/null +++ b/src/application/services/js-services/http/__tests__/import-api.multipart.test.ts @@ -0,0 +1,118 @@ +import axios from 'axios'; + +import { executeAPIVoidRequest, getAxios } from '../core'; +import { ImportMultipartUploadInfo, uploadImportFileMultipart } from '../import-api'; + +jest.mock('@/application/services/js-services/http/core', () => ({ + executeAPIRequest: jest.fn(), + executeAPIVoidRequest: jest.fn(), + getAxios: jest.fn(), +})); + +function multipart(partCount: number): ImportMultipartUploadInfo { + return { + s3_key: 'import.zip', + upload_id: 'upload-1', + part_presigned_urls: Array.from({ length: partCount }, (_, i) => ({ + part_number: i + 1, + presigned_url: `https://upload.test/part-${i + 1}`, + })), + }; +} + +describe('multipart import request lifecycle', () => { + const file = new File(['12345678901234'], 'export.zip', { type: 'application/zip' }); + let put: jest.SpyInstance; + let post: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + put = jest.spyOn(axios, 'put'); + post = jest.fn().mockResolvedValue({ data: { code: 0 } }); + jest.mocked(getAxios).mockReturnValue({ post } as never); + jest.mocked(executeAPIVoidRequest).mockImplementation(async (request) => { + await request(); + }); + }); + + afterEach(() => jest.restoreAllMocks()); + + it.each(['network', 'http'] as const)( + 'cancels sibling requests after a %s failure and never starts queued parts', + async (failure) => { + const caller = new AbortController(); + const pendingSignals: AbortSignal[] = []; + + put.mockImplementation((url: string, _data: Blob, config: { signal: AbortSignal }) => { + if (url.endsWith('part-1')) { + return failure === 'network' + ? Promise.reject(new Error('Network unavailable')) + : Promise.resolve({ status: 503, statusText: 'Unavailable', headers: {} }); + } + + pendingSignals.push(config.signal); + return new Promise((_resolve, reject) => { + config.signal.addEventListener('abort', () => reject(new Error('cancelled')), { once: true }); + }); + }); + + await expect(uploadImportFileMultipart(file, multipart(7), jest.fn(), caller.signal)).rejects.toMatchObject({ + message: failure === 'network' ? 'Network unavailable' : 'Multipart upload failed for part 1. Unavailable', + }); + + expect(put).toHaveBeenCalledTimes(5); + expect(pendingSignals).toHaveLength(4); + expect(pendingSignals.every((signal) => signal.aborted)).toBe(true); + expect(caller.signal.aborted).toBe(false); + expect(post).not.toHaveBeenCalled(); + } + ); + + it('forwards cancellation to multipart finalization and releases its abort listener', async () => { + const caller = new AbortController(); + const removeListener = jest.spyOn(caller.signal, 'removeEventListener'); + let finalizationSignal: AbortSignal | undefined; + let notifyFinalization: () => void = () => undefined; + const finalizationStarted = new Promise((resolve) => { + notifyFinalization = resolve; + }); + + put.mockResolvedValue({ status: 200, headers: { etag: '"part-1"' } }); + post.mockImplementation((_url, _data, config) => { + finalizationSignal = config?.signal; + notifyFinalization(); + return new Promise((_resolve, reject) => { + finalizationSignal?.addEventListener('abort', () => reject(new Error('cancelled')), { once: true }); + }); + }); + + const upload = uploadImportFileMultipart(file, multipart(1), jest.fn(), caller.signal); + + await finalizationStarted; + expect(finalizationSignal).toBeDefined(); + caller.abort(); + await expect(upload).rejects.toThrow('cancelled'); + expect(finalizationSignal?.aborted).toBe(true); + expect(removeListener).toHaveBeenCalledWith('abort', expect.any(Function)); + }); + + it('completes successful parts in order and releases its abort listener', async () => { + const caller = new AbortController(); + const removeListener = jest.spyOn(caller.signal, 'removeEventListener'); + + put.mockImplementation(async (url: string) => ({ status: 200, headers: { etag: `"${url.split('/').pop()}"` } })); + + await uploadImportFileMultipart(file, multipart(3), jest.fn(), caller.signal); + + expect(post).toHaveBeenCalledWith( + '/api/import/complete-multipart', + { + s3_key: 'import.zip', + upload_id: 'upload-1', + parts: [1, 2, 3].map((n) => ({ part_number: n, e_tag: `part-${n}` })), + }, + { signal: expect.any(AbortSignal) } + ); + expect(removeListener).toHaveBeenCalledWith('abort', expect.any(Function)); + }); +}); diff --git a/src/application/services/js-services/http/__tests__/import-api.test.ts b/src/application/services/js-services/http/__tests__/import-api.test.ts index 6dda34711..a62510f93 100644 --- a/src/application/services/js-services/http/__tests__/import-api.test.ts +++ b/src/application/services/js-services/http/__tests__/import-api.test.ts @@ -1,6 +1,11 @@ import { executeAPIRequest, getAxios } from '@/application/services/js-services/http/core'; -import { createImportTask, CreateImportTaskType } from '../import-api'; +import { + createConfluenceImportTask, + createImportTask, + CreateImportTaskType, + createNotionImportTask, +} from '../import-api'; jest.mock('@/application/services/js-services/http/core', () => ({ executeAPIRequest: jest.fn(), @@ -16,6 +21,7 @@ describe('createImportTask', () => { it.each([ ['AppFlowy workspace', CreateImportTaskType.Workspace], ['Notion workspace', CreateImportTaskType.Notion], + ['Confluence space', CreateImportTaskType.Confluence], ])('sends the selected task type for an %s import', async (_label, taskType) => { const post = jest.fn(); @@ -48,3 +54,42 @@ describe('createImportTask', () => { ); }); }); + +describe.each([ + ['notion', createNotionImportTask], + ['confluence', createConfluenceImportTask], +] as const)('create %s page import task', (source, createTask) => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it.each([false, true])('preserves the destination and upload response (multipart=%s)', async (isMultipart) => { + const post = jest.fn(); + const multipart = isMultipart + ? { + s3_key: 'workspace/task.zip', + upload_id: 'upload-id', + part_presigned_urls: [{ part_number: 1, presigned_url: 'https://example.com/part-1' }], + } + : undefined; + + jest.mocked(getAxios).mockReturnValue({ post } as never); + jest.mocked(executeAPIRequest).mockImplementation(async (request) => { + await request(); + return { task_id: 'task-id', presigned_url: 'https://example.com/upload', multipart }; + }); + + const payload = { content_length: 4096, md5_base64: 'checksum' }; + + await expect(createTask('workspace/id', 'parent-page-id', payload)).resolves.toEqual({ + taskId: 'task-id', + presignedUrl: 'https://example.com/upload', + multipart: multipart ?? null, + }); + expect(post).toHaveBeenCalledTimes(1); + expect(post).toHaveBeenCalledWith(`/api/import/workspace%2Fid/${source}`, payload, { + params: { page_id: 'parent-page-id' }, + headers: { 'X-Host': expect.any(String) }, + }); + }); +}); diff --git a/src/application/services/js-services/http/import-api.ts b/src/application/services/js-services/http/import-api.ts index 556abe598..bcd35bac6 100644 --- a/src/application/services/js-services/http/import-api.ts +++ b/src/application/services/js-services/http/import-api.ts @@ -36,13 +36,16 @@ export interface ImportUploadTask { export enum CreateImportTaskType { Notion = 'Notion', Workspace = 'Workspace', + Confluence = 'Confluence', } -export interface CreateNotionImportTaskPayload { +export interface CreateZipImportTaskPayload { content_length: number; md5_base64: string; } +export type CreateNotionImportTaskPayload = CreateZipImportTaskPayload; + function toImportUploadTask(data: CreateImportTaskRaw): ImportUploadTask { return { taskId: data.task_id, @@ -77,7 +80,25 @@ export async function createNotionImportTask( parentViewId: string, payload: CreateNotionImportTaskPayload ): Promise { - const url = `/api/import/${encodeURIComponent(workspaceId)}/notion`; + return createZipImportTask(workspaceId, parentViewId, 'notion', payload); +} + +/** Create a Confluence HTML export import under the selected page. */ +export async function createConfluenceImportTask( + workspaceId: string, + parentViewId: string, + payload: CreateZipImportTaskPayload +): Promise { + return createZipImportTask(workspaceId, parentViewId, 'confluence', payload); +} + +async function createZipImportTask( + workspaceId: string, + parentViewId: string, + source: 'notion' | 'confluence', + payload: CreateZipImportTaskPayload +): Promise { + const url = `/api/import/${encodeURIComponent(workspaceId)}/${source}`; return executeAPIRequest(() => getAxios()?.post>(url, payload, { @@ -136,7 +157,13 @@ export async function uploadImportFileMultipart( const bytesUploaded = new Array(partCount).fill(0); const completedParts: { e_tag: string; part_number: number }[] = []; - let aborted = false; + // One failure ends the entire upload. Give sibling requests their own shared + // controller so cleanup does not abort the caller's controller or a later retry. + const uploadController = new AbortController(); + const abortUpload = () => uploadController.abort(); + + if (signal?.aborted) abortUpload(); + else signal?.addEventListener('abort', abortUpload, { once: true }); const reportProgress = () => { const total = bytesUploaded.reduce((sum, b) => sum + b, 0); @@ -145,7 +172,7 @@ export async function uploadImportFileMultipart( }; const uploadPart = async (i: number) => { - if (aborted) return; + if (uploadController.signal.aborted) return; const partInfo = multipart.part_presigned_urls[i]; const start = (partInfo.part_number - 1) * partSize; @@ -154,7 +181,7 @@ export async function uploadImportFileMultipart( const resp = await axios.put(partInfo.presigned_url, blob, { validateStatus: () => true, - signal, + signal: uploadController.signal, headers: { 'Content-Type': 'application/zip', }, @@ -165,7 +192,6 @@ export async function uploadImportFileMultipart( }); if (resp.status < 200 || resp.status >= 300) { - aborted = true; return Promise.reject({ code: -1, message: `Multipart upload failed for part ${partInfo.part_number}. ${resp.statusText}`, @@ -175,7 +201,6 @@ export async function uploadImportFileMultipart( const eTag = (resp.headers['etag'] as string | undefined)?.replace(/"/g, ''); if (!eTag) { - aborted = true; return Promise.reject({ code: -1, message: `Missing ETag in response for part ${partInfo.part_number}`, @@ -188,29 +213,38 @@ export async function uploadImportFileMultipart( // Upload parts with limited concurrency const queue = Array.from({ length: partCount }, (_, i) => i); const workers = Array.from({ length: Math.min(MAX_CONCURRENCY, partCount) }, async () => { - // `signal` cancels the in-flight PUTs; this check stops the workers from picking up new - // parts once the caller has given up, so a cancelled upload winds down instead of - // grinding through the rest of the queue. - while (queue.length > 0 && !aborted && !signal?.aborted) { + // Stop taking queued parts when the caller cancels or a sibling request fails. + while (queue.length > 0 && !uploadController.signal.aborted) { const idx = queue.shift()!; await uploadPart(idx); } }); - await Promise.all(workers); + try { + await Promise.all(workers); - // Never finalise an upload the caller cancelled — the parts are incomplete. - if (signal?.aborted) { - return Promise.reject({ code: -1, message: 'Multipart upload cancelled' }); - } + if (uploadController.signal.aborted) { + throw new Error('Multipart upload cancelled'); + } - // Complete the multipart upload on the server - await completeImportMultipart({ - s3_key: multipart.s3_key, - upload_id: multipart.upload_id, - parts: completedParts.sort((a, b) => a.part_number - b.part_number), - }); + await completeImportMultipart( + { + s3_key: multipart.s3_key, + upload_id: multipart.upload_id, + parts: completedParts.sort((a, b) => a.part_number - b.part_number), + }, + uploadController.signal + ); + } catch (error) { + abortUpload(); + // Let every request settle before callers cancel the server task or retry. + // Promise.all alone rejects immediately while other uploads keep running. + await Promise.allSettled(workers); + throw error; + } finally { + signal?.removeEventListener('abort', abortUpload); + } } export async function cancelImportTask(taskId: string) { @@ -219,14 +253,17 @@ export async function cancelImportTask(taskId: string) { return executeAPIVoidRequest(() => getAxios()?.post(url)); } -async function completeImportMultipart(data: { - s3_key: string; - upload_id: string; - parts: { e_tag: string; part_number: number }[]; -}) { +async function completeImportMultipart( + data: { + s3_key: string; + upload_id: string; + parts: { e_tag: string; part_number: number }[]; + }, + signal?: AbortSignal +) { const url = `/api/import/complete-multipart`; - return executeAPIVoidRequest(() => getAxios()?.post(url, data)); + return executeAPIVoidRequest(() => getAxios()?.post(url, data, { signal })); } export async function createDatabaseCsvImportTask( diff --git a/src/application/types.ts b/src/application/types.ts index 4ef77fb79..e77a96c91 100644 --- a/src/application/types.ts +++ b/src/application/types.ts @@ -1814,6 +1814,9 @@ export interface SpaceInfo { /** The created time of the space view (timestamp). */ space_created_at?: number; + /** Whether this space retains a home document at its own view ID. */ + has_space_home_page?: boolean; + /** The space icon. If not set, uses the default icon. */ space_icon?: string; diff --git a/src/assets/icons/confluence.svg b/src/assets/icons/confluence.svg new file mode 100644 index 000000000..b56596ad1 --- /dev/null +++ b/src/assets/icons/confluence.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/components/_shared/file-dropzone/FileDropzone.tsx b/src/components/_shared/file-dropzone/FileDropzone.tsx index ee7548ad4..8f5821d38 100644 --- a/src/components/_shared/file-dropzone/FileDropzone.tsx +++ b/src/components/_shared/file-dropzone/FileDropzone.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useState } from 'react'; +import { useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; @@ -19,8 +19,10 @@ function FileDropzone({ onChange, accept, multiple, disabled, placeholder, loadi const { t } = useTranslation(); const [dragging, setDragging] = useState(false); const fileInputRef = useRef(null); + const isDisabled = Boolean(disabled || loading); const handleFiles = (files: FileList) => { + if (isDisabled) return; const fileArray = Array.from(files); if (onChange) { @@ -36,6 +38,7 @@ function FileDropzone({ onChange, accept, multiple, disabled, placeholder, loadi event.preventDefault(); event.stopPropagation(); setDragging(false); + if (isDisabled) return; const toastError = () => toast.error(t('document.plugins.file.noImages')); @@ -67,6 +70,7 @@ function FileDropzone({ onChange, accept, multiple, disabled, placeholder, loadi const handleDragOver = (event: React.DragEvent) => { event.preventDefault(); event.stopPropagation(); + if (isDisabled) return; setDragging(true); }; @@ -77,6 +81,7 @@ function FileDropzone({ onChange, accept, multiple, disabled, placeholder, loadi }; const handleClick = () => { + if (isDisabled) return; fileInputRef.current?.click(); }; @@ -91,33 +96,34 @@ function FileDropzone({ onChange, accept, multiple, disabled, placeholder, loadi
-
- {placeholder || ( - <> - {t('document.plugins.file.fileUploadHint')} - click to {t('document.plugins.file.fileUploadHintSuffix')} - - )} -
+ + {placeholder || ( + <> + {t('document.plugins.file.fileUploadHint')} + click to {t('document.plugins.file.fileUploadHintSuffix')} + + )} + +
- {progress !== undefined && ( - {Math.round(progress)}% - )} + {progress !== undefined && {Math.round(progress)}%}
)} diff --git a/src/components/_shared/file-dropzone/__tests__/FileDropzone.test.tsx b/src/components/_shared/file-dropzone/__tests__/FileDropzone.test.tsx new file mode 100644 index 000000000..faab3af30 --- /dev/null +++ b/src/components/_shared/file-dropzone/__tests__/FileDropzone.test.tsx @@ -0,0 +1,74 @@ +import { fireEvent, render, screen } from '@testing-library/react'; + +import FileDropzone from '../FileDropzone'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +function fileInput() { + return screen.getByTestId('file-dropzone').querySelector('input') as HTMLInputElement; +} + +describe('FileDropzone', () => { + afterEach(() => jest.restoreAllMocks()); + + it('offers a focusable native browse button and opens the picker once for keyboard activation', () => { + const onChange = jest.fn(); + + render(); + + const browse = screen.getByRole('button', { name: 'Browse Confluence ZIP' }); + const input = fileInput(); + const click = jest.spyOn(input, 'click'); + + expect(browse.tagName).toBe('BUTTON'); + expect(browse.type).toBe('button'); + browse.focus(); + expect(document.activeElement).toBe(browse); + // Native Enter/Space activation produces a click with no pointer click count. + fireEvent.click(browse, { detail: 0 }); + expect(click).toHaveBeenCalledTimes(1); + + const file = new File(['zip'], 'space.html.zip', { type: 'application/zip' }); + + fireEvent.change(input, { target: { files: [file] } }); + expect(onChange).toHaveBeenCalledWith([file]); + expect(input.value).toBe(''); + }); + + it.each(['disabled', 'loading'] as const)('blocks browsing and dropped files while %s', (state) => { + const onChange = jest.fn(); + + render(); + + const browse = screen.getByRole('button', { name: 'Browse ZIP' }); + const input = fileInput(); + const click = jest.spyOn(input, 'click'); + const file = new File(['zip'], 'space.zip'); + + expect(browse.disabled).toBe(true); + expect(input.disabled).toBe(true); + fireEvent.click(browse, { detail: 0 }); + fireEvent.drop(screen.getByTestId('file-dropzone'), { + dataTransfer: { files: [file], clearData: jest.fn() }, + }); + fireEvent.change(input, { target: { files: [file] } }); + + expect(click).not.toHaveBeenCalled(); + expect(onChange).not.toHaveBeenCalled(); + }); + + it.each([false, true])('preserves ZIP drop selection with multiple=%s', (multiple) => { + const onChange = jest.fn(); + const files = [new File(['one'], 'one.zip'), new File(['two'], 'two.zip')]; + const clearData = jest.fn(); + + render(); + fireEvent.drop(screen.getByTestId('file-dropzone'), { dataTransfer: { files, clearData } }); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(multiple ? files : [files[0]]); + expect(clearData).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/_shared/more-actions/importer/ImporterDialogContent.tsx b/src/components/_shared/more-actions/importer/ImporterDialogContent.tsx index 8e0dc773b..d77bbf24a 100644 --- a/src/components/_shared/more-actions/importer/ImporterDialogContent.tsx +++ b/src/components/_shared/more-actions/importer/ImporterDialogContent.tsx @@ -9,23 +9,44 @@ import { TabPanel, ViewTab, ViewTabs } from '@/components/_shared/tabs/ViewTabs' const ZIP_ACCEPT = '.zip,application/zip,application/x-zip,application/x-zip-compressed'; -type ImportSource = 'appflowy' | 'notion'; +const IMPORT_SOURCES = { + appflowy: { + taskType: FileService.CreateImportTaskType.Workspace, + label: 'web.importFromAppFlowy', + placeholder: 'web.dropAppFlowyFile', + }, + notion: { + taskType: FileService.CreateImportTaskType.Notion, + label: 'web.importFromNotion', + placeholder: 'web.dropNotionFile', + }, + confluence: { + taskType: FileService.CreateImportTaskType.Confluence, + label: 'web.importFromConfluence', + placeholder: 'web.dropConfluenceFile', + }, +} as const; + +type ImportSource = keyof typeof IMPORT_SOURCES; function ImporterDialogContent({ source, onSuccess }: { source?: string; onSuccess: () => void }) { const { t } = useTranslation(); - const [value, setValue] = React.useState(source === 'appflowy' ? 'appflowy' : 'notion'); + const [value, setValue] = React.useState( + source === 'appflowy' || source === 'confluence' ? source : 'notion' + ); const [progress, setProgress] = React.useState(0); const [isError, setIsError] = React.useState(false); + const [isUploading, setIsUploading] = React.useState(false); const handleUpload = useCallback( async (file: File) => { + if (isUploading) return; + setIsUploading(true); + setProgress(0); setIsError(false); try { - const taskType = - value === 'appflowy' ? FileService.CreateImportTaskType.Workspace : FileService.CreateImportTaskType.Notion; - await FileService.importFile(file, { - taskType, + taskType: IMPORT_SOURCES[value].taskType, onProgress: setProgress, }); onSuccess(); @@ -33,72 +54,56 @@ function ImporterDialogContent({ source, onSuccess }: { source?: string; onSucce } catch (e: any) { notify.error(e.message); setIsError(true); + } finally { + setIsUploading(false); } }, - [onSuccess, value] + [isUploading, onSuccess, value] ); - const isUploading = !isError && progress < 1 && progress > 0; - return (
+

{t('web.importCreatesWorkspace')}

setValue(newValue)} value={value} + variant='scrollable' + scrollButtons='auto' + allowScrollButtonsMobile > - - + {Object.entries(IMPORT_SOURCES).map(([source, config]) => ( + + ))}
- - { - if (!files.length) return; - void handleUpload(files[0]); - }} - disabled={isUploading} - placeholder={t('web.dropAppFlowyFile')} - loading={isUploading} - /> - {progress > 0 && ( - - )} - - - { - if (!files.length) return; - void handleUpload(files[0]); - }} - disabled={isUploading} - placeholder={t('web.dropNotionFile')} - loading={isUploading} - /> - {progress > 0 && ( - ( + + { + if (!files.length) return; + void handleUpload(files[0]); + }} + disabled={isUploading} + placeholder={t(config.placeholder)} + loading={isUploading} /> - )} - + {progress > 0 && ( + + )} + + ))}
); diff --git a/src/components/_shared/more-actions/importer/__tests__/ImporterDialogContent.test.tsx b/src/components/_shared/more-actions/importer/__tests__/ImporterDialogContent.test.tsx index 6d2a29620..f6d9955d2 100644 --- a/src/components/_shared/more-actions/importer/__tests__/ImporterDialogContent.test.tsx +++ b/src/components/_shared/more-actions/importer/__tests__/ImporterDialogContent.test.tsx @@ -1,6 +1,7 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { FileService } from '@/application/services/domains'; +import { notify } from '@/components/_shared/notify'; import ImporterDialogContent from '../ImporterDialogContent'; @@ -8,11 +9,14 @@ jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }), })); +jest.mock('@/components/_shared/notify', () => ({ notify: { error: jest.fn() } })); + jest.mock('@/application/services/domains', () => ({ FileService: { CreateImportTaskType: { Notion: 'Notion', Workspace: 'Workspace', + Confluence: 'Confluence', }, importFile: jest.fn(), }, @@ -36,6 +40,7 @@ describe('ImporterDialogContent', () => { it.each([ ['appflowy', FileService.CreateImportTaskType.Workspace], ['notion', FileService.CreateImportTaskType.Notion], + ['confluence', FileService.CreateImportTaskType.Confluence], ] as const)('routes the %s tab to the matching import task type', async (source, taskType) => { const file = new File(['workspace'], `${source}.zip`, { type: 'application/zip' }); @@ -49,4 +54,73 @@ describe('ImporterDialogContent', () => { }); }); }); + + it('lets users select Confluence and asks for an HTML export ZIP', async () => { + const onSuccess = jest.fn(); + const file = new File(['confluence'], 'space.html.zip', { type: 'application/zip' }); + + render(); + fireEvent.click(screen.getByRole('tab', { name: 'web.importFromConfluence' })); + + expect(screen.getByText('web.dropConfluenceFile')).toBeTruthy(); + uploadVisibleFile(file); + + await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1)); + expect(importFile).toHaveBeenCalledWith(file, { + taskType: FileService.CreateImportTaskType.Confluence, + onProgress: expect.any(Function), + }); + }); + + it('keeps controls disabled through preparation and finalization, then allows retry after failure', async () => { + let reportProgress: ((progress: number) => void) | undefined; + let resolveImport: (() => void) | undefined; + let rejectImport: ((error: Error) => void) | undefined; + + importFile.mockImplementation((_file, options) => { + reportProgress = options?.onProgress; + return new Promise((resolve, reject) => { + resolveImport = resolve; + rejectImport = reject; + }); + }); + + const onSuccess = jest.fn(); + const file = new File(['zip'], 'space.html.zip', { type: 'application/zip' }); + const expectControlsDisabled = (disabled: boolean) => { + for (const tab of screen.getAllByRole('tab')) { + expect((tab as HTMLButtonElement).disabled).toBe(disabled); + } + + expect(screen.getByTestId('file-dropzone').querySelector('input')?.disabled).toBe(disabled); + }; + + render(); + uploadVisibleFile(file); + await waitFor(() => expect(reportProgress).toBeDefined()); + // Task creation happens before the first upload progress callback. + expectControlsDisabled(true); + act(() => reportProgress?.(0.5)); + expectControlsDisabled(true); + // Uploading the last byte does not finish multipart finalization. + act(() => reportProgress?.(1)); + expectControlsDisabled(true); + expect(onSuccess).not.toHaveBeenCalled(); + + await act(async () => rejectImport?.(new Error('Finalization failed'))); + expect(notify.error).toHaveBeenCalledWith('Finalization failed'); + expectControlsDisabled(false); + + uploadVisibleFile(file); + expectControlsDisabled(true); + expect(screen.queryByRole('progressbar', { value: { now: 100 } })).toBeNull(); + expect(importFile).toHaveBeenCalledTimes(2); + + expect(screen.getByRole('tab', { name: 'web.importFromConfluence' }).getAttribute('aria-selected')).toBe('true'); + act(() => reportProgress?.(1)); + expectControlsDisabled(true); + await act(async () => resolveImport?.()); + expect(onSuccess).toHaveBeenCalledTimes(1); + expectControlsDisabled(false); + }); }); diff --git a/src/components/app/import/ImportDialog.tsx b/src/components/app/import/ImportDialog.tsx index 8bae346c1..b84a133a2 100644 --- a/src/components/app/import/ImportDialog.tsx +++ b/src/components/app/import/ImportDialog.tsx @@ -5,6 +5,7 @@ import { toast } from 'sonner'; import { ViewLayout } from '@/application/types'; import { ReactComponent as CloseIcon } from '@/assets/icons/close.svg'; +import { ReactComponent as ConfluenceIcon } from '@/assets/icons/confluence.svg'; import { ReactComponent as DatabaseIcon } from '@/assets/icons/database.svg'; import { ReactComponent as NotionIcon } from '@/assets/icons/notion.svg'; import { ReactComponent as TextIcon } from '@/assets/icons/text.svg'; @@ -12,6 +13,7 @@ import { useAppOperations, useCurrentWorkspaceId, useOpenPageModal, useToView } import { ImportAbortError, ImportCsvBatchItem, + importConfluenceZipToView, importCsvFilesAsDatabases, importNotionZipToView, populateDocumentWithMarkdown, @@ -20,7 +22,7 @@ import { const MARKDOWN_ACCEPT = '.md,.markdown,.txt,text/markdown,text/plain'; const CSV_ACCEPT = '.csv,text/csv'; -const NOTION_ACCEPT = '.zip,application/zip,application/x-zip,application/x-zip-compressed'; +const ZIP_ACCEPT = '.zip,application/zip,application/x-zip,application/x-zip-compressed'; // Enough failed names to be actionable in a toast without turning it into a wall of text. const MAX_REPORTED_FAILURES = 3; @@ -30,7 +32,8 @@ const MAX_REPORTED_FAILURES = 3; // Toasts render plain text, so there is nothing to escape for. const RAW_INTERPOLATION = { interpolation: { escapeValue: false } }; -type ImportFormat = 'markdown' | 'csv' | 'notion'; +type ZipImportFormat = 'notion' | 'confluence'; +type ImportFormat = 'markdown' | 'csv' | ZipImportFormat; interface CsvProgress { current: number; @@ -55,6 +58,7 @@ export default function ImportDialog({ open, parentViewId, prevViewId, onOpenCha const markdownInputRef = useRef(null); const csvInputRef = useRef(null); const notionInputRef = useRef(null); + const confluenceInputRef = useRef(null); const abortRef = useRef(null); // Abort any in-flight import on unmount so polling doesn't keep running @@ -79,10 +83,10 @@ export default function ImportDialog({ open, parentViewId, prevViewId, onOpenCha onOpenChange(false); }, [active, onOpenChange]); - // A CSV batch and a Notion zip upload can both run for minutes, so the close button doubles as + // A CSV batch and a ZIP upload can both run for minutes, so the close button doubles as // a cancel for them and keeps whatever already imported. Markdown blocks the button instead: // it is two round trips, and its page already exists by the time the upload starts. - const cancellable = active === 'csv' || active === 'notion'; + const cancellable = active === 'csv' || active === 'notion' || active === 'confluence'; const closeDisabled = active !== null && !cancellable; // The button doubles as the cancel control during a batch, so it has to say so. @@ -203,23 +207,27 @@ export default function ImportDialog({ open, parentViewId, prevViewId, onOpenCha [workspaceId, parentViewId, toView, close, t] ); - const handleNotion = useCallback( - async (file: File) => { + const handleZip = useCallback( + async (file: File, source: ZipImportFormat) => { if (!workspaceId) return; const controller = new AbortController(); abortRef.current?.abort(); abortRef.current = controller; - setActive('notion'); + setActive(source); try { - await importNotionZipToView({ + const importZip = source === 'confluence' ? importConfluenceZipToView : importNotionZipToView; + + await importZip({ workspaceId, parentViewId, file, signal: controller.signal, }); - toast.success(t('importPanel.notionImportStarted')); + toast.success( + t(source === 'confluence' ? 'importPanel.confluenceImportStarted' : 'importPanel.notionImportStarted') + ); close(); // eslint-disable-next-line } catch (e: any) { @@ -258,9 +266,19 @@ export default function ImportDialog({ open, parentViewId, prevViewId, onOpenCha const file = event.target.files?.[0]; event.target.value = ''; - if (file) void handleNotion(file); + if (file) void handleZip(file, 'notion'); + }, + [handleZip] + ); + + const onConfluencePicked = useCallback( + (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + + event.target.value = ''; + if (file) void handleZip(file, 'confluence'); }, - [handleNotion] + [handleZip] ); return ( @@ -335,6 +353,18 @@ export default function ImportDialog({ open, parentViewId, prevViewId, onOpenCha {t('importPanel.notionZip')} {active === 'notion' ? : null} + +
{/* The visible counter sits inside a disabled button, which assistive tech skips, so the @@ -365,11 +395,19 @@ export default function ImportDialog({ open, parentViewId, prevViewId, onOpenCha +
); diff --git a/src/components/app/import/__tests__/ImportDialog.confluence.test.tsx b/src/components/app/import/__tests__/ImportDialog.confluence.test.tsx new file mode 100644 index 000000000..8a1ffc00d --- /dev/null +++ b/src/components/app/import/__tests__/ImportDialog.confluence.test.tsx @@ -0,0 +1,156 @@ +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { toast } from 'sonner'; + +import { + ImportAbortError, + importConfluenceZipToView, + importNotionZipToView, +} from '@/components/app/import/import-service'; +import ImportDialog from '@/components/app/import/ImportDialog'; + +jest.mock('@/components/app/app.hooks', () => ({ + useAppOperations: () => ({ addPage: jest.fn() }), + useCurrentWorkspaceId: () => 'workspace-1', + useOpenPageModal: () => jest.fn(), + useToView: () => jest.fn(), +})); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +jest.mock('sonner', () => ({ toast: { success: jest.fn(), error: jest.fn() } })); + +jest.mock('@/components/app/import/import-service', () => ({ + ImportAbortError: class ImportAbortError extends Error {}, + importConfluenceZipToView: jest.fn(), + importNotionZipToView: jest.fn(), + importCsvFilesAsDatabases: jest.fn(), + populateDocumentWithMarkdown: jest.fn(), + stripFileExtension: (name: string) => name, +})); + +const importConfluence = importConfluenceZipToView as jest.MockedFunction; + +function renderDialog() { + const onOpenChange = jest.fn(); + const result = render(); + + return { ...result, onOpenChange }; +} + +function pickFile(file = new File(['html zip'], 'space.html.zip', { type: 'application/zip' })) { + fireEvent.change(screen.getByTestId('import-confluence-input'), { target: { files: [file] } }); + return file; +} + +describe('ImportDialog Confluence import', () => { + beforeEach(() => { + jest.clearAllMocks(); + importConfluence.mockReset(); + importConfluence.mockResolvedValue({ taskId: 'confluence-task' }); + }); + + it('opens a single ZIP picker from the Confluence tile', () => { + renderDialog(); + + const input = screen.getByTestId('import-confluence-input'); + const click = jest.spyOn(input, 'click'); + + fireEvent.click(screen.getByTestId('import-confluence')); + + expect(click).toHaveBeenCalledTimes(1); + expect(input.accept).toBe('.zip,application/zip,application/x-zip,application/x-zip-compressed'); + expect(input.multiple).toBe(false); + click.mockRestore(); + }); + + it('imports the selected ZIP into the current workspace and parent and reports the queued import', async () => { + const { onOpenChange } = renderDialog(); + const file = pickFile(); + + await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false)); + + expect(importConfluence).toHaveBeenCalledTimes(1); + expect(importConfluence).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + parentViewId: 'parent-1', + file, + signal: expect.any(AbortSignal), + }); + expect(importNotionZipToView).not.toHaveBeenCalled(); + expect(toast.success).toHaveBeenCalledWith('importPanel.confluenceImportStarted'); + expect(toast.error).not.toHaveBeenCalled(); + }); + + it('shows loading, blocks other formats and accidental dismissal, and lets the user cancel', async () => { + importConfluence.mockImplementation( + ({ signal }) => + new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => reject(new ImportAbortError()), { once: true }); + }) + ); + + const { onOpenChange } = renderDialog(); + + pickFile(); + await waitFor(() => expect(importConfluence).toHaveBeenCalledTimes(1)); + + const signal = importConfluence.mock.calls[0][0].signal; + const close = screen.getByTestId('import-dialog-close'); + + for (const format of ['markdown', 'csv', 'notion', 'confluence']) { + expect(screen.getByTestId(`import-${format}`).disabled).toBe(true); + } + + expect(within(screen.getByTestId('import-confluence')).getByRole('progressbar')).toBeTruthy(); + expect(close.disabled).toBe(false); + expect(close.getAttribute('aria-label')).toBe('importPanel.cancelImport'); + + fireEvent.keyDown(screen.getByTestId('import-dialog'), { key: 'Escape', code: 'Escape' }); + fireEvent.click(document.querySelector('.MuiBackdrop-root') as HTMLElement); + expect(onOpenChange).not.toHaveBeenCalled(); + expect(signal?.aborted).toBe(false); + + fireEvent.click(close); + + expect(signal?.aborted).toBe(true); + expect(onOpenChange).toHaveBeenCalledWith(false); + await waitFor(() => expect(within(screen.getByTestId('import-confluence')).queryByRole('progressbar')).toBeNull()); + expect(toast.success).not.toHaveBeenCalled(); + expect(toast.error).not.toHaveBeenCalled(); + }); + + it('reports an upload failure and lets the user retry the same file', async () => { + importConfluence.mockRejectedValueOnce(new Error('Upload failed')); + + const { onOpenChange } = renderDialog(); + const file = pickFile(); + + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Upload failed')); + + expect(onOpenChange).not.toHaveBeenCalled(); + expect(toast.success).not.toHaveBeenCalled(); + expect(screen.getByTestId('import-confluence').disabled).toBe(false); + expect(screen.getByTestId('import-confluence-input').value).toBe(''); + + pickFile(file); + await waitFor(() => expect(toast.success).toHaveBeenCalledWith('importPanel.confluenceImportStarted')); + expect(importConfluence).toHaveBeenCalledTimes(2); + expect(importConfluence.mock.calls[1][0].file).toBe(file); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it('aborts an unfinished upload when the dialog is unmounted', () => { + importConfluence.mockImplementation(() => new Promise(() => undefined)); + + const { unmount } = renderDialog(); + + pickFile(); + + const signal = importConfluence.mock.calls[0][0].signal; + + unmount(); + expect(signal?.aborted).toBe(true); + }); +}); diff --git a/src/components/app/import/__tests__/import-i18n.test.ts b/src/components/app/import/__tests__/import-i18n.test.ts index 1d6b86ef6..d8c065a60 100644 --- a/src/components/app/import/__tests__/import-i18n.test.ts +++ b/src/components/app/import/__tests__/import-i18n.test.ts @@ -1,6 +1,7 @@ import i18next, { i18n } from 'i18next'; import en from '@/@types/translations/en.json'; +import zh from '@/@types/translations/zh-CN.json'; /** * `ImportDialog` reports counts through i18next plurals. Those only resolve when the option is @@ -14,7 +15,7 @@ async function createI18n(lng: string): Promise { await instance.init({ lng, fallbackLng: 'en', - resources: { en: { translation: en } }, + resources: { en: { translation: en }, 'zh-CN': { translation: zh } }, }); return instance; @@ -61,6 +62,21 @@ describe('import panel count strings', () => { expect(t('importPanel.importingProgress', { current: 2, total: 7 })).toBe('Importing file 2 of 7'); }); + it('identifies Confluence HTML exports and reports background import status', async () => { + expect(t('importPanel.confluenceZip')).toBe('Confluence HTML Export (.zip)'); + expect(t('web.dropConfluenceFile')).toBe( + 'Drop your Confluence HTML export (.zip) here to upload, or click to browse' + ); + expect(t('importPanel.confluenceImportStarted')).toBe( + 'Confluence import started. Pages will appear in this view when ready.' + ); + + const chinese = await createI18n('zh-CN'); + + expect(chinese.t('importPanel.confluenceZip')).toBe('Confluence HTML 导出文件 (.zip)'); + expect(chinese.t('web.importFromConfluence')).toBe('从 Confluence 导入'); + }); + it('picks a locale-specific plural form where one exists', async () => { // English lumps every count above one into `_other`; Czech splits 2-4 out into `_few`, which // a single hardcoded "{{n}} files" string can never get right. Only `en` is loaded here, so diff --git a/src/components/app/import/__tests__/import-notion-abort.test.ts b/src/components/app/import/__tests__/import-notion-abort.test.ts deleted file mode 100644 index 79e95c62e..000000000 --- a/src/components/app/import/__tests__/import-notion-abort.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -jest.mock('@/utils/md5', () => ({ - calculateMd5: jest.fn().mockResolvedValue('md5-base64'), -})); - -jest.mock('@/application/services/js-services/http/collab-api', () => ({ - getCollab: jest.fn(), - updateCollab: jest.fn(), -})); - -jest.mock('@/application/services/js-services/http/import-api', () => ({ - createDatabaseCsvImportTask: jest.fn(), - uploadDatabaseCsvImportFile: jest.fn(), - getDatabaseCsvImportStatus: jest.fn(), - cancelDatabaseCsvImportTask: jest.fn(), - cancelImportTask: jest.fn(), - createNotionImportTask: jest.fn(), - uploadImportFile: jest.fn(), - uploadImportFileMultipart: jest.fn(), -})); - -import { - cancelImportTask, - createNotionImportTask, - uploadImportFile, - uploadImportFileMultipart, -} from '@/application/services/js-services/http/import-api'; -import { ImportAbortError, importNotionZipToView } from '@/components/app/import/import-service'; - -const createTask = createNotionImportTask as jest.Mock; -const uploadSingle = uploadImportFile as jest.Mock; -const uploadMultipart = uploadImportFileMultipart as jest.Mock; -const cancelTask = cancelImportTask as jest.Mock; - -const WORKSPACE_ID = 'workspace-1'; -const PARENT_VIEW_ID = 'parent-view-1'; - -function zipFile(): File { - return new File(['zip-bytes'], 'export.zip', { type: 'application/zip' }); -} - -function importZip(signal?: AbortSignal) { - return importNotionZipToView({ - workspaceId: WORKSPACE_ID, - parentViewId: PARENT_VIEW_ID, - file: zipFile(), - signal, - }); -} - -describe('importNotionZipToView cancellation', () => { - beforeEach(() => { - jest.clearAllMocks(); - cancelTask.mockResolvedValue(undefined); - uploadSingle.mockResolvedValue(undefined); - uploadMultipart.mockResolvedValue(undefined); - createTask.mockResolvedValue({ taskId: 'task-1', presignedUrl: 'https://s3.test/zip' }); - }); - - it('hands the signal to a single-part upload so a cancel reaches the request', async () => { - const controller = new AbortController(); - - await importZip(controller.signal); - - expect(uploadSingle).toHaveBeenCalledWith( - 'https://s3.test/zip', - expect.anything(), - expect.any(Function), - controller.signal - ); - }); - - it('hands the signal to a multipart upload too — the longest path in the dialog', async () => { - const multipart = { s3_key: 'key', upload_id: 'upload-1', part_presigned_urls: [] }; - - createTask.mockResolvedValue({ taskId: 'task-1', presignedUrl: 'https://s3.test/zip', multipart }); - - const controller = new AbortController(); - - await importZip(controller.signal); - - expect(uploadMultipart).toHaveBeenCalledWith( - expect.anything(), - multipart, - expect.any(Function), - controller.signal - ); - }); - - it('reports a cancelled upload as an abort, not as a failed import', async () => { - const controller = new AbortController(); - - uploadSingle.mockImplementation(async () => { - controller.abort(); - // axios rejects an aborted request with a CanceledError, never an ImportAbortError. - throw Object.assign(new Error('canceled'), { name: 'CanceledError' }); - }); - - // Without the normalisation the dialog would toast "canceled" as an import failure. - await expect(importZip(controller.signal)).rejects.toBeInstanceOf(ImportAbortError); - expect(cancelTask).toHaveBeenCalledWith('task-1'); - }); - - it('still surfaces a genuine upload failure', async () => { - uploadSingle.mockRejectedValue({ code: -1, message: 'Upload file failed. Bad Gateway' }); - - await expect(importZip()).rejects.toEqual({ code: -1, message: 'Upload file failed. Bad Gateway' }); - expect(cancelTask).toHaveBeenCalledWith('task-1'); - }); -}); diff --git a/src/components/app/import/__tests__/import-zip.test.ts b/src/components/app/import/__tests__/import-zip.test.ts new file mode 100644 index 000000000..5471e2d0d --- /dev/null +++ b/src/components/app/import/__tests__/import-zip.test.ts @@ -0,0 +1,186 @@ +jest.mock('@/utils/md5', () => ({ + calculateMd5: jest.fn().mockResolvedValue('md5-base64'), +})); + +jest.mock('@/application/services/js-services/http/collab-api', () => ({ + getCollab: jest.fn(), + updateCollab: jest.fn(), +})); + +jest.mock('@/application/services/js-services/http/import-api', () => ({ + createDatabaseCsvImportTask: jest.fn(), + uploadDatabaseCsvImportFile: jest.fn(), + getDatabaseCsvImportStatus: jest.fn(), + cancelDatabaseCsvImportTask: jest.fn(), + cancelImportTask: jest.fn(), + createNotionImportTask: jest.fn(), + createConfluenceImportTask: jest.fn(), + uploadImportFile: jest.fn(), + uploadImportFileMultipart: jest.fn(), +})); + +import { + cancelImportTask, + createConfluenceImportTask, + createNotionImportTask, + uploadImportFile, + uploadImportFileMultipart, +} from '@/application/services/js-services/http/import-api'; +import { + ImportAbortError, + importConfluenceZipToView, + importNotionZipToView, +} from '@/components/app/import/import-service'; +import { calculateMd5 } from '@/utils/md5'; + +const uploadSingle = uploadImportFile as jest.Mock; +const uploadMultipart = uploadImportFileMultipart as jest.Mock; +const cancelTask = cancelImportTask as jest.Mock; + +const WORKSPACE_ID = 'workspace-1'; +const PARENT_VIEW_ID = 'parent-view-1'; + +function zipFile(): File { + return new File(['zip-bytes'], 'export.zip', { type: 'application/zip' }); +} + +describe.each([ + ['Notion', importNotionZipToView, createNotionImportTask, createConfluenceImportTask], + ['Confluence', importConfluenceZipToView, createConfluenceImportTask, createNotionImportTask], +] as const)('%s ZIP import', (_source, importToView, createTaskFunction, otherCreateTask) => { + const createTask = jest.mocked(createTaskFunction); + + function importZip(signal?: AbortSignal) { + return importToView({ + workspaceId: WORKSPACE_ID, + parentViewId: PARENT_VIEW_ID, + file: zipFile(), + signal, + }); + } + + beforeEach(() => { + jest.resetAllMocks(); + jest.mocked(calculateMd5).mockResolvedValue('md5-base64'); + cancelTask.mockResolvedValue(undefined); + uploadSingle.mockResolvedValue(undefined); + uploadMultipart.mockResolvedValue(undefined); + createTask.mockResolvedValue({ taskId: 'task-1', presignedUrl: 'https://s3.test/zip', multipart: null }); + }); + + it('creates the selected source task and uploads the exact file to its destination', async () => { + const file = zipFile(); + const onProgress = jest.fn(); + + await expect( + importToView({ + workspaceId: WORKSPACE_ID, + parentViewId: PARENT_VIEW_ID, + file, + onProgress, + }) + ).resolves.toEqual({ taskId: 'task-1' }); + + expect(calculateMd5).toHaveBeenCalledWith(file); + expect(createTask).toHaveBeenCalledWith(WORKSPACE_ID, PARENT_VIEW_ID, { + content_length: file.size, + md5_base64: 'md5-base64', + }); + expect(otherCreateTask).not.toHaveBeenCalled(); + expect(uploadSingle).toHaveBeenCalledWith('https://s3.test/zip', file, onProgress, undefined); + expect(uploadMultipart).not.toHaveBeenCalled(); + expect(cancelTask).not.toHaveBeenCalled(); + }); + + it('does not create a task when cancelled before upload preparation', async () => { + const controller = new AbortController(); + + controller.abort(); + await expect(importZip(controller.signal)).rejects.toBeInstanceOf(ImportAbortError); + expect(calculateMd5).not.toHaveBeenCalled(); + expect(createTask).not.toHaveBeenCalled(); + expect(uploadSingle).not.toHaveBeenCalled(); + }); + + it('cancels a task created while the caller was aborting', async () => { + const controller = new AbortController(); + + createTask.mockImplementation(async () => { + controller.abort(); + return { taskId: 'task-1', presignedUrl: 'https://s3.test/zip', multipart: null }; + }); + + await expect(importZip(controller.signal)).rejects.toBeInstanceOf(ImportAbortError); + expect(uploadSingle).not.toHaveBeenCalled(); + expect(uploadMultipart).not.toHaveBeenCalled(); + expect(cancelTask).toHaveBeenCalledWith('task-1'); + }); + + it('hands the signal to a single-part upload so a cancel reaches the request', async () => { + const controller = new AbortController(); + + await importZip(controller.signal); + + expect(uploadSingle).toHaveBeenCalledWith( + 'https://s3.test/zip', + expect.anything(), + expect.any(Function), + controller.signal + ); + }); + + it('hands the signal to a multipart upload too — the longest path in the dialog', async () => { + const multipart = { s3_key: 'key', upload_id: 'upload-1', part_presigned_urls: [] }; + + createTask.mockResolvedValue({ taskId: 'task-1', presignedUrl: 'https://s3.test/zip', multipart }); + + const controller = new AbortController(); + + await importZip(controller.signal); + + expect(uploadMultipart).toHaveBeenCalledWith(expect.anything(), multipart, expect.any(Function), controller.signal); + expect(uploadSingle).not.toHaveBeenCalled(); + }); + + it('reports a cancelled upload as an abort, not as a failed import', async () => { + const controller = new AbortController(); + + uploadSingle.mockImplementation(async () => { + controller.abort(); + // axios rejects an aborted request with a CanceledError, never an ImportAbortError. + throw Object.assign(new Error('canceled'), { name: 'CanceledError' }); + }); + + // Without the normalisation the dialog would toast "canceled" as an import failure. + await expect(importZip(controller.signal)).rejects.toBeInstanceOf(ImportAbortError); + expect(cancelTask).toHaveBeenCalledWith('task-1'); + }); + + it('still surfaces a genuine upload failure', async () => { + uploadSingle.mockRejectedValue({ code: -1, message: 'Upload file failed. Bad Gateway' }); + + await expect(importZip()).rejects.toEqual({ code: -1, message: 'Upload file failed. Bad Gateway' }); + expect(cancelTask).toHaveBeenCalledWith('task-1'); + }); + + it('cancels the task on a multipart upload failure', async () => { + createTask.mockResolvedValue({ + taskId: 'task-1', + presignedUrl: '', + multipart: { s3_key: 'key', upload_id: 'upload-1', part_presigned_urls: [] }, + }); + uploadMultipart.mockRejectedValue(new Error('Part upload failed')); + + await expect(importZip()).rejects.toThrow('Part upload failed'); + expect(cancelTask).toHaveBeenCalledWith('task-1'); + }); + + it('does not report success when cancellation races with upload completion', async () => { + const controller = new AbortController(); + + uploadSingle.mockImplementation(async () => controller.abort()); + + await expect(importZip(controller.signal)).rejects.toBeInstanceOf(ImportAbortError); + expect(cancelTask).toHaveBeenCalledWith('task-1'); + }); +}); diff --git a/src/components/app/import/import-service.ts b/src/components/app/import/import-service.ts index cb440b3a5..6f53887fc 100644 --- a/src/components/app/import/import-service.ts +++ b/src/components/app/import/import-service.ts @@ -4,6 +4,7 @@ import { getCollab, updateCollab } from '@/application/services/js-services/http import { cancelDatabaseCsvImportTask, cancelImportTask, + createConfluenceImportTask, createDatabaseCsvImportTask, createNotionImportTask, getDatabaseCsvImportStatus, @@ -15,7 +16,6 @@ import { slateContentInsertToYData } from '@/application/slate-yjs/utils/convert import { deleteBlock, getBlock, getChildrenArray, getPageId } from '@/application/slate-yjs/utils/yjs'; import { DatabaseCsvImportLayout, DatabaseCsvImportMode, Types, YjsEditorKey, YSharedRoot } from '@/application/types'; import { parsedBlockToSlateElement } from '@/components/app/import/markdown-to-blocks'; -import { parseMarkdown } from '@/components/editor/parsers/markdown-parser'; // Import failures arrive either as `Error`s or as `{ code, message }` rejections from the // HTTP layer; `getErrorMessage` normalises both. import { getErrorMessage, isAPIErrorCode } from '@/utils/errors'; @@ -43,9 +43,13 @@ export function stripFileExtension(name: string): string { * The page must already exist (created via PageService.add by the caller). */ export async function populateDocumentWithMarkdown(workspaceId: string, viewId: string, file: File): Promise { - // Fetch the file text and the (empty) page collab in parallel — they're independent - // and the markdown parse is much cheaper than either round trip. - const [text, collab] = await Promise.all([file.text(), getCollab(workspaceId, viewId, Types.Document)]); + // ZIP and CSV imports do not need the Markdown parser. Load it only for Markdown, + // alongside the independent file and empty-page reads. + const [text, collab, { parseMarkdown }] = await Promise.all([ + file.text(), + getCollab(workspaceId, viewId, Types.Document), + import('@/components/editor/parsers/markdown-parser'), + ]); const blocks = parseMarkdown(text); if (blocks.length === 0) return; @@ -89,7 +93,7 @@ export interface ImportCsvResult { viewId: string; } -export interface ImportNotionInput { +export interface ImportZipInput { workspaceId: string; parentViewId: string; file: File; @@ -97,10 +101,13 @@ export interface ImportNotionInput { signal?: AbortSignal; } -export interface ImportNotionResult { +export interface ImportZipResult { taskId: string; } +export type ImportNotionInput = ImportZipInput; +export type ImportNotionResult = ImportZipResult; + export class ImportAbortError extends Error { constructor() { super('Import aborted'); @@ -254,13 +261,25 @@ export async function importCsvFilesAsDatabases(input: ImportCsvBatchInput): Pro * The server processes the imported workspace asynchronously after upload. */ export async function importNotionZipToView(input: ImportNotionInput): Promise { + return importZipToView(input, createNotionImportTask); +} + +/** Upload a Confluence HTML export ZIP for asynchronous import under the selected view. */ +export async function importConfluenceZipToView(input: ImportZipInput): Promise { + return importZipToView(input, createConfluenceImportTask); +} + +async function importZipToView( + input: ImportZipInput, + createTask: typeof createNotionImportTask +): Promise { const { workspaceId, parentViewId, file, onProgress, signal } = input; throwIfAborted(signal); const md5_base64 = await calculateMd5(file); throwIfAborted(signal); - const task = await createNotionImportTask(workspaceId, parentViewId, { + const task = await createTask(workspaceId, parentViewId, { content_length: file.size, md5_base64, }); @@ -273,6 +292,7 @@ export async function importNotionZipToView(input: ImportNotionInput): Promise setHovered(true)} onMouseLeave={() => setHovered(false)} @@ -105,9 +112,20 @@ function SpaceItem({ />
-
- {name} -
+ {canOpenHomePage ? ( + + ) : ( +
+ {name} +
+ )} {isPrivate && (
@@ -125,6 +143,7 @@ function SpaceItem({ isExpanded, isPrivate, onClickSpace, + onClickView, renderExtra, shouldSuppressClick, toggleExpand, diff --git a/src/components/app/outline/__tests__/SpaceItem.homePage.test.tsx b/src/components/app/outline/__tests__/SpaceItem.homePage.test.tsx new file mode 100644 index 000000000..554683707 --- /dev/null +++ b/src/components/app/outline/__tests__/SpaceItem.homePage.test.tsx @@ -0,0 +1,155 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { useState } from 'react'; + +import { View, ViewLayout } from '@/application/types'; +import SpaceItem from '@/components/app/outline/SpaceItem'; + +import type { ReactNode } from 'react'; + +const mockOnClickView = jest.fn(); +const mockToggleExpand = jest.fn(); +const mockShouldSuppressClick = jest.fn(); + +jest.mock('@/components/app/app.hooks', () => ({ + useCurrentWorkspaceIdOptional: () => 'workspace-id', +})); + +jest.mock('@/components/_shared/reorder/useReorderableItem', () => ({ + useReorderableItem: () => ({ + dragState: { type: 'idle' }, + shouldSuppressClick: mockShouldSuppressClick, + }), +})); + +jest.mock('@/components/app/outline/reorder/useReorderableSidebarList', () => ({ + useReorderableSidebarList: ({ items }: { items: View[] }) => ({ orderedItems: items }), +})); + +jest.mock('@/components/app/outline/AnimatedCollapse', () => ({ + __esModule: true, + default: ({ expanded, children }: { expanded: boolean; children: ReactNode }) => + expanded ?
{children}
: null, +})); + +jest.mock('@/components/app/outline/ViewItem', () => ({ + __esModule: true, + default: ({ view }: { view: View }) =>
{view.name}
, +})); + +jest.mock('@/components/_shared/view-icon/SpaceIcon', () => ({ + __esModule: true, + default: () => null, +})); + +jest.mock('@/components/database/components/drag-and-drop/DropRowLine', () => ({ + __esModule: true, + default: () => null, +})); + +const child: View = { + view_id: 'child-page', + name: 'Child page', + layout: ViewLayout.Document, + icon: null, + extra: null, + children: [], + is_private: false, + is_published: false, +}; + +function SpaceHarness({ hasHomePage, onClickSpace }: { hasHomePage?: boolean; onClickSpace?: (id: string) => void }) { + const [expandIds, setExpandIds] = useState([]); + const space: View = { + ...child, + view_id: 'space-id', + name: 'Test Space', + extra: { is_space: true, has_space_home_page: hasHomePage }, + children: [child], + }; + + return ( + { + mockToggleExpand(id, expanded); + setExpandIds(expanded ? [id] : []); + }} + onClickView={mockOnClickView} + onClickSpace={onClickSpace} + /> + ); +} + +describe('SpaceItem home page navigation', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockShouldSuppressClick.mockReturnValue(false); + }); + + it('opens the retained home page while expanding and collapsing its child pages', () => { + render(); + + fireEvent.click(screen.getByTestId('space-space-id')); + + expect(mockOnClickView).toHaveBeenCalledTimes(1); + expect(mockOnClickView).toHaveBeenCalledWith('space-id'); + expect(mockToggleExpand).toHaveBeenLastCalledWith('space-id', true); + expect(screen.getByText('Child page')).toBeTruthy(); + + const title = screen.getByRole('button', { name: 'Test Space' }); + + // Native button activation also handles Enter and Space without custom key listeners. + expect(title.getAttribute('type')).toBe('button'); + expect(title.tabIndex).toBe(0); + expect(title.getAttribute('aria-expanded')).toBe('true'); + fireEvent.click(title); + + expect(mockOnClickView).toHaveBeenCalledTimes(2); + expect(mockToggleExpand).toHaveBeenLastCalledWith('space-id', false); + expect(title.getAttribute('aria-expanded')).toBe('false'); + expect(screen.queryByText('Child page')).toBeNull(); + }); + + it('keeps ordinary spaces as expansion-only rows', () => { + render(); + + fireEvent.click(screen.getByTestId('space-space-id')); + + expect(screen.getByText('Child page')).toBeTruthy(); + fireEvent.click(screen.getByTestId('space-name')); + + expect(screen.queryByText('Child page')).toBeNull(); + expect(mockToggleExpand.mock.calls).toEqual([ + ['space-id', true], + ['space-id', false], + ]); + expect(mockOnClickView).not.toHaveBeenCalled(); + expect(screen.queryByRole('button', { name: 'Test Space' })).toBeNull(); + }); + + it('preserves destination selection without navigating or toggling twice', () => { + const onClickSpace = jest.fn(); + + render(); + fireEvent.click(screen.getByTestId('space-space-id')); + + expect(onClickSpace).toHaveBeenCalledTimes(1); + expect(onClickSpace).toHaveBeenCalledWith('space-id'); + expect(mockToggleExpand).toHaveBeenCalledTimes(1); + expect(mockToggleExpand).toHaveBeenCalledWith('space-id', true); + expect(mockOnClickView).not.toHaveBeenCalled(); + }); + + it('does not open or expand the home page from a suppressed post-drag click', () => { + mockShouldSuppressClick.mockReturnValue(true); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Test Space' })); + + expect(mockOnClickView).not.toHaveBeenCalled(); + expect(mockToggleExpand).not.toHaveBeenCalled(); + expect(screen.queryByText('Child page')).toBeNull(); + }); +}); diff --git a/src/components/app/settings/ManageDataPanel.tsx b/src/components/app/settings/ManageDataPanel.tsx index c5d86318b..9057f5782 100644 --- a/src/components/app/settings/ManageDataPanel.tsx +++ b/src/components/app/settings/ManageDataPanel.tsx @@ -1,8 +1,8 @@ -import { useCallback, useMemo, useRef, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; -import { ExportService, FileService } from '@/application/services/domains'; +import { ExportService } from '@/application/services/domains'; import { isSameUserUid } from '@/application/user-uid'; import { ReactComponent as HelpIcon } from '@/assets/icons/help.svg'; import { useCurrentWorkspaceId, useUserWorkspaceInfo } from '@/components/app/app.hooks'; @@ -12,19 +12,15 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { getErrorMessage } from '@/utils/errors'; import { openUrl } from '@/utils/url'; -const ZIP_ACCEPT = '.zip,application/zip,application/x-zip,application/x-zip-compressed'; - const IMPORT_GUIDE_URL = 'https://appflowy.com/guide/import-from-AppFlowy'; const BACKUP_GUIDE_URL = 'https://appflowy.com/guide/back-up-your-data'; -export function ManageDataPanel() { +export function ManageDataPanel({ onImport }: { onImport: () => void }) { const { t } = useTranslation(); const currentWorkspaceId = useCurrentWorkspaceId(); const userWorkspaceInfo = useUserWorkspaceInfo(); const currentUser = useCurrentUser(); - const fileInputRef = useRef(null); - const [importing, setImporting] = useState(false); const [backingUp, setBackingUp] = useState(false); const isOwner = useMemo(() => { @@ -33,41 +29,6 @@ export function ManageDataPanel() { return isSameUserUid(workspace?.owner?.uid, currentUser?.uid); }, [userWorkspaceInfo?.workspaces, currentWorkspaceId, currentUser?.uid]); - const handleImport = useCallback( - async (file: File) => { - setImporting(true); - try { - await FileService.importFile(file, { - taskType: FileService.CreateImportTaskType.Workspace, - onProgress: () => { - /* progress is surfaced via the in-progress state */ - }, - }); - toast.success(t('settings.manageData.importWorkspace.success')); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (e: any) { - toast.error(getErrorMessage(e) || t('settings.manageData.importWorkspace.failed')); - } finally { - setImporting(false); - } - }, - [t] - ); - - const onFilePicked = useCallback( - (event: React.ChangeEvent) => { - const file = event.target.files?.[0]; - - event.target.value = ''; - if (file) void handleImport(file); - }, - [handleImport] - ); - - const handleImportClick = useCallback(() => { - fileInputRef.current?.click(); - }, []); - const handleBackup = useCallback(async () => { if (!currentWorkspaceId) return; setBackingUp(true); @@ -105,24 +66,9 @@ export function ManageDataPanel() {

{t('settings.manageData.importWorkspace.tooltip')}

- - {/* Backup your workspace — owner only */} diff --git a/src/components/app/settings/Settings.tsx b/src/components/app/settings/Settings.tsx index 0fe6a3db8..2661c0e21 100644 --- a/src/components/app/settings/Settings.tsx +++ b/src/components/app/settings/Settings.tsx @@ -1,5 +1,5 @@ import { Dialog } from '@mui/material'; -import React, { useEffect } from 'react'; +import React, { useCallback, useEffect } from 'react'; import { useSearchParams } from 'react-router-dom'; import { SettingMenuItem } from '@/application/types'; @@ -32,6 +32,18 @@ export function SettingsDialog({ open, onClose, onRequestOpen }: SettingsDialogP } }, [search, setSearch, onRequestOpen]); + const handleImport = useCallback(() => { + // Reuse the workspace importer after closing Settings so only one dialog owns focus. + onClose(); + setSearch((prev) => { + const next = new URLSearchParams(prev); + + next.set('action', 'import'); + next.set('source', 'appflowy'); + return next; + }); + }, [onClose, setSearch]); + return ( } {selectedItem === SettingMenuItem.PROFILE && } {selectedItem === SettingMenuItem.MEMBERS && } - {selectedItem === SettingMenuItem.MANAGE_DATA && } + {selectedItem === SettingMenuItem.MANAGE_DATA && }
); diff --git a/src/components/app/settings/__tests__/Settings.workspaceImport.test.tsx b/src/components/app/settings/__tests__/Settings.workspaceImport.test.tsx new file mode 100644 index 000000000..4d2c0e48d --- /dev/null +++ b/src/components/app/settings/__tests__/Settings.workspaceImport.test.tsx @@ -0,0 +1,152 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { useState } from 'react'; +import { MemoryRouter, useLocation } from 'react-router-dom'; + +import { FileService } from '@/application/services/domains'; +import Import from '@/components/_shared/more-actions/importer/Import'; +import { SettingsDialog } from '@/components/app/settings/Settings'; + +const mockImportFile = jest.fn(); +const mockExportWorkspace = jest.fn(); +const mockSettingsClose = jest.fn(); +const mockImportSuccess = jest.fn(); +const mockTranslate = (key: string) => key; +let mockCurrentUserUid = '42'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: mockTranslate }), +})); + +jest.mock('sonner', () => ({ toast: { error: jest.fn(), success: jest.fn() } })); +jest.mock('@/components/_shared/notify', () => ({ notify: { error: jest.fn() } })); + +jest.mock('@/application/services/domains', () => ({ + FileService: { + CreateImportTaskType: { Workspace: 'Workspace', Notion: 'Notion', Confluence: 'Confluence' }, + importFile: (...args: unknown[]) => mockImportFile(...args), + }, + ExportService: { + exportWorkspace: (...args: unknown[]) => mockExportWorkspace(...args), + }, +})); + +jest.mock('@/components/app/app.hooks', () => ({ + useCurrentWorkspaceId: () => 'current-workspace', + useUserWorkspaceInfo: () => ({ + workspaces: [{ id: 'current-workspace', owner: { uid: 42 } }], + }), +})); + +jest.mock('@/components/main/app.hooks', () => ({ + useCurrentUser: () => ({ uid: mockCurrentUserUid }), + useIsAuthenticatedOptional: () => true, +})); + +jest.mock('@/components/app/settings/AccountAppPanel', () => ({ AccountAppPanel: () => null })); +jest.mock('@/components/app/settings/MembersPanel', () => ({ MembersPanel: () => null })); +jest.mock('@/components/app/settings/ProfilePanel', () => ({ ProfilePanel: () => null })); +jest.mock('@/components/login', () => ({ LoginModal: () => null })); + +function SettingsWithWorkspaceImport() { + const [settingsOpen, setSettingsOpen] = useState(true); + const location = useLocation(); + + return ( + <> + {location.pathname + location.search} + { + mockSettingsClose(); + setSettingsOpen(false); + }} + /> + + + ); +} + +function renderManageData() { + render( + + + + ); + fireEvent.click(screen.getByTestId('settings-menu-manage_data')); +} + +describe('Settings workspace import', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockCurrentUserUid = '42'; + mockImportFile.mockResolvedValue(undefined); + mockExportWorkspace.mockResolvedValue(undefined); + }); + + it('opens the shared workspace importer and submits Confluence only after choosing its ZIP', async () => { + renderManageData(); + + fireEvent.click(screen.getByTestId('manage-data-import')); + + const confluenceTab = await screen.findByRole('tab', { name: 'web.importFromConfluence' }); + + await waitFor(() => expect(screen.queryByTestId('settings-dialog')).toBeNull()); + expect(mockSettingsClose).toHaveBeenCalledTimes(1); + expect(screen.getByRole('tab', { name: 'web.importFromAppFlowy' }).getAttribute('aria-selected')).toBe('true'); + expect(screen.getByRole('tab', { name: 'web.importFromNotion' })).toBeTruthy(); + expect(screen.getByText('web.importCreatesWorkspace')).toBeTruthy(); + + const route = new URL(screen.getByTestId('current-route').textContent!, 'http://localhost'); + + expect(route.pathname).toBe('/app/current-workspace/current-page'); + expect(route.searchParams.get('action')).toBe('import'); + expect(route.searchParams.get('source')).toBe('appflowy'); + expect(route.searchParams.getAll('keep')).toEqual(['first', 'second']); + expect(mockImportFile).not.toHaveBeenCalled(); + + fireEvent.click(confluenceTab); + expect(confluenceTab.getAttribute('aria-selected')).toBe('true'); + expect(screen.getByText('web.dropConfluenceFile')).toBeTruthy(); + expect(mockImportFile).not.toHaveBeenCalled(); + + const input = screen.getByTestId('file-dropzone').querySelector('input')!; + const file = new File(['confluence-html-export'], 'space.html.zip', { type: 'application/zip' }); + + expect(input.multiple).toBe(false); + expect(input.accept).toContain('.zip'); + fireEvent.change(input, { target: { files: [file] } }); + + await waitFor(() => expect(mockImportSuccess).toHaveBeenCalledTimes(1)); + expect(mockImportFile).toHaveBeenCalledTimes(1); + expect(mockImportFile).toHaveBeenCalledWith(file, { + taskType: FileService.CreateImportTaskType.Confluence, + onProgress: expect.any(Function), + }); + expect(mockExportWorkspace).not.toHaveBeenCalled(); + }); + + it('keeps backup available to the workspace owner', async () => { + renderManageData(); + + fireEvent.click(screen.getByTestId('manage-data-backup')); + + await waitFor(() => expect(mockExportWorkspace).toHaveBeenCalledWith('current-workspace')); + expect(mockImportFile).not.toHaveBeenCalled(); + expect(mockSettingsClose).not.toHaveBeenCalled(); + }); + + it('allows a member to open workspace import while hiding owner-only backup', async () => { + mockCurrentUserUid = '43'; + renderManageData(); + + expect(screen.queryByTestId('manage-data-backup')).toBeNull(); + fireEvent.click(screen.getByTestId('manage-data-import')); + + expect(await screen.findByRole('tab', { name: 'web.importFromConfluence' })).toBeTruthy(); + expect(mockExportWorkspace).not.toHaveBeenCalled(); + expect(mockImportFile).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/app/workspaces/Workspaces.tsx b/src/components/app/workspaces/Workspaces.tsx index 2a6878a2c..a70eddc45 100644 --- a/src/components/app/workspaces/Workspaces.tsx +++ b/src/components/app/workspaces/Workspaces.tsx @@ -94,7 +94,7 @@ export function Workspaces() { const [, setSearchParams] = useSearchParams(); const handleOpenImport = useCallback( - (source: 'notion' | 'appflowy') => { + (source: 'notion' | 'appflowy' | 'confluence') => { setSearchParams((prev) => { prev.set('action', 'import'); prev.set('source', source); @@ -212,16 +212,10 @@ export function Workspaces() {
{t('web.importWorkspace')}
- handleOpenImport('appflowy')} - > + handleOpenImport('appflowy')}>
{t('web.importFromAppFlowy')}
- handleOpenImport('notion')} - > + handleOpenImport('notion')}>
{t('web.importFromNotion')}
@@ -238,6 +232,9 @@ export function Workspaces() { {t('workspace.learnMore')}
+ handleOpenImport('confluence')}> +
{t('web.importFromConfluence')}
+
From 0273259af1cc769e50b3f87c8873d118c4a9b1a5 Mon Sep 17 00:00:00 2001 From: "Nathan.fooo" <86001920+appflowy@users.noreply.github.com> Date: Mon, 14 Sep 2026 11:06:58 +0800 Subject: [PATCH 18/21] fix: preserve calendar draft isolation on dismissal and save failure (#550) * fix: preserve calendar draft isolation on dismissal and save failure * fix: handle row registration delay during document duplication --- src/application/constants.ts | 1 + .../__tests__/duplicate-row-document.test.ts | 76 +++++++++ .../services/js-services/http/collab-api.ts | 37 ++++- .../draft/CalendarEventDraft.test.ts | 63 ++++++++ .../fullcalendar/draft/CalendarEventDraft.ts | 11 +- .../fullcalendar/event/EventWithPopover.tsx | 6 +- .../__tests__/EventWithPopover.draft.test.tsx | 146 ++++++++++++++++++ 7 files changed, 331 insertions(+), 9 deletions(-) create mode 100644 src/application/services/js-services/http/__tests__/duplicate-row-document.test.ts create mode 100644 src/components/database/fullcalendar/event/__tests__/EventWithPopover.draft.test.tsx diff --git a/src/application/constants.ts b/src/application/constants.ts index ad7687543..6dfbc83d2 100644 --- a/src/application/constants.ts +++ b/src/application/constants.ts @@ -34,6 +34,7 @@ export const ERROR_CODE = { RECORD_ALREADY_EXISTS: -3, RECORD_DELETED: -4, RETRY_LATER: -5, + INVALID_REQUEST: 1008, // Auth & permissions NOT_LOGGED_IN: 1011, diff --git a/src/application/services/js-services/http/__tests__/duplicate-row-document.test.ts b/src/application/services/js-services/http/__tests__/duplicate-row-document.test.ts new file mode 100644 index 000000000..ed5039874 --- /dev/null +++ b/src/application/services/js-services/http/__tests__/duplicate-row-document.test.ts @@ -0,0 +1,76 @@ +import { getAxios } from '../core'; +import { duplicateRowDocument } from '../collab-api'; + +jest.mock('../core', () => ({ + executeAPIVoidRequest: (request: () => Promise) => request(), + getAxios: jest.fn(), +})); + +jest.mock('@/application/services/js-services/device-id', () => ({ + getOrCreateDeviceId: jest.fn(() => 'test-device-id'), +})); + +describe('duplicateRowDocument', () => { + const post = jest.fn(); + const missingTarget = { + code: 1008, + message: 'Invalid request:new_row_id target-row does not belong to database database-id', + }; + + beforeEach(() => { + jest.useFakeTimers(); + post.mockReset(); + (getAxios as jest.Mock).mockReturnValue({ post }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('copies the document after the server registers the newly synced target row', async () => { + post.mockRejectedValueOnce(missingTarget).mockResolvedValueOnce(undefined); + + const outcome = duplicateRowDocument('workspace-id', 'database-id', 'source-row', 'target-row', 'document-state') + .catch((error: unknown) => error); + + await jest.runAllTimersAsync(); + + await expect(outcome).resolves.toBeUndefined(); + expect(post).toHaveBeenCalledTimes(2); + for (const args of post.mock.calls) { + expect(args).toEqual([ + '/api/workspace/workspace-id/database/database-id/row/source-row/duplicate-document', + { new_row_id: 'target-row', client_doc_state_b64: 'document-state' }, + ]); + } + }); + + it('stops retrying when the target row never registers', async () => { + post.mockRejectedValue(missingTarget); + const outcome = duplicateRowDocument('workspace-id', 'database-id', 'source-row', 'target-row') + .catch((error: unknown) => error); + + await jest.runAllTimersAsync(); + + await expect(outcome).resolves.toBe(missingTarget); + expect(post).toHaveBeenCalledTimes(5); + expect(jest.getTimerCount()).toBe(0); + }); + + it.each([ + { code: 1012, message: 'Not enough permissions' }, + { code: -1, message: 'Network Error' }, + { code: 1017, message: 'Internal server error' }, + { code: 1008, message: 'Invalid request:destination row target-row already has a row document collab' }, + { code: 1008, message: 'Invalid request:source_row_id source-row does not belong to database database-id' }, + { code: 1008, message: 'Invalid request:new_row_id other-row does not belong to database database-id' }, + ])('does not retry a rejected or potentially accepted copy: $message', async (error) => { + post.mockRejectedValue(error); + + await expect( + duplicateRowDocument('workspace-id', 'database-id', 'source-row', 'target-row') + ).rejects.toBe(error); + expect(post).toHaveBeenCalledTimes(1); + expect(jest.getTimerCount()).toBe(0); + }); +}); diff --git a/src/application/services/js-services/http/collab-api.ts b/src/application/services/js-services/http/collab-api.ts index e6f3610fc..9436034f0 100644 --- a/src/application/services/js-services/http/collab-api.ts +++ b/src/application/services/js-services/http/collab-api.ts @@ -1,5 +1,6 @@ import { toBase64 } from 'lib0/buffer'; +import { ERROR_CODE } from '@/application/constants'; import { getOrCreateDeviceId } from '@/application/services/js-services/device-id'; import { RowDocumentSourcePayload, RowId, Types, User, View } from '@/application/types'; import { database_blob } from '@/proto/database_blob'; @@ -394,6 +395,8 @@ export async function getPageCollab(workspaceId: string, viewId: string) { }; } +const ROW_REGISTRATION_RETRY_DELAYS_MS = [250, 500, 1000, 2000]; + export async function duplicateRowDocument( workspaceId: string, databaseId: string, @@ -403,12 +406,34 @@ export async function duplicateRowDocument( ): Promise { const url = `/api/workspace/${workspaceId}/database/${databaseId}/row/${sourceRowId}/duplicate-document`; - await executeAPIVoidRequest(() => - getAxios()?.post(url, { - new_row_id: newRowId, - client_doc_state_b64: clientDocStateB64, - }) - ); + for (let attempt = 0; ; attempt++) { + try { + await executeAPIVoidRequest(() => + getAxios()?.post(url, { + new_row_id: newRowId, + client_doc_state_b64: clientDocStateB64, + }) + ); + return; + } catch (error) { + const apiError = error as APIError | null; + const delay = ROW_REGISTRATION_RETRY_DELAYS_MS[attempt]; + + // The sync batch can return before realtime sync registers the new row's + // database membership in PostgreSQL. + // This specific rejection happens before the server enqueues a copy, so + // it is safe to retry while the new row's registration catches up. + if ( + delay === undefined || + apiError?.code !== ERROR_CODE.INVALID_REQUEST || + !apiError.message?.includes(`new_row_id ${newRowId} does not belong to database ${databaseId}`) + ) { + throw error; + } + + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } } export async function databaseBlobDiff( diff --git a/src/components/database/fullcalendar/draft/CalendarEventDraft.test.ts b/src/components/database/fullcalendar/draft/CalendarEventDraft.test.ts index 3cf81af2f..5761b6feb 100644 --- a/src/components/database/fullcalendar/draft/CalendarEventDraft.test.ts +++ b/src/components/database/fullcalendar/draft/CalendarEventDraft.test.ts @@ -367,4 +367,67 @@ describe('CalendarEventDraft defaults and properties', () => { await draft.commit(async (snapshot) => snapshot.id); expect(parseSelectOptionTypeOptions(liveField).options).toEqual([existing, remote, local]); }); + + it.each(['rejection', 'empty result'])('keeps option edits local after a save %s and merges them on retry', async (failure) => { + const { source, database } = fixture(); + const liveField = database.get(YjsDatabaseKey.fields).get('tags'); + const existing = { id: 'existing', name: 'Existing', color: 0 }; + const removed = { id: 'removed', name: 'Remove on save', color: 1 }; + const renamed = { ...existing, name: 'Renamed' }; + const added = { id: 'added', name: 'Added', color: 2 }; + const remote = { id: 'remote', name: 'Concurrent option', color: 3 }; + + getTypeOptions(liveField)!.set(YjsDatabaseKey.content, JSON.stringify({ options: [existing, removed] })); + const draft = createDraft(source); + const localField = draft.database.get(YjsDatabaseKey.fields).get('tags'); + + getTypeOptions(localField)!.set(YjsDatabaseKey.content, JSON.stringify({ options: [renamed, added] })); + setCell(draft, 'tags', added.id); + let optionsDuringSave: ReturnType['options']; + const persist = jest.fn(async (snapshot: CalendarDraftSnapshot) => { + if (persist.mock.calls.length > 1) return snapshot.id; + optionsDuringSave = parseSelectOptionTypeOptions(liveField).options; + // Another client adds an option while this draft's save is pending. + getTypeOptions(liveField)!.set(YjsDatabaseKey.content, JSON.stringify({ options: [...optionsDuringSave, remote] })); + if (failure === 'empty result') return null; + throw new Error('Template duplication failed'); + }); + + await expect(draft.commit(persist)).rejects.toThrow( + failure === 'empty result' ? 'could not be saved' : 'Template duplication failed' + ); + expect(optionsDuringSave).toEqual([existing, removed]); + expect(parseSelectOptionTypeOptions(liveField).options).toEqual([existing, removed, remote]); + expect(draft.savedId).toBeNull(); + expect(draft.dirty).toBe(true); + + await expect(draft.commit(persist)).resolves.toBe(draft.id); + expect(parseSelectOptionTypeOptions(liveField).options).toEqual([renamed, remote, added]); + }); + + it('publishes option edits when a reciprocal-link failure happens after the row is published', async () => { + const { source, database, orders } = fixture(); + const draft = createDraft(source); + const liveField = database.get(YjsDatabaseKey.fields).get('tags'); + const localField = draft.database.get(YjsDatabaseKey.fields).get('tags'); + const added = { id: 'added', name: 'Added', color: 2 }; + + getTypeOptions(localField)!.set(YjsDatabaseKey.content, JSON.stringify({ options: [added] })); + setCell(draft, 'tags', added.id); + const persist = jest.fn(async (snapshot: CalendarDraftSnapshot) => { + orders.push([{ id: snapshot.id, height: 36 }]); + throw new Error('Related row unavailable'); + }); + + await expect(draft.commit(persist)).rejects.toThrow('Related row unavailable'); + expect(draft.savedId).toBe(draft.id); + expect(parseSelectOptionTypeOptions(liveField).options).toEqual([added]); + // Subsequent calls must not replay option edits over newer remote changes. + const remote = { ...added, name: 'Renamed remotely' }; + + getTypeOptions(liveField)!.set(YjsDatabaseKey.content, JSON.stringify({ options: [remote] })); + await expect(draft.commit(persist)).resolves.toBe(draft.id); + expect(persist).toHaveBeenCalledTimes(1); + expect(parseSelectOptionTypeOptions(liveField).options).toEqual([remote]); + }); }); diff --git a/src/components/database/fullcalendar/draft/CalendarEventDraft.ts b/src/components/database/fullcalendar/draft/CalendarEventDraft.ts index fb58a21ec..614947f3a 100644 --- a/src/components/database/fullcalendar/draft/CalendarEventDraft.ts +++ b/src/components/database/fullcalendar/draft/CalendarEventDraft.ts @@ -221,10 +221,11 @@ export class CalendarEventDraft { snapshotDoc.getMap('snapshot').set('meta', meta); try { await this.resolveUploads(cells); - this.commitOptions(); const id = await persist({ id: this.id, cells, meta }); if (!id) throw new Error('The calendar row could not be saved'); + // Keep shared schema untouched while row/template setup can still fail. + this.commitOptions(); this.savedId = id; return id; } catch (error) { @@ -233,7 +234,13 @@ export class CalendarEventDraft { const liveDatabase = this.source.databaseDoc.getMap(YjsEditorKey.data_section).get(YjsEditorKey.database) as YDatabase; const orders = liveDatabase.get(YjsDatabaseKey.views).get(this.source.activeViewId)?.get(YjsDatabaseKey.row_orders); - if (orders?.toArray().some((row) => row.id === this.id)) this.savedId = this.id; + if (orders?.toArray().some((row) => row.id === this.id)) { + this.savedId = this.id; + // The published row already references these options even if a + // later reciprocal-link update failed. + this.commitOptions(); + } + throw error; } finally { snapshotDoc.destroy(); diff --git a/src/components/database/fullcalendar/event/EventWithPopover.tsx b/src/components/database/fullcalendar/event/EventWithPopover.tsx index 124838b6a..82f331ed1 100644 --- a/src/components/database/fullcalendar/event/EventWithPopover.tsx +++ b/src/components/database/fullcalendar/event/EventWithPopover.tsx @@ -138,7 +138,11 @@ export const EventWithPopover = memo((props: EventWithPopoverProps) => { const navigateToRow = useNavigateToRow(); const draft = draftContext?.draft; - if (draftContext && props.event.extendedProps.isDraft && draft?.id === props.event.id) { + if (props.event.extendedProps.isDraft) { + // FullCalendar removes its React portal after the draft context updates. + // A stale segment must not mount live row observers for a discarded ID. + if (!draftContext || draft?.id !== props.event.id) return null; + return ( ({ + useDatabaseContext: jest.requireActual('@/application/database-yjs/context').useDatabaseContext, + useCalendarLayoutSetting: () => ({ fieldId: 'date' }), + usePrimaryFieldId: () => 'title', +})); +jest.mock('@/application/database-yjs/dispatch/row', () => ({ useNewRowDispatch: () => mockCreateRow })); +jest.mock('@/components/database/components/database-row/DeleteRowConfirm', () => ({ + __esModule: true, + default: () => null, +})); +jest.mock('../../CalendarContent', () => ({ + useEventContext: () => ({}), +})); +jest.mock('../EventDisplay', () => ({ + EventDisplay: ({ event }: EventContentArg) => { + // Keep the real metadata observer used by EventIconButton: it resolves + // the row through whichever DatabaseContext the event is rendered under. + const { useRowMetaSelector } = jest.requireActual('@/application/database-yjs/selector'); + + useRowMetaSelector(event.id); + return
{event.id}
; + }, +})); +jest.mock('../EventPopoverContent', () => ({ + __esModule: true, + default: ({ onCloseEvent, onRequestDelete }: { onCloseEvent: () => void; onRequestDelete: () => void }) => ( + <> + + + + ), +})); + +const emptyEvents: EventInput[] = []; +const plugins = [dayGridPlugin]; +const renderEvent = (eventInfo: EventContentArg) => ; + +function DraftCalendar() { + const { draft, event, startDraft, finishDraft, discardDraft } = useCalendarDraft(emptyEvents, emptyEvents); + const events = useMemo(() => (event ? [event] : emptyEvents), [event]); + + return ( + + + + + ); +} + +function createContext(): DatabaseContextState { + const databaseDoc = new Y.Doc() as YDoc; + const database = new Y.Map() as YDatabase; + const fields = new Y.Map(); + const views = new Y.Map(); + const view = new Y.Map() as YDatabaseView; + + for (const [id, type] of [['title', FieldType.RichText], ['date', FieldType.DateTime]] as const) { + const field = new Y.Map() as YDatabaseField; + + field.set(YjsDatabaseKey.id, id); + field.set(YjsDatabaseKey.type, type); + field.set(YjsDatabaseKey.is_primary, id === 'title'); + fields.set(id, field); + } + + view.set(YjsDatabaseKey.row_orders, new Y.Array()); + views.set('calendar', view); + database.set(YjsDatabaseKey.id, 'database'); + database.set(YjsDatabaseKey.fields, fields); + database.set(YjsDatabaseKey.views, views); + databaseDoc.getMap(YjsEditorKey.data_section).set(YjsEditorKey.database, database); + + return { + readOnly: false, + databaseDoc, + rowMap: {}, + ensureRow: jest.fn(async () => undefined), + databasePageId: 'calendar', + activeViewId: 'calendar', + workspaceId: 'workspace', + }; +} + +describe('FullCalendar draft removal', () => { + let context: DatabaseContextState; + + beforeAll(() => { + Object.defineProperty(window, 'ResizeObserver', { + configurable: true, + value: class { + observe() { return undefined; } + unobserve() { return undefined; } + disconnect() { return undefined; } + }, + }); + }); + + beforeEach(() => { + jest.clearAllMocks(); + context = createContext(); + }); + + afterEach(() => { + cleanup(); + context.databaseDoc.destroy(); + }); + + it.each(['Close draft', 'Discard draft'])('never loads the discarded row through the live context after %s', async (action) => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'New draft' })); + const dismiss = await screen.findByRole('button', { name: action }); + + expect(screen.getByTestId('event-display')).toBeTruthy(); + expect(context.ensureRow).not.toHaveBeenCalled(); + fireEvent.click(dismiss); + await waitFor(() => expect(screen.queryByTestId('event-display')).toBeNull()); + expect(mockCreateRow).not.toHaveBeenCalled(); + expect(context.ensureRow).not.toHaveBeenCalled(); + }); +}); From 465348c6104cf2c9c6e45ef2f67356710d0f2273 Mon Sep 17 00:00:00 2001 From: Nathan Date: Mon, 14 Sep 2026 09:13:40 +0000 Subject: [PATCH 19/21] fix(timeline): keep a selected table row opaque MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selecting a row swapped the docked table cell's opaque background for the theme's selection fill, which is translucent — so a bar or arrow scrolled under the table showed through the selected row, as if drawn over the cells. The tint is now painted over the opaque background. The hover scenario checks the selected cell's computed background. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NoEH4HcEiojEcioVSLE3QM --- playwright/bdd/features/database/timeline.feature | 1 + playwright/bdd/steps/timeline.steps.ts | 12 ++++++++++++ src/components/database/timeline/TimelineRow.tsx | 2 +- .../database/timeline/TimelineSidebarRow.tsx | 5 ++++- 4 files changed, 18 insertions(+), 2 deletions(-) diff --git a/playwright/bdd/features/database/timeline.feature b/playwright/bdd/features/database/timeline.feature index 58e4f0612..59e07dafa 100644 --- a/playwright/bdd/features/database/timeline.feature +++ b/playwright/bdd/features/database/timeline.feature @@ -61,6 +61,7 @@ Feature: Timeline view interactions And the hover card starts at the "Design" bar and clears the docked table When I click the table row "Build" Then the "Build" row and bar are selected + And the selected "Build" table row stays opaque When I click the empty canvas of the "Build" row Then no timeline row is selected When I open the table row "Build" diff --git a/playwright/bdd/steps/timeline.steps.ts b/playwright/bdd/steps/timeline.steps.ts index 7bd8ac9e9..d252164bb 100644 --- a/playwright/bdd/steps/timeline.steps.ts +++ b/playwright/bdd/steps/timeline.steps.ts @@ -356,6 +356,18 @@ Then('the {string} row and bar are selected', async ({ page }, title) => { await expect(TimelineSelectors.bar(page, id)).toHaveAttribute('data-selected', 'true'); }); +Then('the selected {string} table row stays opaque', async ({ page }, title) => { + // A translucent selection tint would let a bar scrolled under the docked + // table show through; the cell itself must keep an opaque background. + const background = await page + .getByTestId(`timeline-sidebar-cell-${rowId(page, title)}`) + .evaluate((element) => getComputedStyle(element).backgroundColor); + // `rgb(...)` is opaque; `rgba(r, g, b, a)` only when a is 1. + const alpha = background.startsWith('rgba(') ? Number(background.slice(5, -1).split(',')[3]) : 1; + + expect(alpha, `background ${background} must be opaque`).toBe(1); +}); + When('I click the empty canvas of the {string} row', async ({ page }, title) => { // Far right of the visible canvas, well clear of any bar. const view = await TimelineSelectors.view(page).boundingBox(); diff --git a/src/components/database/timeline/TimelineRow.tsx b/src/components/database/timeline/TimelineRow.tsx index 2fc48d37b..8b0c05226 100644 --- a/src/components/database/timeline/TimelineRow.tsx +++ b/src/components/database/timeline/TimelineRow.tsx @@ -191,7 +191,7 @@ export const TimelineRow = memo(
diff --git a/src/components/database/timeline/TimelineSidebarRow.tsx b/src/components/database/timeline/TimelineSidebarRow.tsx index 02b86a6a1..f9556c1e7 100644 --- a/src/components/database/timeline/TimelineSidebarRow.tsx +++ b/src/components/database/timeline/TimelineSidebarRow.tsx @@ -91,7 +91,10 @@ export const TimelineSidebarRow = memo( className={cn( // `group/list-row` reveals the shared row actions on hover, as in the List view. 'group/list-row sticky left-0 z-10 flex h-full shrink-0 items-center overflow-hidden border-b border-r border-border-primary bg-background-primary text-sm text-text-primary', - selected && 'bg-fill-theme-select', + // The selection tint is translucent: paint it over the opaque + // background rather than instead of it, or the bar and arrows + // scrolled under the docked table show through. + selected && 'before:pointer-events-none before:absolute before:inset-0 before:bg-fill-theme-select', dnd.dragging && 'opacity-40' )} style={{ width }} From 889dbfe8995a31fc24035b6eb60b47aeb3824ace Mon Sep 17 00:00:00 2001 From: Nathan Date: Wed, 16 Sep 2026 13:29:13 +0800 Subject: [PATCH 20/21] fix(timeline): align table layout and desktop settings Read desktop-authored Yjs table columns and dependency metadata, including nested updates. Align new-row and calculation footers, keep the table divider continuous, and route dependency connectors to the bar edges without reversed bends. Validated with TypeScript checks, focused timeline tests, and live desktop and web checks. --- .../__tests__/timeline-layout.test.ts | 80 +++++++++++++++++++ .../database-yjs/timeline-layout.ts | 29 ++++--- .../database/timeline/TimelineGroupRow.tsx | 4 +- .../database/timeline/TimelineView.tsx | 29 ++++--- .../timeline/__tests__/dependencies.test.ts | 70 ++++++++++++++-- src/components/database/timeline/constants.ts | 2 + .../database/timeline/scale/dependencies.ts | 73 ++++++++--------- 7 files changed, 215 insertions(+), 72 deletions(-) diff --git a/src/application/database-yjs/__tests__/timeline-layout.test.ts b/src/application/database-yjs/__tests__/timeline-layout.test.ts index acf305c2a..abc5d160a 100644 --- a/src/application/database-yjs/__tests__/timeline-layout.test.ts +++ b/src/application/database-yjs/__tests__/timeline-layout.test.ts @@ -264,3 +264,83 @@ test('dependency direction and per-link type / lag round-trip; default links nee doc.transact(() => updateTimelineLayoutSetting(view, { dependencyLinks: {} })); expect(setting.has(YjsDatabaseKey.dependency_links)).toBe(false); }); + +test('desktop table columns decode from shared arrays and follow remote edits', () => { + const desktop = createFixture(); + const webDoc = new Y.Doc(); + + initializeTimelineLayoutSetting(desktop.view, 'date'); + const setting = desktop.view.get(YjsDatabaseKey.layout_settings).get(TIMELINE_LAYOUT_KEY); + const columns = new Y.Array(); + + setting.set(YjsDatabaseKey.table_field_ids, columns); + columns.push(['blocked-by', 'blocking']); + sync(desktop.doc, webDoc); + + const store = createTimelineLayoutStore(webDoc, 'timeline', 0, false); + const initial = store.getSnapshot(); + + expect(initial.tableFieldIds).toEqual(['blocked-by', 'blocking']); + expect(store.getSnapshot()).toBe(initial); + const notify = jest.fn(); + const unsubscribe = store.subscribe(notify); + + desktop.doc.transact(() => { + columns.delete(0, 1); + columns.push(['owner']); + }); + sync(desktop.doc, webDoc); + expect(notify).toHaveBeenCalledTimes(1); + expect(store.getSnapshot().tableFieldIds).toEqual(['blocking', 'owner']); + expect(initial.tableFieldIds).toEqual(['blocked-by', 'blocking']); + expect(store.getSnapshot()).toBe(store.getSnapshot()); + + columns.delete(0, columns.length); + sync(desktop.doc, webDoc); + expect(notify).toHaveBeenCalledTimes(2); + expect(store.getSnapshot().tableFieldIds).toEqual([]); + unsubscribe(); +}); + +test('desktop dependency metadata decodes from shared maps and follows nested remote edits', () => { + const desktop = createFixture(); + const webDoc = new Y.Doc(); + + initializeTimelineLayoutSetting(desktop.view, 'date'); + const setting = desktop.view.get(YjsDatabaseKey.layout_settings).get(TIMELINE_LAYOUT_KEY); + const links = new Y.Map>(); + + setting.set(YjsDatabaseKey.dependency_links, links); + sync(desktop.doc, webDoc); + + const store = createTimelineLayoutStore(webDoc, 'timeline', 0, false); + + expect(store.getSnapshot().dependencyLinks).toEqual({}); + const notify = jest.fn(); + const unsubscribe = store.subscribe(notify); + const link = new Y.Map([ + ['ty', TimelineDependencyType.StartToStart], + ['lag', 2], + ]); + + links.set('a:b', link); + sync(desktop.doc, webDoc); + const initial = store.getSnapshot(); + + expect(notify).toHaveBeenCalledTimes(1); + expect(initial.dependencyLinks).toEqual({ 'a:b': { type: TimelineDependencyType.StartToStart, lag: 2 } }); + expect(store.getSnapshot()).toBe(initial); + + link.set('lag', -1); + sync(desktop.doc, webDoc); + expect(notify).toHaveBeenCalledTimes(2); + expect(store.getSnapshot().dependencyLinks).toEqual({ 'a:b': { type: TimelineDependencyType.StartToStart, lag: -1 } }); + expect(initial.dependencyLinks['a:b'].lag).toBe(2); + expect(store.getSnapshot()).toBe(store.getSnapshot()); + + links.delete('a:b'); + sync(desktop.doc, webDoc); + expect(notify).toHaveBeenCalledTimes(3); + expect(store.getSnapshot().dependencyLinks).toEqual({}); + unsubscribe(); +}); diff --git a/src/application/database-yjs/timeline-layout.ts b/src/application/database-yjs/timeline-layout.ts index bc560f034..dcb840ada 100644 --- a/src/application/database-yjs/timeline-layout.ts +++ b/src/application/database-yjs/timeline-layout.ts @@ -27,23 +27,26 @@ export const DEFAULT_TIMELINE_DEPENDENCY_SHIFT = TimelineDependencyShift.Overlap const EMPTY_IDS: string[] = []; const EMPTY_LINKS: Record = {}; -// `getSnapshot` re-reads the setting on every subscriber render. Yjs hands the -// same object back until the key is rewritten, so parse each stored value once. +// Plain values keep their identity until the key is rewritten. Shared Yjs +// containers are converted before caching because their contents can change +// without replacing the container. const parsedLinks = new WeakMap>(); const parsedIds = new WeakMap(); /** - * Per-link metadata as stored (a plain map of `{ ty, lag }` records). Unknown + * Per-link metadata in a plain or shared map of `{ ty, lag }` records. Unknown * types fall back to finish-to-start and lag is clamped to whole days. */ function linkMap(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return EMPTY_LINKS; - const cached = parsedLinks.get(value); + const records = value instanceof Y.Map ? value.toJSON() : value; + + if (!records || typeof records !== 'object' || Array.isArray(records)) return EMPTY_LINKS; + const cached = parsedLinks.get(records); if (cached) return cached; const result: Record = {}; - Object.entries(value as Record).forEach(([key, raw]) => { + Object.entries(records as Record).forEach(([key, raw]) => { if (!raw || typeof raw !== 'object') return; const record = raw as { ty?: unknown; lag?: unknown }; const type = integer(record.ty, TimelineDependencyType.FinishToStart, TimelineDependencyType.StartToFinish); @@ -54,7 +57,7 @@ function linkMap(value: unknown): Record { const links = Object.keys(result).length === 0 ? EMPTY_LINKS : result; - parsedLinks.set(value, links); + parsedLinks.set(records, links); return links; } @@ -68,16 +71,18 @@ function sameLinks(a: Record, b: Record typeof id === 'string' && id !== ''); + const filtered = values.filter((id): id is string => typeof id === 'string' && id !== ''); const ids = filtered.length === 0 ? EMPTY_IDS : filtered; - parsedIds.set(value, ids); + parsedIds.set(values, ids); return ids; } diff --git a/src/components/database/timeline/TimelineGroupRow.tsx b/src/components/database/timeline/TimelineGroupRow.tsx index cc35cdc55..682eac084 100644 --- a/src/components/database/timeline/TimelineGroupRow.tsx +++ b/src/components/database/timeline/TimelineGroupRow.tsx @@ -62,9 +62,9 @@ export const TimelineGroupFooter = memo(({ group, fieldId, sidebarWidth, showSid tabIndex={0} className={cn( 'sticky left-0 z-10 flex h-full shrink-0 cursor-pointer items-center gap-1.5 overflow-hidden border-b border-r border-border-primary bg-background-primary text-sm text-text-tertiary hover:bg-fill-content-hover', - showSidebar ? 'pr-3' : 'justify-center' + showSidebar ? 'px-2' : 'justify-center' )} - style={{ width: sidebarWidth, paddingLeft: showSidebar ? 40 : undefined }} + style={{ width: sidebarWidth }} data-testid={`timeline-group-new-row-${group.id}`} aria-label={t('grid.row.newRow', { defaultValue: 'New row' })} onClick={() => void createRow()} diff --git a/src/components/database/timeline/TimelineView.tsx b/src/components/database/timeline/TimelineView.tsx index b967d1564..10d705d88 100644 --- a/src/components/database/timeline/TimelineView.tsx +++ b/src/components/database/timeline/TimelineView.tsx @@ -55,6 +55,7 @@ import { TIMELINE_ROW_HEIGHT, TIMELINE_SIDEBAR_WIDTH, TIMELINE_TABLE_COLUMN_WIDTH, + TIMELINE_TABLE_CONTROL_WIDTH, TIMELINE_TODAY_ANCHOR, } from './constants'; import { useScrollWindow } from './hooks/useScrollWindow'; @@ -735,7 +736,7 @@ export function TimelineView({ setting }: { setting: TimelineLayoutSetting }) { {/* Blank sticky column so overlays never show through below the last table cell. */}
@@ -868,9 +869,9 @@ export function TimelineView({ setting }: { setting: TimelineLayoutSetting }) { // Same treatment as the grid's "+ New row" footer. className={cn( 'sticky left-0 z-10 flex h-full shrink-0 cursor-pointer items-center gap-1.5 border-b border-r border-border-primary bg-fill-content text-sm font-medium text-text-secondary hover:bg-fill-content-hover', - showSidebar ? 'pr-3' : 'justify-center' + showSidebar ? 'px-2' : 'justify-center' )} - style={{ width: sidebarWidth, paddingLeft: showSidebar ? 40 : undefined }} + style={{ width: sidebarWidth }} data-testid='timeline-new-row' aria-label={t('grid.row.newRow', { defaultValue: 'New row' })} onClick={handleNewRow} @@ -888,7 +889,7 @@ export function TimelineView({ setting }: { setting: TimelineLayoutSetting }) { ) : null} {showSidebar ? ( - // Calculations footer under the table, one cell per column, as in the grid. + // Borderless like the grid: empty calculation controls appear on hover.
{primaryFieldId ? : null}
- {tableFieldIds.map((fieldId) => ( + {tableFieldIds.map((fieldId, index) => (
))} - {/* Same trailing control slot as the header toggle and the rows' open button. */} -
) : null} + {/* Keep the table divider continuous above row and footer backgrounds. */} +
+
+
diff --git a/src/components/database/timeline/__tests__/dependencies.test.ts b/src/components/database/timeline/__tests__/dependencies.test.ts index 5bf5a3aef..3a8d9fec7 100644 --- a/src/components/database/timeline/__tests__/dependencies.test.ts +++ b/src/components/database/timeline/__tests__/dependencies.test.ts @@ -63,6 +63,33 @@ describe('dependency graph', () => { describe('dependency arrow path', () => { const options = { rowHeight: 36, barInset: 4 }; + test('an upward connector leaves the bar top and enters the target horizontally', () => { + const path = dependencyArrowPath( + { rect: { left: 36, width: 36 }, index: 2 }, + { rect: { left: 72, width: 180 }, index: 1 }, + { rowHeight: 36, barInset: 7 } + ); + + expect(path.startsWith('M 54 79 ')).toBe(true); + expect(path).toContain('V 59 a 5 5 0 0 1 5 -5 H 72'); + expect(path.endsWith('m -5 -5 l 5 5 l -5 5')).toBe(true); + }); + + test.each([1, 8, 18])('a narrow predecessor (%ipx) keeps its connector attached', (width) => { + const path = dependencyArrowPath( + { rect: { left: 36, width }, index: 1 }, + { rect: { left: 36 + width, width: 180 }, index: 0 }, + { rowHeight: 36, barInset: 7 } + ); + const [, x, y] = /^M (\S+) (\S+)/.exec(path)!; + + expect(Number(x)).toBeGreaterThanOrEqual(36); + expect(Number(x)).toBeLessThanOrEqual(36 + width); + expect(Number(y)).toBe(43); + expect(path).toContain(`H ${36 + width} m -5 -5`); + expect(path).not.toMatch(/a -|NaN|Infinity/); + }); + test('a successor that starts after the predecessor gets the short two-bend route', () => { const path = dependencyArrowPath( { rect: { left: 0, width: 100 }, index: 0 }, @@ -70,8 +97,8 @@ describe('dependency arrow path', () => { options ); - expect(path.startsWith('M 40 32 V ')).toBe(true); - expect(path).toContain('L 147 90'); + expect(path.startsWith('M 50 32 V ')).toBe(true); + expect(path).toContain('H 160'); expect(path.endsWith('m -5 -5 l 5 5 l -5 5')).toBe(true); }); @@ -82,9 +109,25 @@ describe('dependency arrow path', () => { options ); - expect(path).toContain('H 42'); + expect(path).toContain('H 46'); expect((path.match(/ a /g) ?? []).length).toBe(3); - expect(path).toContain('L 47 54'); + expect(path).toContain('H 60 m -5 -5'); + }); + + test.each([0, 2])('a backward connector from row %i turns away from its source bar', (index) => { + const path = dependencyArrowPath( + { rect: { left: 100, width: 100 }, index }, + { rect: { left: 60, width: 50 }, index: 1 }, + options + ); + const direction = index === 0 ? 1 : -1; + const startY = index === 0 ? 32 : 76; + const bends = Array.from(path.matchAll(/a \S+ \S+ 0 0 [01] \S+ (\S+)/g)); + + expect(path.startsWith(`M 118 ${startY} V ${startY} `)).toBe(true); + expect(bends).toHaveLength(3); + expect(bends.every((bend) => Number(bend[1]) * direction > 0)).toBe(true); + expect(path).toContain('H 60 m -5 -5'); }); }); @@ -310,7 +353,7 @@ describe('dependency direction and per-link metadata', () => { describe('dependencyLinkPath', () => { const options = { rowHeight: 36, barInset: 4 }; - test("finish-to-start delegates to frappe's route", () => { + test('finish-to-start uses the rounded vertical route', () => { const from = { rect: { left: 0, width: 100 }, index: 0 }; const to = { rect: { left: 160, width: 80 }, index: 2 }; @@ -327,7 +370,7 @@ describe('dependencyLinkPath', () => { options ); - expect(path.startsWith('M 100 18 H 82 V 54 H 147')).toBe(true); + expect(path.startsWith('M 100 18 H 82 V 54 H 160')).toBe(true); expect(path.endsWith('m -5 -5 l 5 5 l -5 5')).toBe(true); }); @@ -339,7 +382,7 @@ describe('dependencyLinkPath', () => { options ); - expect(direct).toBe('M 200 18 H 218 V 54 H 113 m 5 -5 l -5 5 l 5 5'); + expect(direct).toBe('M 200 18 H 218 V 54 H 100 m 5 -5 l -5 5 l 5 5'); const detour = dependencyLinkPath( TimelineDependencyType.FinishToFinish, @@ -348,6 +391,17 @@ describe('dependencyLinkPath', () => { options ); - expect(detour).toBe('M 50 18 H 68 V 36 H 231 V 54 H 213 m 5 -5 l -5 5 l 5 5'); + expect(detour).toBe('M 50 18 H 68 V 36 H 218 V 54 H 200 m 5 -5 l -5 5 l 5 5'); + }); + + test('start-to-finish reaches the right edge with a left-pointing head', () => { + const path = dependencyLinkPath( + TimelineDependencyType.StartToFinish, + { rect: { left: 40, width: 60 }, index: 1 }, + { rect: { left: 100, width: 100 }, index: 0 }, + options + ); + + expect(path).toBe('M 40 54 H 22 V 36 H 218 V 18 H 200 m 5 -5 l -5 5 l 5 5'); }); }); diff --git a/src/components/database/timeline/constants.ts b/src/components/database/timeline/constants.ts index 71ef0f923..615feb304 100644 --- a/src/components/database/timeline/constants.ts +++ b/src/components/database/timeline/constants.ts @@ -10,6 +10,8 @@ export const TIMELINE_SIDEBAR_WIDTH = 280; export const TIMELINE_COLLAPSED_SIDEBAR_WIDTH = 32; /** Width of each extra property column in the docked table. */ export const TIMELINE_TABLE_COLUMN_WIDTH = 140; +/** Width reserved for the table toggle and each row's open button. */ +export const TIMELINE_TABLE_CONTROL_WIDTH = 28; /** Vertical inset of a bar inside its row: 36px rows hold the calendar's 22px event chips. */ export const TIMELINE_BAR_INSET = 7; /** Extra blank rows below the last row so the canvas can be scrolled past it. */ diff --git a/src/components/database/timeline/scale/dependencies.ts b/src/components/database/timeline/scale/dependencies.ts index ba6498a84..1c446f41f 100644 --- a/src/components/database/timeline/scale/dependencies.ts +++ b/src/components/database/timeline/scale/dependencies.ts @@ -1,11 +1,11 @@ /** * Dependency graph helpers and the arrow routing between bars. * - * The arrow path is a port of frappe/gantt's `Arrow.calculate_path` (MIT, - * Copyright (c) 2024 Frappe Technologies Pvt. Ltd.): leave the predecessor - * from underneath its bar, drop to the successor's row and enter its left - * edge, looping back with two extra bends when the successor starts before - * the predecessor ends. + * The finish-to-start route is adapted from frappe/gantt's + * `Arrow.calculate_path` (MIT, Copyright (c) 2024 Frappe Technologies Pvt. + * Ltd.): leave the predecessor toward the successor's row and enter its + * left edge, looping along the row boundary when there is too little room + * for a direct turn. */ import { TimelineDependencyDirection, @@ -142,8 +142,7 @@ export function dependencyLinkPath( const exitX = exit === 'finish' ? from.rect.left + from.rect.width : from.rect.left; const exitY = rowMid(from.index); const exitClearX = exit === 'finish' ? exitX + padding : exitX - padding; - // The head tip sits 13px outside the entered edge, as in the finish-to-start route. - const entryX = entry === 'start' ? to.rect.left - 13 : to.rect.left + to.rect.width + 13; + const entryX = entry === 'start' ? to.rect.left : to.rect.left + to.rect.width; const entryY = rowMid(to.index); const entryDir = entry === 'start' ? 1 : -1; const head = entry === 'start' ? 'm -5 -5 l 5 5 l -5 5' : 'm 5 -5 l -5 5 l 5 5'; @@ -176,55 +175,49 @@ export function dependencyArrowPath(from: ArrowEndpoint, to: ArrowEndpoint, opti const barHeight = rowHeight - barInset * 2; const rowTop = (index: number) => index * rowHeight; - let startX = from.rect.left + from.rect.width / 2; - - // Walk the exit point left until the successor's start is reachable. - while (to.rect.left < startX + padding && startX > from.rect.left + padding) { - startX -= 10; - } - - startX -= 10; - const startY = rowTop(from.index) + barInset + barHeight; - const endX = to.rect.left - 13; - const endY = rowTop(to.index) + rowHeight / 2; + // Stay inside even a very narrow bar, while leaving room for the turn. + const startX = Math.min( + from.rect.left + from.rect.width / 2, + Math.max(from.rect.left + padding, to.rect.left - padding) + ); const fromIsBelowTo = from.index > to.index; + const direction = fromIsBelowTo ? -1 : 1; + const startY = rowTop(from.index) + barInset + (fromIsBelowTo ? 0 : barHeight); + const endX = to.rect.left; + const endY = rowTop(to.index) + rowHeight / 2; const clockwise = fromIsBelowTo ? 1 : 0; - let curve = options.curve ?? 5; - let curveY = fromIsBelowTo ? -curve : curve; - - if (to.rect.left <= from.rect.left + padding) { - let down1 = padding / 2 - curve; + let curve = Math.max(0, options.curve ?? 5); - if (down1 < 0) { - down1 = 0; - curve = padding / 2; - curveY = fromIsBelowTo ? -curve : curve; - } + if (endX - startX < padding) { + // Keep the detour between rows, and fit each bend into the available + // clearance so short bars and upward links never double back. + const gapY = rowTop(from.index) + (fromIsBelowTo ? 0 : rowHeight); + const left = Math.min(startX, endX) - padding; - const down2 = rowTop(to.index) + barInset + barHeight / 2 - curveY; - const left = to.rect.left - padding; + curve = Math.min(curve, barInset, (startX - left) / 2, (endX - left) / 2, Math.abs(endY - gapY) / 2); + const curveY = direction * curve; return [ `M ${startX} ${startY}`, - `v ${down1}`, - `a ${curve} ${curve} 0 0 1 ${-curve} ${curve}`, - `H ${left}`, + `V ${gapY - curveY}`, + `a ${curve} ${curve} 0 0 ${fromIsBelowTo ? 0 : 1} ${-curve} ${curveY}`, + `H ${left + curve}`, `a ${curve} ${curve} 0 0 ${clockwise} ${-curve} ${curveY}`, - `V ${down2}`, + `V ${endY - curveY}`, `a ${curve} ${curve} 0 0 ${clockwise} ${curve} ${curveY}`, - `L ${endX} ${endY}`, + `H ${endX}`, 'm -5 -5 l 5 5 l -5 5', ].join(' '); } - if (endX < startX + curve) curve = endX - startX; - const offset = fromIsBelowTo ? endY + curve : endY - curve; + curve = Math.min(curve, (endX - startX) / 2, Math.abs(endY - startY)); + const curveY = direction * curve; return [ `M ${startX} ${startY}`, - `V ${offset}`, - `a ${curve} ${curve} 0 0 ${clockwise} ${curve} ${curve}`, - `L ${endX} ${endY}`, + `V ${endY - curveY}`, + `a ${curve} ${curve} 0 0 ${clockwise} ${curve} ${curveY}`, + `H ${endX}`, 'm -5 -5 l 5 5 l -5 5', ].join(' '); } From 5ca6a29b341469e5e1d803be7e48dbe18afc3ba0 Mon Sep 17 00:00:00 2001 From: Nathan Date: Wed, 16 Sep 2026 21:57:02 +0800 Subject: [PATCH 21/21] fix(database): surface server errors when creating and copying views --- .../app/view-actions/AddPageActions.tsx | 6 +-- .../__tests__/AddPageActions.test.tsx | 13 ++++++ .../components/tabs/AddViewButton.tsx | 3 +- .../database/components/tabs/DatabaseTabs.tsx | 3 +- .../tabs/__tests__/AddViewButton.test.tsx | 25 ++++++++++ .../tabs/__tests__/DatabaseTabs.test.tsx | 46 +++++++++++++++++++ .../toolbar/block-controls/ControlsMenu.tsx | 3 +- 7 files changed, 93 insertions(+), 6 deletions(-) diff --git a/src/components/app/view-actions/AddPageActions.tsx b/src/components/app/view-actions/AddPageActions.tsx index 103e4e01b..667fcf2f5 100644 --- a/src/components/app/view-actions/AddPageActions.tsx +++ b/src/components/app/view-actions/AddPageActions.tsx @@ -21,6 +21,7 @@ import { } from '@/components/app/app.hooks'; import { DropdownMenuGroup, DropdownMenuItem } from '@/components/ui/dropdown-menu'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { getErrorMessage } from '@/utils/errors'; function AddPageActions({ view, onImportClick }: { view: View; onImportClick?: (view: View) => void }) { const { t } = useTranslation(); @@ -145,10 +146,9 @@ function AddPageActions({ view, onImportClick }: { view: View; onImportClick?: ( } toast.dismiss(loadingToastId); - // eslint-disable-next-line - } catch (e: any) { + } catch (e: unknown) { toast.dismiss(loadingToastId); - toast.error(e.message); + toast.error(getErrorMessage(e, 'Failed to create page')); } }, [ diff --git a/src/components/app/view-actions/__tests__/AddPageActions.test.tsx b/src/components/app/view-actions/__tests__/AddPageActions.test.tsx index 188fcce97..5d6723987 100644 --- a/src/components/app/view-actions/__tests__/AddPageActions.test.tsx +++ b/src/components/app/view-actions/__tests__/AddPageActions.test.tsx @@ -553,6 +553,19 @@ describe('AddPageActions', () => { expect(mockToView).toHaveBeenCalledWith('chat-id'); }); + it('shows the workspace plan error without navigating when Timeline creation is rejected', async () => { + const message = 'Creating a Timeline view requires an active Pro plan for this workspace.'; + + mockAddPage.mockRejectedValueOnce({ code: 1090, message }); + renderActions(view({ view_id: 'space-id', extra: { is_space: true } })); + fireEvent.click(screen.getByTestId('add-timeline-page-button')); + + await waitFor(() => expect(toast.error).toHaveBeenCalledWith(message)); + expect(toast.dismiss).toHaveBeenCalled(); + expect(mockToView).not.toHaveBeenCalled(); + expect(mockOpenPageModal).not.toHaveBeenCalled(); + }); + it('does not initialize or navigate to an AI chat when page creation fails', async () => { mockAddPage.mockRejectedValueOnce(new Error('create failed')); diff --git a/src/components/database/components/tabs/AddViewButton.tsx b/src/components/database/components/tabs/AddViewButton.tsx index 2b0e146dd..34aab8124 100644 --- a/src/components/database/components/tabs/AddViewButton.tsx +++ b/src/components/database/components/tabs/AddViewButton.tsx @@ -10,6 +10,7 @@ import { ViewIcon } from '@/components/_shared/view-icon'; import { Button } from '@/components/ui/button'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; import { Progress } from '@/components/ui/progress'; +import { getErrorMessage } from '@/utils/errors'; interface AddViewButtonProps { databasePageId: string; @@ -72,7 +73,7 @@ export function AddViewButton({ databasePageId, onBeforeAddView, onAfterAddView, } catch (e: unknown) { if (isCurrentActionScope()) { console.error('[AddViewButton] Error adding view:', e); - toast.error(e instanceof Error ? e.message : 'Failed to add view'); + toast.error(getErrorMessage(e, 'Failed to add view')); } } finally { if (isCurrentActionScope()) { diff --git a/src/components/database/components/tabs/DatabaseTabs.tsx b/src/components/database/components/tabs/DatabaseTabs.tsx index 412ace22b..f6c80e7b5 100644 --- a/src/components/database/components/tabs/DatabaseTabs.tsx +++ b/src/components/database/components/tabs/DatabaseTabs.tsx @@ -21,6 +21,7 @@ import DeleteViewConfirm from '@/components/database/components/tabs/DeleteViewC import { useOpenDatabaseAsPage } from '@/components/database/hooks'; import { Button } from '@/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { getErrorMessage } from '@/utils/errors'; const TAB_BAR_CLASS_NAME = '-mb-[0.5px] flex items-center text-text-primary flex-col max-sm:!px-6 min-w-0 overflow-hidden'; @@ -472,7 +473,7 @@ export const DatabaseTabs = forwardRef( } catch (error) { if (isCurrentDuplicateScope()) { toast.error( - error instanceof Error ? error.message : t('document.plugins.subPage.errors.failedDuplicatePage') + getErrorMessage(error, t('document.plugins.subPage.errors.failedDuplicatePage')) ); } } finally { diff --git a/src/components/database/components/tabs/__tests__/AddViewButton.test.tsx b/src/components/database/components/tabs/__tests__/AddViewButton.test.tsx index 7c9542db0..5f1a2b371 100644 --- a/src/components/database/components/tabs/__tests__/AddViewButton.test.tsx +++ b/src/components/database/components/tabs/__tests__/AddViewButton.test.tsx @@ -1,5 +1,6 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; +import { toast } from 'sonner'; import { DatabaseViewLayout } from '@/application/types'; import { AddViewButton } from '@/components/database/components/tabs/AddViewButton'; @@ -73,6 +74,30 @@ describe('AddViewButton', () => { jest.restoreAllMocks(); }); + it('shows the server plan error and finishes loading without selecting a new view', async () => { + const onViewAdded = jest.fn(); + const onAfterAddView = jest.fn(); + const message = 'Creating a Timeline view requires an active Pro plan for this workspace.'; + + jest.spyOn(console, 'error').mockImplementation(() => undefined); + mockAddView.mockRejectedValueOnce({ code: 1090, message }); + render( + + + + ); + + fireEvent.click(screen.getByTestId('add-timeline-view-button')); + + await waitFor(() => expect(toast.error).toHaveBeenCalledWith(message)); + expect(onViewAdded).not.toHaveBeenCalled(); + expect(onAfterAddView).toHaveBeenCalledTimes(1); + }); + it('creates an enabled List view and selects it', async () => { const onViewAdded = jest.fn(); diff --git a/src/components/database/components/tabs/__tests__/DatabaseTabs.test.tsx b/src/components/database/components/tabs/__tests__/DatabaseTabs.test.tsx index b8fe5d3d8..afc36cfb0 100644 --- a/src/components/database/components/tabs/__tests__/DatabaseTabs.test.tsx +++ b/src/components/database/components/tabs/__tests__/DatabaseTabs.test.tsx @@ -1,4 +1,5 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { toast } from 'sonner'; import { useDatabase, useDatabaseContext } from '@/application/database-yjs'; import { DatabaseContextState } from '@/application/database-yjs/context'; @@ -6,6 +7,8 @@ import { useDuplicateDatabaseView, useUpdateDatabaseView } from '@/application/d import { DatabaseViewLayout, UIVariant, View, ViewLayout, YjsDatabaseKey } from '@/application/types'; import { DatabaseTabs } from '@/components/database/components/tabs/DatabaseTabs'; +jest.mock('sonner', () => ({ toast: { error: jest.fn(), success: jest.fn() } })); + jest.mock('@/application/database-yjs', () => ({ useDatabase: jest.fn(), useDatabaseContext: jest.fn(), @@ -209,6 +212,49 @@ describe('DatabaseTabs', () => { await waitFor(() => expect(onAfterViewAddedToDatabase).toHaveBeenCalledTimes(1)); }); + it('shows the plan rejection when duplicating a Timeline view', async () => { + const message = 'Creating a Timeline view requires a Pro workspace.'; + const duplicateView = jest.fn().mockRejectedValue({ code: 1090, message }); + const onBeforeViewAddedToDatabase = jest.fn(); + const onAfterViewAddedToDatabase = jest.fn(); + const sourceYjsView = { + get: jest.fn((key: YjsDatabaseKey) => { + if (key === YjsDatabaseKey.name) return 'Timeline'; + if (key === YjsDatabaseKey.layout) return DatabaseViewLayout.Timeline; + return undefined; + }), + }; + const views = new Map([[databaseView.view_id, sourceYjsView]]); + const context = { + createDatabaseView: jest.fn(), + isDocumentBlock: true, + loadViewMeta: jest.fn(async () => databaseContainer), + readOnly: false, + showActions: true, + } as unknown as DatabaseContextState; + const props = { + databasePageId: databaseView.view_id, + selectedViewId: databaseView.view_id, + viewIds: [databaseView.view_id], + onBeforeViewAddedToDatabase, + onAfterViewAddedToDatabase, + }; + + (useDatabase as jest.Mock).mockReturnValue({ get: () => views }); + (useDuplicateDatabaseView as jest.Mock).mockReturnValue(duplicateView); + (useDatabaseContext as jest.Mock).mockReturnValue(context); + + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Duplicate view' })); + + await waitFor(() => expect(duplicateView).toHaveBeenCalledWith(databaseView.view_id, 'Timeline (Copy)')); + expect(toast.error).toHaveBeenCalledWith(message); + expect(toast.success).not.toHaveBeenCalled(); + expect(onBeforeViewAddedToDatabase).toHaveBeenCalledTimes(1); + await waitFor(() => expect(onAfterViewAddedToDatabase).toHaveBeenCalledTimes(1)); + }); + it('renders the database container name for an embedded database', async () => { const loadViewMeta = jest.fn(async (viewId: string) => { if (viewId === databaseView.view_id) return databaseView; diff --git a/src/components/editor/components/toolbar/block-controls/ControlsMenu.tsx b/src/components/editor/components/toolbar/block-controls/ControlsMenu.tsx index 4c2189ac1..c0598e37e 100644 --- a/src/components/editor/components/toolbar/block-controls/ControlsMenu.tsx +++ b/src/components/editor/components/toolbar/block-controls/ControlsMenu.tsx @@ -34,6 +34,7 @@ import { import { BlockNode, CalloutNode, DatabaseNode, OutlineNode } from '@/components/editor/editor.type'; import { useEditorContext, useEditorLocalState } from '@/components/editor/EditorContext'; import { copyTextToClipboard } from '@/utils/copy'; +import { getErrorMessage } from '@/utils/errors'; import CalloutIconControl from './CalloutIconControl'; import CalloutQuickStyleControl from './CalloutQuickStyleControl'; @@ -660,7 +661,7 @@ function ControlsMenu({ onClose(); Promise.resolve(option.onClick()).catch((error) => { notify.error( - error instanceof Error ? error.message : t('document.plugins.subPage.errors.failedDuplicatePage') + getErrorMessage(error, t('document.plugins.subPage.errors.failedDuplicatePage')) ); }); }}