diff --git a/apps/dotcom/client/e2e/fixtures/Editor.ts b/apps/dotcom/client/e2e/fixtures/Editor.ts index 96ab2371afda..ea8352c678a9 100644 --- a/apps/dotcom/client/e2e/fixtures/Editor.ts +++ b/apps/dotcom/client/e2e/fixtures/Editor.ts @@ -52,8 +52,8 @@ export class Editor { } @step - async expectShapesCount(expected: number) { - await expect(this.shapes).toHaveCount(expected) + async expectShapesCount(expected: number, timeout?: number) { + await expect(this.shapes).toHaveCount(expected, { timeout }) } async getCurrentFileName() { diff --git a/apps/dotcom/client/e2e/fixtures/Sidebar.ts b/apps/dotcom/client/e2e/fixtures/Sidebar.ts index 94ac6d43c5b7..5435c520af07 100644 --- a/apps/dotcom/client/e2e/fixtures/Sidebar.ts +++ b/apps/dotcom/client/e2e/fixtures/Sidebar.ts @@ -1,6 +1,15 @@ import type { Locator, Page } from '@playwright/test' +import { MAX_WORKSPACE_NAME_LENGTH } from '@tldraw/dotcom-shared' import { expect, step } from './tla-test' +// The createWorkspace/updateWorkspace mutators clamp names to MAX_WORKSPACE_NAME_LENGTH, so the +// stored (and therefore displayed and synced) name diverges from a longer requested name. Scenario +// ids alone run close to that limit, so workspace helpers must match against the clamped form or +// they search for a name the app never renders. +function clampWorkspaceName(name: string) { + return name.trim().slice(0, MAX_WORKSPACE_NAME_LENGTH) +} + // Cross-client Zero sync (accepting an invite, being removed, a workspace being deleted) delivers // workspace-membership rows asynchronously and with variable latency on a busy CI machine. This is // the budget for the data-layer wait that actually gates on that sync; it is intentionally generous @@ -311,6 +320,7 @@ export class Sidebar { @step async createWorkspace(name: string) { + name = clampWorkspaceName(name) // The standalone button is only shown while the user has no workspaces; // after that, creating happens from the workspace switcher dropdown. if (await this.createWorkspaceButton.isVisible()) { @@ -339,7 +349,9 @@ export class Sidebar { } getWorkspaceLink(name: string) { - return this.page.locator('[data-element="workspace-link"]').filter({ hasText: name }) + return this.page + .locator('[data-element="workspace-link"]') + .filter({ hasText: clampWorkspaceName(name) }) } async getHomeWorkspaceName() { @@ -354,6 +366,7 @@ export class Sidebar { @step async expectWorkspaceVisible(name: string) { + name = clampWorkspaceName(name) // Gate on cross-client sync at the data layer first — immune to dropdown churn — then assert // the switcher renders the workspace. Re-open and re-check together: the membership can be // fully synced (the member may even be active in the workspace) yet the assertion still fails @@ -368,6 +381,7 @@ export class Sidebar { @step async expectWorkspaceNotVisible(name: string) { + name = clampWorkspaceName(name) // Workspace removal (member removed, workspace deleted) reaches an active member via // cross-client sync too. Wait for the membership to leave the data layer first, then confirm // the switcher no longer lists it. Keep the menu open while checking (Home is always present) @@ -387,6 +401,7 @@ export class Sidebar { * without depending on the dropdown being open at the moment the row arrives. */ private async waitForWorkspaceMembershipSync(name: string, present: boolean) { + name = clampWorkspaceName(name) await expect .poll( () => @@ -401,6 +416,7 @@ export class Sidebar { @step async expectActiveWorkspace(name: string) { + name = clampWorkspaceName(name) // Switching workspaces navigates to (or creates) a file in the target before the active name // updates, so allow the suite's cross-client budget rather than the default 5s. await expect(this.page.getByTestId('tla-active-workspace-name')).toHaveText(name, { @@ -415,6 +431,7 @@ export class Sidebar { @step async switchToWorkspace(name: string) { + name = clampWorkspaceName(name) // Re-open and click together: a settling re-render can dismiss the menu between opening it and // clicking the workspace, leaving the click waiting on an item that no longer exists. await expect(async () => { @@ -436,6 +453,7 @@ export class Sidebar { @step async openWorkspaceSettings(name: string) { + name = clampWorkspaceName(name) // The settings action lives in the active workspace's action rows, so // switch to the workspace first if needed. const activeName = await this.page.getByTestId('tla-active-workspace-name').innerText() @@ -447,6 +465,7 @@ export class Sidebar { @step async renameWorkspace(oldName: string, newName: string) { + newName = clampWorkspaceName(newName) await this.openWorkspaceSettings(oldName) // Find the name input and change it (use placeholder as user sees it) @@ -463,26 +482,26 @@ export class Sidebar { async deleteWorkspace(name: string) { await this.openWorkspaceSettings(name) - // Click the Delete workspace button (exact text as user sees it) - await this.page.getByRole('button', { name: 'Delete workspace…' }).click() + // Delete lives on the Settings tab of the Manage workspace dialog. + await this.page.getByRole('tab', { name: 'Settings' }).click() + await this.page.getByRole('button', { name: 'Delete workspace', exact: true }).click() - // Confirm deletion in the confirmation dialog - await this.page.getByRole('button', { name: 'Delete workspace' }).click() + // Confirm in the confirmation dialog, whose button is just "Delete". + await this.page.getByRole('button', { name: 'Delete', exact: true }).click() await this.mutationResolution() } @step async copyWorkspaceInviteLink(name: string): Promise { - // The invite link now lives in the workspace settings dialog rather than a - // dedicated sidebar action, so open settings and read it from there. + // The invite link lives in the Manage workspace dialog and is only exposed via + // the Copy button (no visible URL field), so copy it and read it back from the + // clipboard. The invite secret can load asynchronously, so poll until valid. await this.openWorkspaceSettings(name) - const dialog = this.page.getByRole('dialog', { name: 'Workspace settings' }) - const inviteInput = dialog.locator('input[readonly]').first() - // The invite secret can load asynchronously, so poll until the input holds a valid URL. + const dialog = this.page.getByRole('dialog', { name: 'Manage workspace' }) let inviteUrl = '' await expect(async () => { - inviteUrl = await inviteInput.inputValue() - expect(new URL(inviteUrl).pathname).toMatch(/^\/invite\//) + await dialog.getByRole('button', { name: 'Copy invite link' }).click() + inviteUrl = await this.readClipboardUrl(/^\/invite\//) }).toPass({ timeout: 10000 }) await this.page.getByRole('button', { name: 'Close' }).click() return inviteUrl @@ -519,36 +538,6 @@ export class Sidebar { await button.click() } - @step - async dragFileToPinnedSection(fileName: string) { - const fileElement = this.getFileByName(fileName) - const fileBox = await fileElement.boundingBox() - const topFile = this.sidebar.locator('[data-drop-target-id^="file:"]').first() - const topBox = await topFile.boundingBox() - - if (!fileBox || !topBox) throw new Error('Could not get bounding boxes') - - // Move to file - await this.page.mouse.move(fileBox.x + fileBox.width / 2, fileBox.y + fileBox.height / 2) - - // Press and hold - await this.page.mouse.down() - - // Small delay to let browser detect drag intent - await this.page.waitForTimeout(100) - - // Drop just above the top of the file list, inside the pin zone - await this.page.mouse.move(topBox.x + topBox.width / 2, topBox.y - 5, { steps: 10 }) - - // Small delay before release - await this.page.waitForTimeout(50) - - // Release - await this.page.mouse.up() - - await this.mutationResolution() - } - @step async dragFileToUnpinnedSection(fileName: string) { const fileElement = this.getFileByName(fileName) @@ -664,6 +653,7 @@ export class Sidebar { @step async moveFileToWorkspace(fileName: string, targetWorkspaceName: string) { + targetWorkspaceName = clampWorkspaceName(targetWorkspaceName) // The move-to menu is a checklist: each destination is a checkbox item, and the // destination we're moving to is never the current (checked) one, so its accessible // name is just the workspace name (an unchecked item adds no "checked" prefix). diff --git a/apps/dotcom/client/e2e/fixtures/scenario-test.ts b/apps/dotcom/client/e2e/fixtures/scenario-test.ts index 7b1ff6b598ac..67e848324619 100644 --- a/apps/dotcom/client/e2e/fixtures/scenario-test.ts +++ b/apps/dotcom/client/e2e/fixtures/scenario-test.ts @@ -1,7 +1,9 @@ +import { createHash } from 'crypto' import fs from 'fs' import { setupClerkTestingToken } from '@clerk/testing/playwright' import { expect, test as base } from '@playwright/test' -import type { Browser, BrowserContext, Download, Page, TestInfo } from '@playwright/test' +import type { Browser, BrowserContext, Download, Locator, Page, TestInfo } from '@playwright/test' +import { MAX_WORKSPACE_NAME_LENGTH } from '@tldraw/dotcom-shared' import { NUMBER_OF_USERS } from '../consts' import { Database, getTestUserEmail } from './Database' import { DeleteFileDialog } from './DeleteFileDialog' @@ -22,6 +24,17 @@ type WorkspaceMemberRole = 'owner' | 'member' const SCENARIO_USER_POOL_START = 4 const ROOT_URL = 'http://localhost:3000' +const MENU_INTERACTION_TIMEOUT = 5_000 + +export async function selectTlaMenuOption(page: Page, select: Locator, optionLabel: string) { + await select.click({ timeout: MENU_INTERACTION_TIMEOUT }) + const openListbox = page.locator('[role="listbox"][data-state="open"]') + await expect(openListbox).toBeVisible({ timeout: MENU_INTERACTION_TIMEOUT }) + await openListbox + .getByRole('option', { name: optionLabel, exact: true }) + .click({ timeout: MENU_INTERACTION_TIMEOUT }) + await expect(openListbox).not.toBeVisible({ timeout: MENU_INTERACTION_TIMEOUT }) +} interface SignedInActorAccount { email: string @@ -359,8 +372,7 @@ class DotcomScenario { const select = actor.page.getByTestId('shared-link-type-select') const expectedLabel = linkType === 'edit' ? 'Editor' : 'Viewer' if ((await select.innerText()) !== expectedLabel) { - await select.click() - await actor.page.getByRole('option', { name: expectedLabel }).click() + await selectTlaMenuOption(actor.page, select, expectedLabel) } await expect(select).toHaveText(expectedLabel) await actor.waitForMutationResolution() @@ -488,7 +500,12 @@ class DotcomScenario { workspaceName?: string fileName?: string }) { - const workspaceName = opts.workspaceName ?? this.name('workspace') + // The createWorkspace mutator clamps names to MAX_WORKSPACE_NAME_LENGTH, so mirror that here: + // otherwise a long scenario id makes the stored (and displayed) name diverge from what the + // sidebar helpers search for, and the workspace link never matches. + const workspaceName = (opts.workspaceName ?? this.name('workspace')) + .trim() + .slice(0, MAX_WORKSPACE_NAME_LENGTH) const fileName = opts.fileName ?? this.name('workspace file') await this.ensureGroupsReady(opts.owner) @@ -537,9 +554,13 @@ class DotcomScenario { memberUserId: string }) { await opts.owner.sidebar.openWorkspaceSettings(opts.workspaceName) - await opts.owner.page.locator(`[id="workspace-member-role-${opts.memberUserId}"]`).click() - await opts.owner.page.getByRole('option', { name: 'Remove' }).click() - await opts.owner.page.getByRole('button', { name: 'Remove member' }).click() + await selectTlaMenuOption( + opts.owner.page, + opts.owner.page.locator(`[id="workspace-member-role-${opts.memberUserId}"]`), + 'Remove' + ) + // The remove confirmation dialog's button is just "Remove". + await opts.owner.page.getByRole('button', { name: 'Remove', exact: true }).click() await opts.owner.waitForMutationResolution() await opts.owner.page.keyboard.press('Escape') } @@ -555,8 +576,7 @@ class DotcomScenario { `[id="workspace-member-role-${opts.memberUserId}"]` ) const roleLabel = opts.role === 'owner' ? 'Owner' : 'Member' - await memberRoleSelect.click() - await opts.owner.page.getByRole('option', { name: roleLabel }).click() + await selectTlaMenuOption(opts.owner.page, memberRoleSelect, roleLabel) await expect(memberRoleSelect).toHaveText(roleLabel) await opts.owner.waitForMutationResolution() await opts.owner.page.keyboard.press('Escape') @@ -668,13 +688,20 @@ async function ensureStorageState( } function getScenarioId(testInfo: TestInfo) { - const slug = testInfo.titlePath + const fullSlug = testInfo.titlePath .slice(1) .join(' ') .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') - .slice(0, 64) + + // Keep the id compact: workspace names are capped at MAX_WORKSPACE_NAME_LENGTH, and a name is + // `${id} ${label}`, so a long id leaves no room for a distinguishing label — two labels that + // differ only past the cap would collide once the mutator clamps the stored name. A short + // readable prefix keeps debugging tractable; a hash of the full title preserves the cross-test + // uniqueness that a truncated prefix alone would lose. + const titleHash = createHash('sha1').update(fullSlug).digest('hex').slice(0, 6) + const slug = `${fullSlug.slice(0, 24).replace(/-+$/g, '')}-${titleHash}` const runId = process.env.GITHUB_RUN_ID ?? Date.now().toString(36) return `e2e-${runId}-w${testInfo.parallelIndex}-x${testInfo.repeatEachIndex}-r${testInfo.retry}-${slug}` diff --git a/apps/dotcom/client/e2e/tests/sharing-live.scenario.spec.ts b/apps/dotcom/client/e2e/tests/sharing-live.scenario.spec.ts index 6f98313c1b31..85d3120cd473 100644 --- a/apps/dotcom/client/e2e/tests/sharing-live.scenario.spec.ts +++ b/apps/dotcom/client/e2e/tests/sharing-live.scenario.spec.ts @@ -213,6 +213,9 @@ test.describe('live sharing scenarios', () => { await member.sidebar.expectFileVisible(fileName) await member.sidebar.openWorkspaceSettings(workspaceName) + // Delete lives on the Settings tab and only for owners; promoting the member should + // surface it there reactively, without a reload. + await member.page.getByRole('tab', { name: 'Settings' }).click() const deleteWorkspaceButton = member.page.getByRole('button', { name: /Delete workspace/ }) await expect(deleteWorkspaceButton).not.toBeVisible() diff --git a/apps/dotcom/client/e2e/tests/smoke/workspaces.spec.ts b/apps/dotcom/client/e2e/tests/smoke/workspaces.spec.ts index 473ca9a8cf67..2f99a5f96c3a 100644 --- a/apps/dotcom/client/e2e/tests/smoke/workspaces.spec.ts +++ b/apps/dotcom/client/e2e/tests/smoke/workspaces.spec.ts @@ -257,25 +257,21 @@ test.describe('workspaces', () => { expect(fileOrder[0]).toBe(file1) }) - test('drag to unpin a file in a workspace', async ({ sidebar }) => { + test('dragging a pinned file does not unpin it', async ({ sidebar }) => { const workspaceName = getRandomName() const file1 = getRandomName() await sidebar.createWorkspace(workspaceName) await sidebar.createNewDocument(file1) - // Dragging an unpinned file to the pinned section no longer pins it. - await sidebar.expectFileNotPinned(file1) - await sidebar.dragFileToPinnedSection(file1) - await sidebar.expectFileNotPinned(file1) - - // Pinning happens through the file menu, but a pinned file can still - // be unpinned by dragging it out of the pinned section. + // Pinning happens through the file menu. await sidebar.pinFile(file1) await sidebar.expectFilePinned(file1) + // Dragging a file is now just native link dragging, so dragging a + // pinned file out of the pinned section no longer unpins it. await sidebar.dragFileToUnpinnedSection(file1) - await sidebar.expectFileNotPinned(file1) + await sidebar.expectFilePinned(file1) }) test('deleting the active file in a workspace stays in the workspace', async ({ diff --git a/apps/dotcom/client/e2e/tests/ui.scenario.spec.ts b/apps/dotcom/client/e2e/tests/ui.scenario.spec.ts index 92b26c3cfb47..9b1e8b20bf9e 100644 --- a/apps/dotcom/client/e2e/tests/ui.scenario.spec.ts +++ b/apps/dotcom/client/e2e/tests/ui.scenario.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from '../fixtures/scenario-test' +import { expect, selectTlaMenuOption, test } from '../fixtures/scenario-test' const ROOT_URL = 'http://localhost:3000' @@ -73,7 +73,9 @@ test.describe('UI scenarios', () => { await owner.sidebar.expectFileVisible(duplicateName) await owner.sidebar.expectFileActive(duplicateName) expect(await owner.editor.getCurrentFileName()).toBe(duplicateName) - await owner.editor.expectShapesCount(1) + // Duplicating copies the room server-side, so the new file's shapes only render once the + // editor navigates and loads the copied content. Allow more than the default 5s for that. + await owner.editor.expectShapesCount(1, 15000) await owner.sidebar.deleteFileByName(duplicateName) await owner.deleteFileDialog.expectIsVisible() @@ -231,54 +233,65 @@ test.describe('UI scenarios', () => { }) await owner.sidebar.openWorkspaceSettings(workspaceName) - const ownerDialog = owner.page.getByRole('dialog', { name: 'Workspace settings' }) + const ownerDialog = owner.page.getByRole('dialog', { name: 'Manage workspace' }) // Owners see the full dialog surface and member roster. await expect(ownerDialog).toBeVisible() await expect(ownerDialog.getByPlaceholder('Workspace name')).toBeVisible() - await expect(ownerDialog.getByText('Invite members')).toBeVisible() + await expect(ownerDialog.getByText('Invite teammates')).toBeVisible() await expect(ownerDialog.getByRole('button', { name: 'Copy invite link' })).toBeVisible() - await expect(ownerDialog.getByText('Members', { exact: true })).toBeVisible() + await expect(ownerDialog.getByText(/Members \(\d+\)/)).toBeVisible() await expect(ownerDialog.getByText(/\(you\)/)).toBeVisible() - await expect(ownerDialog.locator(`[id="workspace-member-role-${memberUserId}"]`)).toHaveText( - 'Member' - ) + const memberRoleSelect = ownerDialog.locator(`[id="workspace-member-role-${memberUserId}"]`) + await expect(memberRoleSelect).toHaveText('Member') - // Copying and regenerating the invite link update clipboard-visible state. - const inviteInput = ownerDialog.locator('input[readonly]').first() - const firstInviteUrl = await inviteInput.inputValue() - expect(new URL(firstInviteUrl).pathname).toMatch(/^\/invite\//) + // Interacting with the portalled role select should not count as a background click. + await selectTlaMenuOption(owner.page, memberRoleSelect, 'Member') + await expect(ownerDialog).toBeVisible() + // The dialog exposes the invite link only through the Copy button (no visible URL + // field), so read it from the clipboard. Regenerating from the Settings tab + // replaces the link, so a later copy returns a different URL. await ownerDialog.getByRole('button', { name: 'Copy invite link' }).click() - await expect - .poll(() => owner.page.evaluate(() => navigator.clipboard.readText())) - .toBe(firstInviteUrl) + const firstInviteUrl = await owner.page.evaluate(() => navigator.clipboard.readText()) + expect(new URL(firstInviteUrl).pathname).toMatch(/^\/invite\//) + await ownerDialog.getByRole('tab', { name: 'Settings' }).click() await ownerDialog.getByRole('button', { name: 'Regenerate invite link' }).click() - await expect.poll(() => inviteInput.inputValue()).not.toBe(firstInviteUrl) - const regeneratedInviteUrl = await inviteInput.inputValue() - expect(new URL(regeneratedInviteUrl).pathname).toMatch(/^\/invite\//) + await owner.page.getByRole('button', { name: 'Regenerate', exact: true }).click() + await owner.waitForMutationResolution() - await owner.page.waitForTimeout(1100) - await ownerDialog.getByRole('button', { name: 'Copy invite link' }).click() + // Copy again (after the 1s copy-button guard) and poll until the new link lands. await expect - .poll(() => owner.page.evaluate(() => navigator.clipboard.readText())) - .toBe(regeneratedInviteUrl) + .poll( + async () => { + await owner.page.waitForTimeout(1100) + await ownerDialog.getByRole('button', { name: 'Copy invite link' }).click() + return owner.page.evaluate(() => navigator.clipboard.readText()) + }, + { timeout: 15000 } + ) + .not.toBe(firstInviteUrl) + const regeneratedInviteUrl = await owner.page.evaluate(() => navigator.clipboard.readText()) + expect(new URL(regeneratedInviteUrl).pathname).toMatch(/^\/invite\//) await owner.page.keyboard.press('Escape') // Non-owners can inspect settings but cannot access owner-only controls. await member.sidebar.openWorkspaceSettings(workspaceName) - const memberDialog = member.page.getByRole('dialog', { name: 'Workspace settings' }) + const memberDialog = member.page.getByRole('dialog', { name: 'Manage workspace' }) await expect(memberDialog.getByPlaceholder('Workspace name')).toBeDisabled() await expect( memberDialog.locator(`[id="workspace-member-role-${memberUserId}"]`) ).not.toBeVisible() + + // Leave/Delete live on the Settings tab; members get Leave but not Delete. + await memberDialog.getByRole('tab', { name: 'Settings' }).click() await expect(memberDialog.getByRole('button', { name: /Delete workspace/ })).not.toBeVisible() await expect(memberDialog.getByRole('button', { name: /Leave workspace/ })).toBeVisible() - // Leaving requires confirmation and removes the member's workspace access. + // Leaving requires confirmation (the confirm button is just "Leave") and removes access. await memberDialog.getByRole('button', { name: /Leave workspace/ }).click() - await member.page.getByRole('button', { name: 'Leave workspace' }).click() + await member.page.getByRole('button', { name: 'Leave', exact: true }).click() await member.waitForMutationResolution() await member.sidebar.expectWorkspaceNotVisible(workspaceName) await member.sidebar.expectFileNotVisible(fileName) diff --git a/apps/dotcom/client/public/tla/locales-compiled/en.json b/apps/dotcom/client/public/tla/locales-compiled/en.json index fc114087c2d8..e5a607953839 100644 --- a/apps/dotcom/client/public/tla/locales-compiled/en.json +++ b/apps/dotcom/client/public/tla/locales-compiled/en.json @@ -21,6 +21,12 @@ "value": "GetHelpLink" } ], + "04a2dd56d6": [ + { + "type": 0, + "value": "Invite teammates" + } + ], "05f43078e9": [ { "type": 0, @@ -129,12 +135,6 @@ "value": "Create file" } ], - "1d2591253e": [ - { - "type": 0, - "value": "Workspace settings" - } - ], "1da2e8106b": [ { "type": 0, @@ -147,6 +147,12 @@ "value": "Today" } ], + "1ffd709937": [ + { + "type": 0, + "value": "A workspace must keep at least one owner. Make someone else an owner first." + } + ], "21f66c9c00": [ { "type": 0, @@ -177,12 +183,24 @@ "value": "Search..." } ], + "2de510be71": [ + { + "type": 0, + "value": "Regenerating replaces the current invite link with a new one. Anyone using the old link will need the new one to join." + } + ], "2e2b3c700b": [ { "type": 0, "value": "Include link to current file" } ], + "2e9856a31e": [ + { + "type": 0, + "value": "Regenerate invite link" + } + ], "31246941ad": [ { "type": 0, @@ -261,12 +279,6 @@ "value": "Contact the owner to request access." } ], - "44680c1fa7": [ - { - "type": 0, - "value": "Leave workspace…" - } - ], "44db318ee5": [ { "type": 0, @@ -333,12 +345,6 @@ "value": "Unable to unpublish the file." } ], - "51317dcad5": [ - { - "type": 0, - "value": "This is your private workspace. Create a new workspace to invite teammates." - } - ], "517bb809d9": [ { "type": 0, @@ -409,12 +415,6 @@ "value": "My workspace" } ], - "5c6fe42bc2": [ - { - "type": 0, - "value": "Revoke this link and create a new one." - } - ], "5d04a002ea": [ { "type": 0, @@ -595,6 +595,12 @@ "value": "Got it" } ], + "78faf88f4d": [ + { + "type": 0, + "value": "Enable invite link" + } + ], "797799f35e": [ { "type": 0, @@ -775,12 +781,6 @@ "value": "Sign in with Google" } ], - "95b56f4cd6": [ - { - "type": 0, - "value": "Delete workspace…" - } - ], "95d2109dc8": [ { "type": 0, @@ -903,6 +903,12 @@ "value": "Submit" } ], + "a52945dbe2": [ + { + "type": 0, + "value": "Leave" + } + ], "a6cc403d56": [ { "type": 0, @@ -973,6 +979,12 @@ "value": "Guest user" } ], + "b2326c3a34": [ + { + "type": 0, + "value": "Regenerate" + } + ], "b2c3c2e30c": [ { "type": 0, @@ -1003,12 +1015,32 @@ "value": "Copy link" } ], + "b9a68fbd56": [ + { + "type": 0, + "value": "Members (" + }, + { + "type": 1, + "value": "count" + }, + { + "type": 0, + "value": ")" + } + ], "ba0318cb81": [ { "type": 0, "value": "Sign in to share" } ], + "bbaaf8b6bf": [ + { + "type": 0, + "value": "Invites are disabled" + } + ], "bbbcefd7ce": [ { "offset": 0, @@ -1061,6 +1093,12 @@ "value": "ownerName" } ], + "c17466812d": [ + { + "type": 0, + "value": "Manage workspace" + } + ], "c2276c9127": [ { "type": 0, @@ -1129,12 +1167,6 @@ "value": "Dismiss" } ], - "c9cc8cce24": [ - { - "type": 0, - "value": "Save" - } - ], "cceaefbc20": [ { "type": 0, @@ -1259,12 +1291,6 @@ "value": "OK" } ], - "e0c88179b3": [ - { - "type": 0, - "value": "Invite members" - } - ], "e2198d6228": [ { "type": 0, @@ -1301,12 +1327,6 @@ "value": "You have been invited to join workspace:" } ], - "ea0626ac5a": [ - { - "type": 0, - "value": "Danger zone" - } - ], "ea4788705e": [ { "type": 0, @@ -1369,12 +1389,6 @@ "value": " to learn more." } ], - "ef53538ae4": [ - { - "type": 0, - "value": "Members" - } - ], "ef7de3f485": [ { "type": 0, diff --git a/apps/dotcom/client/public/tla/locales/en.json b/apps/dotcom/client/public/tla/locales/en.json index 91a97ae1f4c7..db431d5cf607 100644 --- a/apps/dotcom/client/public/tla/locales/en.json +++ b/apps/dotcom/client/public/tla/locales/en.json @@ -8,6 +8,9 @@ "043973ec1f": { "translation": "Still having trouble? {GetHelpLink}" }, + "04a2dd56d6": { + "translation": "Invite teammates" + }, "05f43078e9": { "translation": "You have reached the maximum number of workspaces. You need to delete old workspaces before creating new ones." }, @@ -62,15 +65,15 @@ "181b90f889": { "translation": "Create file" }, - "1d2591253e": { - "translation": "Workspace settings" - }, "1da2e8106b": { "translation": "An unexpected error occurred." }, "1dd1c5fb7f": { "translation": "Today" }, + "1ffd709937": { + "translation": "A workspace must keep at least one owner. Make someone else an owner first." + }, "21f66c9c00": { "translation": "Workspace name" }, @@ -86,9 +89,15 @@ "2a37324bb4": { "translation": "Search..." }, + "2de510be71": { + "translation": "Regenerating replaces the current invite link with a new one. Anyone using the old link will need the new one to join." + }, "2e2b3c700b": { "translation": "Include link to current file" }, + "2e9856a31e": { + "translation": "Regenerate invite link" + }, "31246941ad": { "translation": "You need to sign in to view this file." }, @@ -128,9 +137,6 @@ "42e53c47c1": { "translation": "Contact the owner to request access." }, - "44680c1fa7": { - "translation": "Leave workspace…" - }, "44db318ee5": { "translation": "Get help on Discord" }, @@ -164,9 +170,6 @@ "50db1b7e1e": { "translation": "Unable to unpublish the file." }, - "51317dcad5": { - "translation": "This is your private workspace. Create a new workspace to invite teammates." - }, "517bb809d9": { "translation": "Page menu" }, @@ -197,9 +200,6 @@ "5bff3c9c88": { "translation": "My workspace" }, - "5c6fe42bc2": { - "translation": "Revoke this link and create a new one." - }, "5d04a002ea": { "translation": "Unable to connect" }, @@ -290,6 +290,9 @@ "78e9815992": { "translation": "Got it" }, + "78faf88f4d": { + "translation": "Enable invite link" + }, "797799f35e": { "translation": "Submit an issue on GitHub" }, @@ -362,9 +365,6 @@ "95aacd4d4a": { "translation": "Sign in with Google" }, - "95b56f4cd6": { - "translation": "Delete workspace…" - }, "95d2109dc8": { "translation": "Build with the tldraw SDK" }, @@ -419,6 +419,9 @@ "a4d3b161ce": { "translation": "Submit" }, + "a52945dbe2": { + "translation": "Leave" + }, "a6cc403d56": { "translation": "Anyone with the link" }, @@ -447,6 +450,9 @@ "b028909917": { "translation": "Guest user" }, + "b2326c3a34": { + "translation": "Regenerate" + }, "b2c3c2e30c": { "translation": "File is full" }, @@ -462,15 +468,24 @@ "b758336699": { "translation": "Copy link" }, + "b9a68fbd56": { + "translation": "Members ({count})" + }, "ba0318cb81": { "translation": "Sign in to share" }, + "bbaaf8b6bf": { + "translation": "Invites are disabled" + }, "bbbcefd7ce": { "translation": "{total, plural, one {Uploading .tldr file…} other {Uploading {uploaded} of {total} .tldr files…}}" }, "bc88b0e89e": { "translation": "Shared by {ownerName}" }, + "c17466812d": { + "translation": "Manage workspace" + }, "c2276c9127": { "translation": "Learn more about sharing." }, @@ -498,9 +513,6 @@ "c8a59e7135": { "translation": "Dismiss" }, - "c9cc8cce24": { - "translation": "Save" - }, "cceaefbc20": { "translation": "Share this file" }, @@ -549,9 +561,6 @@ "e0aa021e21": { "translation": "OK" }, - "e0c88179b3": { - "translation": "Invite members" - }, "e2198d6228": { "translation": "Copy invite link" }, @@ -570,9 +579,6 @@ "e9e0f16850": { "translation": "You have been invited to join workspace:" }, - "ea0626ac5a": { - "translation": "Danger zone" - }, "ea4788705e": { "translation": "Cancel" }, @@ -597,9 +603,6 @@ "ede55d955f": { "translation": "Read our cookie policy to learn more." }, - "ef53538ae4": { - "translation": "Members" - }, "ef7de3f485": { "translation": "selected" }, diff --git a/apps/dotcom/client/src/tla/app/TldrawApp.ts b/apps/dotcom/client/src/tla/app/TldrawApp.ts index 22c8913e2d33..ad20e6463c38 100644 --- a/apps/dotcom/client/src/tla/app/TldrawApp.ts +++ b/apps/dotcom/client/src/tla/app/TldrawApp.ts @@ -4,7 +4,6 @@ import { AcceptInviteResponseBody, CreateFilesResponseBody, CreateSnapshotRequestBody, - DragFileOperation, FILE_PREFIX, LOCAL_FILE_PREFIX, MAX_NUMBER_OF_FILES, @@ -80,13 +79,6 @@ import { createIntl, defineMessages, setupCreateIntl } from '../utils/i18n' import { updateLocalSessionState } from '../utils/local-session-state' import { Zero as ZeroPolyfill } from './zero-polyfill' -type DragState = null | { - type: 'file' - id: string - operation: DragFileOperation - hasDragStarted: boolean -} - export const TLDR_FILE_ENDPOINT = `/api/app/tldr` export const PUBLISH_ENDPOINT = `/api/app/publish` @@ -1201,7 +1193,6 @@ export class TldrawApp { fileId: string workspaceId: string }, - dragState: null as DragState, // The current sidebar file-search query. Empty string means no filter. searchQuery: '', }) diff --git a/apps/dotcom/client/src/tla/components/TlaEditor/TlaEditor.tsx b/apps/dotcom/client/src/tla/components/TlaEditor/TlaEditor.tsx index e96bde0753ff..ad62db5698b3 100644 --- a/apps/dotcom/client/src/tla/components/TlaEditor/TlaEditor.tsx +++ b/apps/dotcom/client/src/tla/components/TlaEditor/TlaEditor.tsx @@ -76,12 +76,43 @@ interface TlaEditorProps { deepLinks?: boolean } +// Components for the inert placeholder editor shown while the real file syncs. +// Same UI as the real editor (toolbar, menus, panels) so the chrome appears +// instantly, plus a nulled loading screen to avoid a flash of its own spinner +// before the (instant) empty local store finishes loading. +const placeholderComponents: TLComponents = { ...components, LoadingScreen: null } + +/** + * An empty, non-interactive tldraw editor shown immediately while the real file + * connects and syncs. It renders the full editor chrome (toolbar, menus, panels) + * over its own ephemeral empty store, so the UI pops in instantly and only the + * document content fills in once synced. It never autofocuses, and the + * ReadyWrapper overlay disables pointer events so nothing here is interactive. + */ +function TlaEditorLoadingPlaceholder() { + const app = useMaybeApp() + return ( + + + + ) +} + export function TlaEditor(props: TlaEditorProps) { // force re-mount when the file slug changes to prevent state from leaking between files return ( <> - + }> diff --git a/apps/dotcom/client/src/tla/components/TlaFileMenu/TlaFileMenu.tsx b/apps/dotcom/client/src/tla/components/TlaFileMenu/TlaFileMenu.tsx index e176f7b4ef48..02806fbbfe70 100644 --- a/apps/dotcom/client/src/tla/components/TlaFileMenu/TlaFileMenu.tsx +++ b/apps/dotcom/client/src/tla/components/TlaFileMenu/TlaFileMenu.tsx @@ -83,7 +83,7 @@ export function TlaFileMenu({ {trigger} - + {children ?? fileItemsWhenNoChildren} diff --git a/apps/dotcom/client/src/tla/components/TlaSidebar/TlaSidebar.tsx b/apps/dotcom/client/src/tla/components/TlaSidebar/TlaSidebar.tsx index ebf95dfb2c2d..3ec3514c18ba 100644 --- a/apps/dotcom/client/src/tla/components/TlaSidebar/TlaSidebar.tsx +++ b/apps/dotcom/client/src/tla/components/TlaSidebar/TlaSidebar.tsx @@ -1,4 +1,5 @@ import { memo, useCallback, useEffect } from 'react' +import { useActiveWorkspaceId } from '../../hooks/useActiveWorkspaceId' import { useHasFlag } from '../../hooks/useHasFlag' import { useTldrFileDrop } from '../../hooks/useTldrFileDrop' import { useTldrawAppUiEvents } from '../../utils/app-ui-events' @@ -15,6 +16,7 @@ import { TlaSidebarFeedbackButton } from './components/TlaSidebarFeedbackButton' import { TlaSidebarRecentFiles } from './components/TlaSidebarRecentFiles' import { TlaSidebarRecentFilesNew } from './components/TlaSidebarRecentFilesNew' import { TlaUserSettingsMenu } from './components/TlaSidebarUserSettingsMenu' +import { TlaSidebarWorkspaceActions } from './components/TlaSidebarWorkspaceActions' import { TlaSidebarWorkspaceLink } from './components/TlaSidebarWorkspaceLink' import { TlaSidebarWorkspaceSwitcher } from './components/TlaSidebarWorkspaceSwitcher' import styles from './sidebar.module.css' @@ -47,6 +49,7 @@ export const TlaSidebar = memo(function TlaSidebar() { const { onDrop, onDragOver, onDragEnter, onDragLeave } = useTldrFileDrop() const workspacesEnabled = useHasFlag('groups_frontend') + const activeWorkspaceId = useActiveWorkspaceId() return (