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/features/database/timeline.feature b/playwright/bdd/features/database/timeline.feature new file mode 100644 index 000000000..59e07dafa --- /dev/null +++ b/playwright/bdd/features/database/timeline.feature @@ -0,0 +1,313 @@ +@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: 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 + 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 + 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" + 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 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 + 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 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 + 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 + 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 + 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 + 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 + + 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 + + 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 + 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 + 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 + + 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 + + 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 + + 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-integration.steps.ts b/playwright/bdd/steps/timeline-integration.steps.ts new file mode 100644 index 000000000..c5a1f760f --- /dev/null +++ b/playwright/bdd/steps/timeline-integration.steps.ts @@ -0,0 +1,214 @@ +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); +}); + +/** + * 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 }) => { + state(page).publishedUrl = await publishCurrentPage(page); +}); + +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); + // 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 new file mode 100644 index 000000000..d252164bb --- /dev/null +++ b/playwright/bdd/steps/timeline.steps.ts @@ -0,0 +1,1257 @@ +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, + readBarSamples, + startBarSampler, + dragHandleBy, + expectBarWidth, + expectBarX, + injectFieldDirect, + loginAndCreateCalendarWithRows, + MONTH_COLUMN_WIDTH, + pressAndMoveBar, + readRowWrites, + startCountingRowWrites, + 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; + /** 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(); + +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 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'); +}); + +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'); +}); + +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(); +}); + +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'); +}); + +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(); + 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'); +}); + +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); +}); + +// --- 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()); +}); + +// --- 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); +}); + +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(); +}); + +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); +}); + +// --- 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 }); +}); + +// --- Dependency setup, direction and link editing --------------------------- + +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); +}); + +// --- 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); +}); + +/** 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 }); +}); + +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 }); +}); + +// --- 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/playwright/e2e/database/timeline.spec.ts b/playwright/e2e/database/timeline.spec.ts new file mode 100644 index 000000000..e514999c2 --- /dev/null +++ b/playwright/e2e/database/timeline.spec.ts @@ -0,0 +1,229 @@ +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 }); + // 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%/, { + 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); + + // 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 * 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}`); + 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/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/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/playwright/support/selectors.ts b/playwright/support/selectors.ts index c2867efed..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}"]`), @@ -866,3 +866,48 @@ 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-"]'), + 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-"]'), + 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..3874c63b8 --- /dev/null +++ b/playwright/support/timeline-test-helpers.ts @@ -0,0 +1,304 @@ +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(); +} + +/** + * 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); + + 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); +} + +/** 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 d5c738a79..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" @@ -2375,6 +2377,8 @@ "linkedKanban": "Linked Kanban", "calendar": "Calendar", "linkedCalendar": "Linked Calendar", + "timeline": "Timeline", + "linkedTimeline": "Linked Timeline", "quote": "Quote", "divider": "Divider", "table": "Table", @@ -3624,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" @@ -4345,5 +4352,64 @@ "revertedDialogTitle": "Page Restored", "revertedDialogDescription": "This page was restored to a previous version from another device.", "revertedDismiss": "Got it" + }, + "timeline": { + "menuName": "Timeline", + "referencedTimelinePrefix": "View of", + "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", + "shiftDependents": "Shift dependents", + "shiftOverlapOnly": "Only when dates overlap", + "shiftMaintainGap": "Keep the time between items", + "shiftNever": "Never", + "avoidWeekends": "Avoid weekends", + "endDateField": "End date", + "tableProperties": "Table properties", + "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" + }, + "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/@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/__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/constants.ts b/src/application/constants.ts index d14039338..6dfbc83d2 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 @@ -25,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/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/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/__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/__tests__/timeline-layout.test.ts b/src/application/database-yjs/__tests__/timeline-layout.test.ts new file mode 100644 index 000000000..abc5d160a --- /dev/null +++ b/src/application/database-yjs/__tests__/timeline-layout.test.ts @@ -0,0 +1,346 @@ +import * as Y from 'yjs'; + +import { YDatabase, YDatabaseView, YjsDatabaseKey, YjsEditorKey } from '@/application/types'; + +import { + TimelineDependencyDirection, + TimelineDependencyShift, + TimelineDependencyType, + 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, + endFieldId: '', + dependencyFieldId: '', + dependencyDirection: TimelineDependencyDirection.BlockedBy, + dependencyLinks: {}, + dependencyShift: TimelineDependencyShift.OverlapOnly, + avoidWeekends: false, + progressFieldId: '', + tableFieldIds: [], + 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, + endFieldId: '', + dependencyFieldId: '', + dependencyDirection: TimelineDependencyDirection.BlockedBy, + dependencyLinks: {}, + dependencyShift: TimelineDependencyShift.OverlapOnly, + avoidWeekends: false, + progressFieldId: '', + tableFieldIds: [], + 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('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); + 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 + ); +}); + +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, + endFieldId: '', + dependencyFieldId: '', + dependencyDirection: TimelineDependencyDirection.BlockedBy, + dependencyLinks: {}, + dependencyShift: TimelineDependencyShift.OverlapOnly, + avoidWeekends: false, + progressFieldId: '', + tableFieldIds: [], + 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); +}); + +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); +}); + +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/database.type.ts b/src/application/database-yjs/database.type.ts index 68fc1e59d..e6ea383b7 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; @@ -104,6 +106,84 @@ 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, +} + +/** 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, +} + +/** 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; + 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; + /// 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; + /// 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. + 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 { fieldId: string; firstDayOfWeek: number; @@ -124,9 +204,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; @@ -141,7 +221,7 @@ export enum AITranslateLanguage { Spanish, Portuguese, Standard_Arabic, - Simplified_Chinese + Simplified_Chinese, } export enum RowCommentKey { @@ -163,5 +243,5 @@ export enum DateGroupCondition { Day = 1, Week = 2, Month = 3, - Year = 4 + Year = 4, } diff --git a/src/application/database-yjs/dispatch.ts b/src/application/database-yjs/dispatch.ts index bd699ee43..628d32f2c 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, @@ -135,6 +136,7 @@ import { YDatabaseGridLayoutSetting, YDatabaseLayoutSettings, YDatabaseListLayoutSetting, + YDatabaseTimelineLayoutSetting, YDatabaseRow, YDatabaseRowOrders, YDatabaseSort, @@ -1039,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) { @@ -1050,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; @@ -1060,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(); @@ -1089,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(); @@ -2748,6 +2762,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 +2773,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 +3227,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 +4991,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/dispatch/relation.ts b/src/application/database-yjs/dispatch/relation.ts index a73a3c0b1..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() ?? {}); @@ -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/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/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 77bc32eec..6bd67ab44 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'; @@ -46,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, @@ -1773,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 }; @@ -1981,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 @@ -2010,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( @@ -2025,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); @@ -2084,7 +2088,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, @@ -2191,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. * @@ -3114,7 +3123,50 @@ export interface CalendarEvent { export function useCalendarEventsSelector() { const setting = useCalendarLayoutSetting(); - const fieldId = setting?.fieldId || ''; + + 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 { events, emptyEvents: starts.emptyEvents, hasEndField }; +} + +/** + * 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 || ''); @@ -3233,23 +3285,25 @@ export function useCalendarEventsSelector() { 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]); @@ -3273,6 +3327,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..dcb840ada --- /dev/null +++ b/src/application/database-yjs/timeline-layout.ts @@ -0,0 +1,308 @@ +import * as Y from 'yjs'; + +import { + YDatabase, + YDatabaseLayoutSettings, + YDatabaseTimelineLayoutSetting, + YDatabaseView, + YjsDatabaseKey, + YjsEditorKey, +} from '@/application/types'; + +import { + TimelineDependencyDirection, + TimelineDependencyLink, + TimelineDependencyShift, + TimelineDependencyType, + 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; + +const EMPTY_IDS: string[] = []; +const EMPTY_LINKS: Record = {}; +// 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 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 { + 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(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); + 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 }; + }); + + const links = Object.keys(result).length === 0 ? EMPTY_LINKS : result; + + parsedLinks.set(records, links); + return links; +} + +function sameLinks(a: Record, b: Record) { + if (a === b) return true; + 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) + ); +} + +/** Property ids stored as either a plain array or a desktop-authored Y.Array. */ +function idList(value: unknown): string[] { + const values = value instanceof Y.Array ? value.toArray() : value; + + if (!Array.isArray(values)) return EMPTY_IDS; + const cached = parsedIds.get(values); + + if (cached) return cached; + const filtered = values.filter((id): id is string => typeof id === 'string' && id !== ''); + const ids = filtered.length === 0 ? EMPTY_IDS : filtered; + + parsedIds.set(values, ids); + return ids; +} + +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 avoidWeekends = setting?.get(YjsDatabaseKey.avoid_weekends); + const dependencyShift = integer( + setting?.get(YjsDatabaseKey.dependency_shift_ty), + 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); + + 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, + 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) ?? '', + tableFieldIds: idList(setting?.get(YjsDatabaseKey.table_field_ids)), + }; +} + +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); + } + + 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.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]); + else setting.delete(YjsDatabaseKey.table_field_ids); + } +} + +/** + * 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 sameIds = (a: string[], b: string[]) => + a === b || (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) => + key === 'tableFieldIds' + ? !sameIds(next.tableFieldIds, snapshot.tableFieldIds) + : key === 'dependencyLinks' + ? !sameLinks(next.dependencyLinks, snapshot.dependencyLinks) + : 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/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/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/__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/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/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 0cca5cf0e..e77a96c91 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', @@ -506,6 +507,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 +662,24 @@ 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', + /// 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: 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. + table_field_ids = 'table_field_ids', icon = 'icon', is_inline = 'is_inline', embedded = 'embedded', @@ -891,6 +913,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 +997,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 { @@ -988,12 +1016,38 @@ 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; } +/// 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 | 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 + | 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 | YjsDatabaseKey.dependency_links): unknown; +} + export interface YDatabaseChartLayoutSetting extends Y.Map { get(key: 'chartType' | 'aggregationType' | 'dateCondition'): string; get(key: 'xFieldId' | 'yFieldId'): string | undefined; @@ -1262,6 +1316,7 @@ export const layoutMap = { [ViewLayout.Gallery]: 'gallery', [ViewLayout.Feed]: 'feed', [ViewLayout.Form]: 'form', + [ViewLayout.Timeline]: 'timeline', }; export const databaseLayoutMap = { @@ -1273,6 +1328,7 @@ export const databaseLayoutMap = { [DatabaseViewLayout.Gallery]: 'gallery', [DatabaseViewLayout.Feed]: 'feed', [DatabaseViewLayout.Form]: 'form', + [DatabaseViewLayout.Timeline]: 'timeline', }; export enum FontLayout { @@ -1758,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/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/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/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/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/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/_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/_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/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/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/view-actions/AddPageActions.tsx b/src/components/app/view-actions/AddPageActions.tsx index 6445ea8e2..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')); } }, [ @@ -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/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/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')}
+
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/database/DatabaseViews.tsx b/src/components/database/DatabaseViews.tsx index 7c62acca8..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 { @@ -43,6 +44,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 +361,8 @@ function DatabaseViews({ return ; case DatabaseViewLayout.Feed: return ; + case DatabaseViewLayout.Timeline: + return ; default: return null; } @@ -485,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/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 = ( + + + ), +})); + +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(); + }); +}); 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/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..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} @@ -190,7 +150,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' @@ -246,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/Timeline.tsx b/src/components/database/timeline/Timeline.tsx new file mode 100644 index 000000000..b644db2d4 --- /dev/null +++ b/src/components/database/timeline/Timeline.tsx @@ -0,0 +1,28 @@ +import { FieldType, useFieldSelector, useTimelineLayoutSetting } from '@/application/database-yjs'; +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]; + +export function Timeline() { + const setting = useTimelineLayoutSetting(); + const { field } = useFieldSelector(setting.fieldId); + const fieldType = field ? (Number(field.get(YjsDatabaseKey.type)) as FieldType) : null; + + if (!setting.fieldId || fieldType === null || !DATE_FIELD_TYPES.includes(fieldType)) { + return ; + } + + // One sort subscription for every row's insert / reorder confirmation. + return ( + + + + ); +} + +export default Timeline; diff --git a/src/components/database/timeline/TimelineArrows.tsx b/src/components/database/timeline/TimelineArrows.tsx new file mode 100644 index 000000000..279d53d57 --- /dev/null +++ b/src/components/database/timeline/TimelineArrows.tsx @@ -0,0 +1,163 @@ +import { memo, MutableRefObject, useMemo } from 'react'; + +import { TIMELINE_BAR_INSET, TIMELINE_ROW_HEIGHT } from './constants'; +import { TimelineLinkDrag } from './hooks/useTimelineLinkDrag'; +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; +} + +/** + * 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)[]; + graph: DependencyGraph; + /** Rows currently rendered; arrows touching them are drawn. */ + firstVisibleIndex: number; + lastVisibleIndex: number; + canvasWidth: number; + bodyHeight: number; + /** Horizontal offset of the canvas inside the body (the sticky sidebar). */ + left: number; + /** A connector being dragged from a bar's link handle. */ + pending?: TimelineLinkDrag | null; + /** 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; +} + +/** + * Dependency arrows drawn as one SVG overlay under the bars. Only pairs that + * touch the rendered row window are emitted so very long lists stay cheap. + */ +export const TimelineArrows = memo( + ({ + rowIds, + rects, + graph, + firstVisibleIndex, + lastVisibleIndex, + canvasWidth, + bodyHeight, + left, + pending, + svgRef, + selectedKey, + }: TimelineArrowsProps) => { + const paths = useMemo(() => { + const indexOf = new Map(rowIds.map((rowId, index) => [rowId, index] as const)); + const result: { key: string; d: string; predecessorId: string; successorId: string }[] = []; + + graph.predecessors.forEach((predecessors, rowId) => { + const toIndex = indexOf.get(rowId); + const toRect = toIndex === undefined ? null : rects[toIndex]; + + if (toIndex === undefined || !toRect) return; + predecessors.forEach((predecessor) => { + const fromIndex = indexOf.get(predecessor); + const fromRect = fromIndex === undefined ? null : rects[fromIndex]; + + if (fromIndex === undefined || !fromRect) return; + const touchesWindow = + (fromIndex >= firstVisibleIndex && fromIndex <= lastVisibleIndex) || + (toIndex >= firstVisibleIndex && toIndex <= lastVisibleIndex); + + if (!touchesWindow) return; + result.push({ + 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 } + ), + }); + }); + }); + + return result; + }, [firstVisibleIndex, graph, lastVisibleIndex, rects, rowIds]); + + if (paths.length === 0 && !pending) return null; + + return ( + // 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. + + {paths.map((path) => ( + + + {/* Wide invisible twin used by isPointInStroke when the canvas is clicked. */} + + + ))} + {pending ? ( + + + + + ) : null} + + ); + } +); + +TimelineArrows.displayName = 'TimelineArrows'; diff --git a/src/components/database/timeline/TimelineBar.tsx b/src/components/database/timeline/TimelineBar.tsx new file mode 100644 index 000000000..5153aff06 --- /dev/null +++ b/src/components/database/timeline/TimelineBar.tsx @@ -0,0 +1,348 @@ +import dayjs from 'dayjs'; +import { memo, PointerEvent as ReactPointerEvent, ReactNode, useLayoutEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Column } from '@/application/database-yjs'; +import { CardField } from '@/components/database/components/field/CardField'; +import { EventIconButton } from '@/components/database/fullcalendar/event/components/EventIconButton'; +import { + calendarEventCompletionTime, + useCalendarEventPast, +} from '@/components/database/fullcalendar/event/eventAppearance'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; + +import { TIMELINE_BAR_INSET, TIMELINE_ROW_HEIGHT } from './constants'; +import { TimelineDragMode } from './hooks/useTimelineDrag'; +import { TimelineRowModel } from './hooks/useTimelineRows'; +import { BarRect, calendarDaysBetween, getBarSpan, ICON_ONLY_BAR_WIDTH } from './scale/geometry'; + +const CONTENT_GAP = 4; +const HANDLE_WIDTH = 8; +const PROGRESS_HANDLE_SIZE = 10; + +export interface TimelineBarDragLabel { + side: 'start' | 'end'; + text: string; +} + +interface TimelineBarProps { + row: TimelineRowModel; + rect: BarRect; + /** Non-primary properties shown as chips after the title. */ + propertyFields: Column[]; + editable: boolean; + selected?: boolean; + /** This bar is the one being dragged; its rect is the live preview. */ + dragging?: boolean; + /** Another bar's drag is moving this one along (a dependent). */ + following?: boolean; + dragLabel?: TimelineBarDragLabel; + /** 0–100 when a progress field is bound. */ + progress?: number; + /** Progress value while the progress handle is being dragged. */ + 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. */ + 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; +} + +/** + * 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(); + + if (!row.start) return null; + const span = getBarSpan(row.start, row.end, row.allDay); + const start = dayjs(span.start); + const lastDay = dayjs(span.endExclusive).subtract(1, 'day'); + const dates = row.allDay + ? lastDay.isSame(start, 'day') + ? start.format('MMM D, YYYY') + : `${start.format('MMM D')} – ${lastDay.format('MMM D, YYYY')}` + : `${start.format('MMM D, h:mm A')} – ${dayjs(span.endExclusive).format('h:mm A')}`; + const days = row.allDay ? calendarDaysBetween(span.start, span.endExclusive) : undefined; + + return ( +
+ {row.title || t('grid.row.titlePlaceholder', { defaultValue: 'Untitled' })} + + {dates} + {days !== undefined ? ` · ${t('timeline.popup.duration', { count: days, defaultValue: `${days} days` })}` : ''} + + {progress !== undefined ? ( + + {t('timeline.popup.progress', { percent: progress, defaultValue: `${progress}% complete` })} + + ) : null} +
+ ); +} + +/** + * A row's bar. The title is drawn twice: a muted copy underneath the bar and + * the real one clipped inside it. Where the bar is too short the muted copy + * shows past its right edge, which is how Notion lets short bars stay readable. + */ +export const TimelineBar = memo( + ({ + row, + rect, + propertyFields, + editable, + selected, + dragging, + following, + dragLabel, + progress, + progressPreview, + hoverDisabled, + hoverCardBoundary, + hoverCardInset = 0, + formatTime, + linkable, + linkTarget, + onLinkPointerDown, + onOpen, + onPointerDown, + }: TimelineBarProps) => { + const { rowId, title } = row; + const { t } = useTranslation(); + // Too narrow for text: show the icon-only chip and let the title spill past it. + const iconOnly = rect.width < ICON_ONLY_BAR_WIDTH; + const spilledLabelOffset = rect.width + CONTENT_GAP; + const shownProgress = progressPreview ?? progress; + const showProgress = shownProgress !== undefined; + const highlighted = dragging || following || selected; + // Like calendar cards, a bar whose span has ended fades unless it is being handled. + const isPast = useCalendarEventPast( + calendarEventCompletionTime({ start: row.start, end: row.end, allDay: row.allDay, isRange: row.isRange }) + ); + + const bar = ( +
{ + if (editable) onPointerDown?.(event, 'move'); + }} + onClick={(event) => { + event.stopPropagation(); + // With editing on, the drag hook turns a still press into the open action. + if (!editable) onOpen?.(rowId); + }} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onOpen?.(rowId); + } + }} + className={cn( + // The calendar's two card treatments: filled chip for all-day rows, + // transparent time-prefixed card for timed rows. + 'event-content absolute inset-0 flex select-none flex-col items-center overflow-hidden rounded-200 border border-transparent text-xs font-medium', + row.allDay + ? 'bg-other-colors-filled-event text-other-colors-text-event hover:bg-other-colors-filled-event-hover' + : 'time-event-content text-text-primary hover:bg-fill-content-hover', + '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 || linkTarget) && 'ring-1 ring-fill-theme-thick', + 'py-0 pl-1 pr-1' + )} + > + {showProgress ? ( +
+ ) : null} +
+
+
+
+ {!iconOnly && !row.allDay && row.start ? ( + {formatTime(row.start)} + ) : null} +
+ + {iconOnly ? null : ( + + {title || t('grid.row.titlePlaceholder', { defaultValue: 'Untitled' })} + + )} + {iconOnly + ? null + : propertyFields.map((field) => ( + + + + ))} +
+
+
+
+
+ ); + + return ( +
+ {iconOnly ? ( +
+ {!row.allDay && row.start ? `${formatTime(row.start)} ` : ''} + {title} +
+ ) : null} + + {/* Keep the element structure stable: swapping the bar between a bare div + and a Tooltip subtree would remount every visible bar on each press. */} + + {bar} + {hoverDisabled ? null : ( + + + + )} + + + {editable ? ( + <> +
onPointerDown?.(event, 'resize-start')} + > + +
+
onPointerDown?.(event, 'resize-end')} + > + +
+ {linkable ? ( + + ) : null} + {showProgress && !iconOnly ? ( +
onPointerDown?.(event, 'progress')} + > + +
+ ) : null} + + ) : null} + + {dragLabel ? ( +
+ {dragLabel.text} +
+ ) : null} +
+ ); + } +); + +TimelineBar.displayName = 'TimelineBar'; diff --git a/src/components/database/timeline/TimelineGrid.tsx b/src/components/database/timeline/TimelineGrid.tsx new file mode 100644 index 000000000..d203bee77 --- /dev/null +++ b/src/components/database/timeline/TimelineGrid.tsx @@ -0,0 +1,46 @@ +import { memo } from 'react'; + +import { cn } from '@/lib/utils'; + +import { HeaderColumn } from './scale/geometry'; + +interface TimelineGridProps { + columns: HeaderColumn[]; + /** Horizontal offset of the canvas inside the body (the sticky sidebar). */ + left: number; + todayX: number; + showToday: boolean; +} + +/** + * Weekend shading, gridlines and the today line behind the rows. Memoized so + * drag steps and selection changes don't re-reconcile one div per column. + */ +export const TimelineGrid = memo(({ columns, left, todayX, showToday }: TimelineGridProps) => { + return ( +
+ {columns.map((column) => + column.isWeekend || column.gridLine ? ( +
+ ) : null + )} + {showToday ? ( +
+ ) : null} +
+ ); +}); + +TimelineGrid.displayName = 'TimelineGrid'; diff --git a/src/components/database/timeline/TimelineGroupRow.tsx b/src/components/database/timeline/TimelineGroupRow.tsx new file mode 100644 index 000000000..682eac084 --- /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/TimelineHeader.tsx b/src/components/database/timeline/TimelineHeader.tsx new file mode 100644 index 000000000..4be08f781 --- /dev/null +++ b/src/components/database/timeline/TimelineHeader.tsx @@ -0,0 +1,101 @@ +import { memo } from 'react'; + +import { cn } from '@/lib/utils'; + +import { TIMELINE_HEADER_HEIGHT } from './constants'; +import { BarRect, HeaderColumn, HeaderSegment } from './scale/geometry'; +import { TimelineHeaderMode } from './scale/presets'; + +interface TimelineHeaderProps { + mode: TimelineHeaderMode; + segments: HeaderSegment[]; + columns: HeaderColumn[]; + canvasWidth: number; + /** Span of the bar being dragged, echoed behind the column labels. */ + highlight?: BarRect | null; + /** Width of the sticky sidebar corner that segment labels must stay clear of. */ + 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 + * labels month segments instead, each pinned with `position: sticky` so the + * current month stays readable while scrolling. + */ +export const TimelineHeader = memo( + ({ mode, segments, columns, canvasWidth, highlight, stickyOffset }: TimelineHeaderProps) => { + return ( +
+ + {highlight ? ( +
+ ) : null} +
+ ); + } +); + +TimelineHeader.displayName = 'TimelineHeader'; diff --git a/src/components/database/timeline/TimelineLinkEditor.tsx b/src/components/database/timeline/TimelineLinkEditor.tsx new file mode 100644 index 000000000..ff2813a46 --- /dev/null +++ b/src/components/database/timeline/TimelineLinkEditor.tsx @@ -0,0 +1,171 @@ +import { memo, 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 const TimelineLinkEditor = memo(function TimelineLinkEditor({ + selection, + link, + predecessorTitle, + successorTitle, + readOnly, + onChange, + onRemove, + onClose, +}: 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); + + if (shownLag !== link.lag) { + setShownLag(link.lag); + setLagText(String(link.lag)); + } + + 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/TimelineRow.tsx b/src/components/database/timeline/TimelineRow.tsx new file mode 100644 index 000000000..8b0c05226 --- /dev/null +++ b/src/components/database/timeline/TimelineRow.tsx @@ -0,0 +1,254 @@ +import { memo, MouseEvent, PointerEvent as ReactPointerEvent, useCallback, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Column } 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 { type Edge } from '@/components/database/components/drag-and-drop/useRowDnd'; +import { cn } from '@/lib/utils'; + +import { TIMELINE_ROW_HEIGHT } from './constants'; +import { TimelineDragMode } from './hooks/useTimelineDrag'; +import { TimelineRowModel } from './hooks/useTimelineRows'; +import { BarRect } from './scale/geometry'; +import { TimelineBar, TimelineBarDragLabel } from './TimelineBar'; +import { TimelineSidebarRow } from './TimelineSidebarRow'; + +/** Row-creation actions shared by every table row; the view owns the dispatches. */ +export interface TimelineRowActions { + addAbove: (rowId: string, groupFieldId?: string, groupId?: string) => Promise; + addBelow: (rowId: string, groupFieldId?: string, groupId?: string) => Promise; + duplicate: (rowId: string) => Promise; +} + +interface TimelineRowProps { + row: TimelineRowModel; + rect: BarRect | null; + /** The bar lies (partly) beyond the visible canvas on that side. */ + offscreenLeft: boolean; + offscreenRight: boolean; + sidebarWidth: number; + showSidebar: boolean; + propertyFields: Column[]; + editable: boolean; + selected?: boolean; + dragging?: boolean; + following?: boolean; + dragLabel?: TimelineBarDragLabel; + progress?: number; + 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. */ + 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. */ + groupFieldId?: string; + groupId?: string; + 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; + /** 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. */ + 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({ + direction, + offset, + onClick, +}: { + direction: 'left' | 'right'; + offset: number; + onClick: () => void; +}) { + const { t } = useTranslation(); + const Icon = direction === 'left' ? ArrowLeft : ArrowRight; + + return ( + + ); +} + +export const TimelineRow = memo( + ({ + row, + rect, + offscreenLeft, + offscreenRight, + sidebarWidth, + showSidebar, + propertyFields, + editable, + selected, + dragging, + following, + dragLabel, + progress, + progressPreview, + anyDragging, + hoverCardBoundary, + formatTime, + rowActions, + tableFieldIds, + groupFieldId, + groupId, + onOpen, + onSelect, + onScrollTo, + onBarPointerDown, + onEmptyClick, + onCanvasClick, + onDropRow, + linkable, + linkTarget, + onLinkPointerDown, + }: TimelineRowProps) => { + const { t } = useTranslation(); + const rowRef = useRef(null); + 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] + ); + // 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) { + // 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; + } + + const bounds = event.currentTarget.getBoundingClientRect(); + + onEmptyClick?.(row, event.clientX - bounds.left); + }; + + return ( +
+ {showSidebar ? ( + + ) : ( +
+ )} + +
+ {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/TimelineSidebarRow.tsx b/src/components/database/timeline/TimelineSidebarRow.tsx new file mode 100644 index 000000000..f9556c1e7 --- /dev/null +++ b/src/components/database/timeline/TimelineSidebarRow.tsx @@ -0,0 +1,167 @@ +import { memo, MutableRefObject, useCallback, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; + +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 { 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'; +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'; + +interface TimelineSidebarRowProps { + row: TimelineRowModel; + width: number; + editable: boolean; + selected?: boolean; + /** 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. */ + groupFieldId?: string; + groupId?: string; + /** The whole timeline row, so a drop anywhere along it counts. */ + dropTargetRef: MutableRefObject; + 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, + rowActions, + tableFieldIds, + groupFieldId, + groupId, + 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(); + // 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, + 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} + addAbove={addAbove} + addBelow={addBelow} + duplicate={duplicate} + /> + ) : ( +
+ )} + + {tableFieldIds.map((fieldId) => ( +
+ +
+ ))} + {/* Trailing control slot, the same width as the header's table toggle, so + the property columns line up with their headers. */} + + + + + {t('timeline.openRow', { defaultValue: 'Open' })} + + {dnd.closestEdge ? : null} + {dnd.clearSortsDialog} +
+ ); + } +); + +TimelineSidebarRow.displayName = 'TimelineSidebarRow'; 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..10d705d88 --- /dev/null +++ b/src/components/database/timeline/TimelineView.tsx @@ -0,0 +1,938 @@ +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'; +import { useTranslation } from 'react-i18next'; + +import { + FieldVisibility, + isAIFieldType, + TimelineDependencyDirection, + TimelineDependencyLink, + TimelineLayoutSetting, + timelineLinkKey, + TimelineLayout, + useDatabaseContext, + useDatabaseViewId, + useFieldSelector, + useDatabaseFields, + useDatabaseView, + useFieldsSelector, + useNavigateToRow, + usePrimaryFieldId, +} 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 { + 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'; +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'; +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_TABLE_COLUMN_WIDTH, + TIMELINE_TABLE_CONTROL_WIDTH, + TIMELINE_TODAY_ANCHOR, +} 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'; +import { useTimelineRange } from './hooks/useTimelineRange'; +import { TimelineRowModel, useTimelineRows } from './hooks/useTimelineRows'; +import { buildDependencyGraph, collectDependents, linkOf } from './scale/dependencies'; +import { + buildHeaderColumns, + buildHeaderSegments, + calendarDaysBetween, + dateToX, + BarRect, + getBarRect, + getBarSpan, + getSpanRect, + minBarWidth, + snapDate, + totalWidth, + xToDate, +} from './scale/geometry'; +import { hitTestLink, TimelineArrows, TimelineLinkSelection } from './TimelineArrows'; +import { TimelineLinkEditor } from './TimelineLinkEditor'; +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, 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. +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); + // 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(); + 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; + // 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 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(); + // 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( + (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]); + 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, + 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 (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)); + + updateStartEnd(rowId, setting.fieldId, dateToUnixTimestamp(start), end, true, history); + return; + } + + updateStartEnd( + rowId, + setting.fieldId, + dateToUnixTimestamp(start), + keepSingle ? undefined : dateToUnixTimestamp(endExclusive), + false, + history + ); + }, + [hasEndField, setting.endFieldId, setting.fieldId, updateStartEnd] + ); + + // 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); + + rowByIdRef.current = rowById; + + 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 = 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'); + // 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, + clickAfterDragRef: clickAfterBarDragRef, + } = 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 = 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. + 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 []; + return [ + { + rowId: dependent.rowId, + allDay: dependent.allDay, + ...getBarSpan(dependent.start, dependent.end, dependent.allDay), + predecessors: (graph.predecessors.get(dependentId) ?? []) + .filter((id) => movingSet.has(id)) + .map((id) => ({ rowId: id, ...linkOf(graph, id, dependentId) })), + }, + ]; + }); + + startDrag( + event, + { + rowId: row.rowId, + allDay: row.allDay, + ...span, + followers, + shift: setting.dependencyShift, + avoidWeekends: setting.avoidWeekends, + progress: progressValues.get(row.rowId) ?? 0, + }, + mode + ); + }, + [graph, permissions.editable, progressValues, setting.avoidWeekends, setting.dependencyShift, 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: items.length, + getScrollElement: () => scrollerRef.current, + estimateSize: () => TIMELINE_ROW_HEIGHT, + overscan: 8, + scrollMargin: TIMELINE_HEADER_HEIGHT, + getItemKey: (index) => items[index]?.key ?? index, + }); + + 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); + + 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 (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; + 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] + ); + + 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; + // 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; + updateSetting({ + dependencyLinks: { + ...setting.dependencyLinks, + [timelineLinkKey(selectedLink.predecessorId, selectedLink.successorId)]: next, + }, + }); + }, + [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 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); + + 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( + () => + 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; + 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]); + + // 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; + const lastVisibleIndex = virtualItems[virtualItems.length - 1]?.index ?? -1; + + return ( +
+ +
+
+
+
+ {showSidebar ? ( + {primaryFieldName} + ) : null} + {showSidebar + ? tableFieldIds.map((fieldId) => ( +
+ +
+ )) + : 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 || link ? ( + + ) : null} + {selectedLink && selectedLinkMeta && editorTitles ? ( + + ) : null} + + {virtualItems.map((virtualRow) => { + const item = items[virtualRow.index]; + + if (!item) return null; + if (item.kind !== 'row') { + return ( +
+ {item.kind === 'group' ? ( + + ) : ( + + )} +
+ ); + } + + 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. + const offscreenLeft = rect !== null && !isDragged && rect.left < viewLeft; + const offscreenRight = rect !== null && !isDragged && rect.left + rect.width > viewRight; + + return ( +
+ +
+ ); + })} + + {showNewRowFooter ? ( +
+
{ + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + handleNewRow(); + } + }} + > + + {showSidebar ? t('grid.row.newRow', { defaultValue: 'New row' }) : null} +
+
+ ) : null} + + {showSidebar ? ( + // Borderless like the grid: empty calculation controls appear on hover. +
+
+
+ {primaryFieldId ? : null} +
+ {tableFieldIds.map((fieldId, index) => ( +
+ +
+ ))} +
+
+ ) : null} + {/* Keep the table divider continuous above row and footer backgrounds. */} +
+
+
+
+
+
+
+ ); +} + +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..3a8d9fec7 --- /dev/null +++ b/src/components/database/timeline/__tests__/dependencies.test.ts @@ -0,0 +1,407 @@ +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'; + +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('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 }, + { rect: { left: 160, width: 80 }, index: 2 }, + options + ); + + 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); + }); + + 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 46'); + expect((path.match(/ a /g) ?? []).length).toBe(3); + 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'); + }); +}); + +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, + allDay: true, + start: local(2020, 11, day), + endExclusive: local(2020, 11, day + days), + }); + + 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', ...keepGap, 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 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, followers: [span('c', 14, 1)] }, + -columnWidth * 5 + ); + + // 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' }, -columnWidth * 5); + + expect(resize.start).toEqual(local(2020, 11, 5)); + expect(resize.followers).toEqual([]); + }); + + test('"maintain gap": extending the end pushes followers along; shrinking pulls them back', () => { + const grow = applyDragDelta( + geometry, + { ...span('a', 5, 3), mode: 'resize-end', ...keepGap, 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', ...keepGap, 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('"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: [fs('a')] }, + { ...span('c', 11, 1), predecessors: [fs('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: [fs('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', () => { + const preview = applyDragDelta( + geometry, + { + ...span('b', 10, 2), + mode: 'move', + shift: TimelineDependencyShift.Never, + followers: [{ ...span('c', 14, 1), predecessors: [fs('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: [fs('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; + + 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([]); + }); +}); + +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)); + }); +}); + +describe('dependencyLinkPath', () => { + const options = { rowHeight: 36, barInset: 4 }; + + 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 }; + + 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 160')).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 100 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 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/__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..615feb304 --- /dev/null +++ b/src/components/database/timeline/constants.ts @@ -0,0 +1,22 @@ +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; +/** 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. */ +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..eb55b1ab3 --- /dev/null +++ b/src/components/database/timeline/hooks/useTimelineDrag.ts @@ -0,0 +1,433 @@ +/** + * 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. 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. 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'; + +import { TimelineDependencyLink, TimelineDependencyShift, TimelineDependencyType } from '@/application/database-yjs'; + +import { + calendarDaysBetween, + columnIndexOf, + dateToX, + snapDate, + startOfDay, + TimelineGeometry, + xToDate, +} from '../scale/geometry'; + +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. */ + start: Date; + /** Exclusive bar end (the day after the last covered day for all-day rows). */ + endExclusive: Date; + allDay: boolean; + /** For followers: the links to rows it depends on, limited to the dragged bar and other followers. */ + predecessors?: TimelineDragLink[]; +} + +export interface TimelineDragOrigin extends TimelineDragSpan { + /** 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; + /** 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 }; +} + +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; +} + +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()); + + 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((link) => { + const predecessor = current.get(link.rowId); + + if (predecessor) required = Math.max(required, constraintStart(link, predecessor, span).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, + drag: TimelineDragOrigin & { mode: TimelineDragMode }, + deltaPx: number +): TimelineDragPreview { + const { preset } = geometry; + const snapMs = preset.snapMinutes * 60_000; + 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') { + const moved = shiftSpan(geometry, drag, deltaPx); + + return { ...base, ...moved, followers: shiftFollowers(geometry, drag, moved, deltaPx) }; + } + + if (drag.mode === 'resize-start') { + let start = shiftDate(geometry, drag.start, deltaPx); + + 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); + const resized = { rowId: drag.rowId, allDay: drag.allDay, start: drag.start, endExclusive }; + + return { ...base, ...resized, followers: shiftFollowers(geometry, drag, resized, 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); + // 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; + + 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; + } + + clickAfterDragRef.current = true; + window.setTimeout(() => { + clickAfterDragRef.current = false; + }, 0); + 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, clickAfterDragRef }; +} diff --git a/src/components/database/timeline/hooks/useTimelineFieldValues.ts b/src/components/database/timeline/hooks/useTimelineFieldValues.ts new file mode 100644 index 000000000..b9b01df7b --- /dev/null +++ b/src/components/database/timeline/hooks/useTimelineFieldValues.ts @@ -0,0 +1,74 @@ +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(); + +/** + * 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(); + // 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(observer)); + return () => { + observer.cancel(); + docs.forEach((doc) => doc.getMap(YjsEditorKey.data_section).unobserveDeep(observer)); + }; + }, [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/useTimelineItems.ts b/src/components/database/timeline/hooks/useTimelineItems.ts new file mode 100644 index 000000000..e28fd75da --- /dev/null +++ b/src/components/database/timeline/hooks/useTimelineItems.ts @@ -0,0 +1,41 @@ +import { useMemo } from 'react'; + +import type { DatabaseGrouping, GridGroup } from '@/application/database-yjs'; + +import { TimelineRowModel } from './useTimelineRows'; + +export type TimelineItem = + | { kind: 'row'; key: string; row: TimelineRowModel; groupId?: string } + | { kind: 'group'; key: string; group: GridGroup } + | { kind: 'footer'; key: string; group: GridGroup }; + +/** + * The vertical sequence the canvas renders: plain rows, or — when the view is + * grouped — a header per visible group, its rows unless collapsed, and a + * "+ New" footer (editors only). Every item is one row tall so bar and arrow + * geometry stays index-based. + */ +export function useTimelineItems( + rows: TimelineRowModel[], + grouping: DatabaseGrouping, + withFooters: boolean +): TimelineItem[] { + return useMemo(() => { + 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]); +} diff --git a/src/components/database/timeline/hooks/useTimelineLinkDrag.ts b/src/components/database/timeline/hooks/useTimelineLinkDrag.ts new file mode 100644 index 000000000..329a69362 --- /dev/null +++ b/src/components/database/timeline/hooks/useTimelineLinkDrag.ts @@ -0,0 +1,131 @@ +/** + * 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); + /** 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 => { + 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] + ); + + // 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 (!dragging) 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); + clickAfterDragRef.current = true; + window.setTimeout(() => { + clickAfterDragRef.current = false; + }, 0); + 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); + }; + }, [dragging, 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, clickAfterDragRef }; +} 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..61a6615dd --- /dev/null +++ b/src/components/database/timeline/hooks/useTimelineRows.ts @@ -0,0 +1,61 @@ +import { useMemo } from 'react'; + +import { CalendarEvent, Row, useRowOrdersSelector, useTimelineEventsSelector } from '@/application/database-yjs'; + +const EMPTY_ROW_ORDERS: Row[] = []; + +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, hasEndField } = 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, rowOrders: rowOrders ?? EMPTY_ROW_ORDERS, hasEndField }; +} 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..1c446f41f --- /dev/null +++ b/src/components/database/timeline/scale/dependencies.ts @@ -0,0 +1,223 @@ +/** + * Dependency graph helpers and the arrow routing between bars. + * + * 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, + TimelineDependencyLink, + TimelineDependencyType, + timelineLinkKey, +} from '@/application/database-yjs'; + +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; + /** 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 push(map: Map, key: string, value: string) { + const list = map.get(key); + + if (list) list.push(value); + else map.set(key, [value]); +} + +/** Build both directions of the graph, ignoring links to rows outside the view. */ +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; + // A cell may repeat an id; each edge is recorded once. + const seen = new Set(); + + rowIds.forEach((rowId) => { + (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); + + if (seen.has(edge)) return; + seen.add(edge); + push(predecessors, successor, predecessor); + push(dependents, predecessor, successor); + }); + }); + + return { predecessors, dependents, links: options.links ?? {} }; +} + +/** 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; +} + +/** 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; + 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'; + 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; + const padding = options.padding ?? 18; + const barHeight = rowHeight - barInset * 2; + const rowTop = (index: number) => index * rowHeight; + + // 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 = Math.max(0, options.curve ?? 5); + + 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; + + 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 ${gapY - curveY}`, + `a ${curve} ${curve} 0 0 ${fromIsBelowTo ? 0 : 1} ${-curve} ${curveY}`, + `H ${left + curve}`, + `a ${curve} ${curve} 0 0 ${clockwise} ${-curve} ${curveY}`, + `V ${endY - curveY}`, + `a ${curve} ${curve} 0 0 ${clockwise} ${curve} ${curveY}`, + `H ${endX}`, + 'm -5 -5 l 5 5 l -5 5', + ].join(' '); + } + + curve = Math.min(curve, (endX - startX) / 2, Math.abs(endY - startY)); + const curveY = direction * curve; + + return [ + `M ${startX} ${startY}`, + `V ${endY - curveY}`, + `a ${curve} ${curve} 0 0 ${clockwise} ${curve} ${curveY}`, + `H ${endX}`, + '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]; +} 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/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')) ); }); }} 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 ;