Skip to content

Commit abfa9f5

Browse files
authored
feat(dotcom): rework the manage workspace dialog (tldraw#9168)
In order to give workspace owners a clearer place to manage their workspace, this PR reworks the "Workspace settings" dialog into a tabbed "Manage workspace" dialog and adds an enable/disable toggle for the invite link. Follow-up to tldraw#9106; closes tldraw#9131. The dialog now has a shared header (name + invite link) above two tabs: - **Members** — each member shows a role dropdown; owners can change roles or remove members. Your own row's action is **Leave**; it (and removing the last owner) is disabled with a tooltip when it would leave the workspace without an owner. - **Settings** (owners only) — enable/disable the invite link, regenerate it, leave, or delete the workspace. Other changes: - **Capabilities** — the per-action capabilities (`manageInvites`, `editMembers`, `editWorkspace`, `deleteWorkspace`) are consolidated into a single owner-only `manageWorkspace`. Members can no longer manage the invite link, roles, name, or deletion. - **Invite link toggle** — like file sharing, turning the link off *keeps* it (so it can be turned back on) rather than clearing it. Backed by a `group.inviteLinkEnabled` column (migration 035), a mutator, and gating on the invite-accept path; the user durable-object state version is bumped so clients refetch. - **SDK dialog fix** — `TldrawUiDialog` now closes on a backdrop click only when the press both starts and ends on the overlay, and no longer auto-dismisses when a portaled popover opened inside it (e.g. a Select) is used. Without this, opening/dismissing the role dropdown tore down the whole dialog. https://github.com/user-attachments/assets/a9571f8d-546f-42b6-add7-eee1cfdca956 ### Change type - [x] `feature` ### Test plan 1. As an **owner**, open Manage workspace → **Members**: every member has a role dropdown; your own row's dropdown has "Leave" (disabled with a tooltip if you're the only owner), other members have "Remove". 2. **Settings** tab: toggle "Enable invite link" off → the copy button becomes a disabled "Invites are disabled"; toggle on → the same link returns. Regenerate / Leave / Delete each open a confirm dialog (descriptive title, short action button). 3. As a **member**: roles are read-only, your own row offers "Leave", and there's no settings management. 4. Open a role dropdown, then click elsewhere in the dialog or on the backdrop: the dropdown closes; the dialog only closes on a deliberate backdrop click (and Escape). - [x] Unit tests - [ ] End to end tests ### Release notes - Fix a dialog unexpectedly closing when a popover (e.g. a select) opened inside it is dismissed. ### Code changes | Section | LOC change | | --------------- | ----------- | | Core code | +64 / -39 | | Tests | +90 / -15 | | Automated files | +102 / -75 | | Apps | +373 / -302 |
1 parent 81ec91f commit abfa9f5

19 files changed

Lines changed: 620 additions & 457 deletions

File tree

apps/dotcom/client/e2e/fixtures/Sidebar.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -482,26 +482,26 @@ export class Sidebar {
482482
async deleteWorkspace(name: string) {
483483
await this.openWorkspaceSettings(name)
484484

485-
// Click the Delete workspace button (exact text as user sees it)
486-
await this.page.getByRole('button', { name: 'Delete workspace…' }).click()
485+
// Delete lives on the Settings tab of the Manage workspace dialog.
486+
await this.page.getByRole('tab', { name: 'Settings' }).click()
487+
await this.page.getByRole('button', { name: 'Delete workspace', exact: true }).click()
487488

488-
// Confirm deletion in the confirmation dialog
489-
await this.page.getByRole('button', { name: 'Delete workspace' }).click()
489+
// Confirm in the confirmation dialog, whose button is just "Delete".
490+
await this.page.getByRole('button', { name: 'Delete', exact: true }).click()
490491
await this.mutationResolution()
491492
}
492493

493494
@step
494495
async copyWorkspaceInviteLink(name: string): Promise<string> {
495-
// The invite link now lives in the workspace settings dialog rather than a
496-
// dedicated sidebar action, so open settings and read it from there.
496+
// The invite link lives in the Manage workspace dialog and is only exposed via
497+
// the Copy button (no visible URL field), so copy it and read it back from the
498+
// clipboard. The invite secret can load asynchronously, so poll until valid.
497499
await this.openWorkspaceSettings(name)
498-
const dialog = this.page.getByRole('dialog', { name: 'Workspace settings' })
499-
const inviteInput = dialog.locator('input[readonly]').first()
500-
// The invite secret can load asynchronously, so poll until the input holds a valid URL.
500+
const dialog = this.page.getByRole('dialog', { name: 'Manage workspace' })
501501
let inviteUrl = ''
502502
await expect(async () => {
503-
inviteUrl = await inviteInput.inputValue()
504-
expect(new URL(inviteUrl).pathname).toMatch(/^\/invite\//)
503+
await dialog.getByRole('button', { name: 'Copy invite link' }).click()
504+
inviteUrl = await this.readClipboardUrl(/^\/invite\//)
505505
}).toPass({ timeout: 10000 })
506506
await this.page.getByRole('button', { name: 'Close' }).click()
507507
return inviteUrl

apps/dotcom/client/e2e/fixtures/scenario-test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -559,7 +559,8 @@ class DotcomScenario {
559559
opts.owner.page.locator(`[id="workspace-member-role-${opts.memberUserId}"]`),
560560
'Remove'
561561
)
562-
await opts.owner.page.getByRole('button', { name: 'Remove member' }).click()
562+
// The remove confirmation dialog's button is just "Remove".
563+
await opts.owner.page.getByRole('button', { name: 'Remove', exact: true }).click()
563564
await opts.owner.waitForMutationResolution()
564565
await opts.owner.page.keyboard.press('Escape')
565566
}

apps/dotcom/client/e2e/tests/sharing-live.scenario.spec.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,9 @@ test.describe('live sharing scenarios', () => {
213213
await member.sidebar.expectFileVisible(fileName)
214214

215215
await member.sidebar.openWorkspaceSettings(workspaceName)
216+
// Delete lives on the Settings tab and only for owners; promoting the member should
217+
// surface it there reactively, without a reload.
218+
await member.page.getByRole('tab', { name: 'Settings' }).click()
216219
const deleteWorkspaceButton = member.page.getByRole('button', { name: /Delete workspace/ })
217220
await expect(deleteWorkspaceButton).not.toBeVisible()
218221

apps/dotcom/client/e2e/tests/ui.scenario.spec.ts

Lines changed: 29 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -233,14 +233,14 @@ test.describe('UI scenarios', () => {
233233
})
234234

235235
await owner.sidebar.openWorkspaceSettings(workspaceName)
236-
const ownerDialog = owner.page.getByRole('dialog', { name: 'Workspace settings' })
236+
const ownerDialog = owner.page.getByRole('dialog', { name: 'Manage workspace' })
237237

238238
// Owners see the full dialog surface and member roster.
239239
await expect(ownerDialog).toBeVisible()
240240
await expect(ownerDialog.getByPlaceholder('Workspace name')).toBeVisible()
241-
await expect(ownerDialog.getByText('Invite members')).toBeVisible()
241+
await expect(ownerDialog.getByText('Invite teammates')).toBeVisible()
242242
await expect(ownerDialog.getByRole('button', { name: 'Copy invite link' })).toBeVisible()
243-
await expect(ownerDialog.getByText('Members', { exact: true })).toBeVisible()
243+
await expect(ownerDialog.getByText(/Members \(\d+\)/)).toBeVisible()
244244
await expect(ownerDialog.getByText(/\(you\)/)).toBeVisible()
245245
const memberRoleSelect = ownerDialog.locator(`[id="workspace-member-role-${memberUserId}"]`)
246246
await expect(memberRoleSelect).toHaveText('Member')
@@ -249,41 +249,49 @@ test.describe('UI scenarios', () => {
249249
await selectTlaMenuOption(owner.page, memberRoleSelect, 'Member')
250250
await expect(ownerDialog).toBeVisible()
251251

252-
// Copying and regenerating the invite link update clipboard-visible state.
253-
const inviteInput = ownerDialog.locator('input[readonly]').first()
254-
const firstInviteUrl = await inviteInput.inputValue()
255-
expect(new URL(firstInviteUrl).pathname).toMatch(/^\/invite\//)
256-
252+
// The dialog exposes the invite link only through the Copy button (no visible URL
253+
// field), so read it from the clipboard. Regenerating from the Settings tab
254+
// replaces the link, so a later copy returns a different URL.
257255
await ownerDialog.getByRole('button', { name: 'Copy invite link' }).click()
258-
await expect
259-
.poll(() => owner.page.evaluate(() => navigator.clipboard.readText()))
260-
.toBe(firstInviteUrl)
256+
const firstInviteUrl = await owner.page.evaluate(() => navigator.clipboard.readText())
257+
expect(new URL(firstInviteUrl).pathname).toMatch(/^\/invite\//)
261258

259+
await ownerDialog.getByRole('tab', { name: 'Settings' }).click()
262260
await ownerDialog.getByRole('button', { name: 'Regenerate invite link' }).click()
263-
await expect.poll(() => inviteInput.inputValue()).not.toBe(firstInviteUrl)
264-
const regeneratedInviteUrl = await inviteInput.inputValue()
265-
expect(new URL(regeneratedInviteUrl).pathname).toMatch(/^\/invite\//)
261+
await owner.page.getByRole('button', { name: 'Regenerate', exact: true }).click()
262+
await owner.waitForMutationResolution()
266263

267-
await owner.page.waitForTimeout(1100)
268-
await ownerDialog.getByRole('button', { name: 'Copy invite link' }).click()
264+
// Copy again (after the 1s copy-button guard) and poll until the new link lands.
269265
await expect
270-
.poll(() => owner.page.evaluate(() => navigator.clipboard.readText()))
271-
.toBe(regeneratedInviteUrl)
266+
.poll(
267+
async () => {
268+
await owner.page.waitForTimeout(1100)
269+
await ownerDialog.getByRole('button', { name: 'Copy invite link' }).click()
270+
return owner.page.evaluate(() => navigator.clipboard.readText())
271+
},
272+
{ timeout: 15000 }
273+
)
274+
.not.toBe(firstInviteUrl)
275+
const regeneratedInviteUrl = await owner.page.evaluate(() => navigator.clipboard.readText())
276+
expect(new URL(regeneratedInviteUrl).pathname).toMatch(/^\/invite\//)
272277
await owner.page.keyboard.press('Escape')
273278

274279
// Non-owners can inspect settings but cannot access owner-only controls.
275280
await member.sidebar.openWorkspaceSettings(workspaceName)
276-
const memberDialog = member.page.getByRole('dialog', { name: 'Workspace settings' })
281+
const memberDialog = member.page.getByRole('dialog', { name: 'Manage workspace' })
277282
await expect(memberDialog.getByPlaceholder('Workspace name')).toBeDisabled()
278283
await expect(
279284
memberDialog.locator(`[id="workspace-member-role-${memberUserId}"]`)
280285
).not.toBeVisible()
286+
287+
// Leave/Delete live on the Settings tab; members get Leave but not Delete.
288+
await memberDialog.getByRole('tab', { name: 'Settings' }).click()
281289
await expect(memberDialog.getByRole('button', { name: /Delete workspace/ })).not.toBeVisible()
282290
await expect(memberDialog.getByRole('button', { name: /Leave workspace/ })).toBeVisible()
283291

284-
// Leaving requires confirmation and removes the member's workspace access.
292+
// Leaving requires confirmation (the confirm button is just "Leave") and removes access.
285293
await memberDialog.getByRole('button', { name: /Leave workspace/ }).click()
286-
await member.page.getByRole('button', { name: 'Leave workspace' }).click()
294+
await member.page.getByRole('button', { name: 'Leave', exact: true }).click()
287295
await member.waitForMutationResolution()
288296
await member.sidebar.expectWorkspaceNotVisible(workspaceName)
289297
await member.sidebar.expectFileNotVisible(fileName)

apps/dotcom/client/public/tla/locales-compiled/en.json

Lines changed: 68 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@
2121
"value": "GetHelpLink"
2222
}
2323
],
24+
"04a2dd56d6": [
25+
{
26+
"type": 0,
27+
"value": "Invite teammates"
28+
}
29+
],
2430
"05f43078e9": [
2531
{
2632
"type": 0,
@@ -129,12 +135,6 @@
129135
"value": "Create file"
130136
}
131137
],
132-
"1d2591253e": [
133-
{
134-
"type": 0,
135-
"value": "Workspace settings"
136-
}
137-
],
138138
"1da2e8106b": [
139139
{
140140
"type": 0,
@@ -147,6 +147,12 @@
147147
"value": "Today"
148148
}
149149
],
150+
"1ffd709937": [
151+
{
152+
"type": 0,
153+
"value": "A workspace must keep at least one owner. Make someone else an owner first."
154+
}
155+
],
150156
"21f66c9c00": [
151157
{
152158
"type": 0,
@@ -177,12 +183,24 @@
177183
"value": "Search..."
178184
}
179185
],
186+
"2de510be71": [
187+
{
188+
"type": 0,
189+
"value": "Regenerating replaces the current invite link with a new one. Anyone using the old link will need the new one to join."
190+
}
191+
],
180192
"2e2b3c700b": [
181193
{
182194
"type": 0,
183195
"value": "Include link to current file"
184196
}
185197
],
198+
"2e9856a31e": [
199+
{
200+
"type": 0,
201+
"value": "Regenerate invite link"
202+
}
203+
],
186204
"31246941ad": [
187205
{
188206
"type": 0,
@@ -261,12 +279,6 @@
261279
"value": "Contact the owner to request access."
262280
}
263281
],
264-
"44680c1fa7": [
265-
{
266-
"type": 0,
267-
"value": "Leave workspace…"
268-
}
269-
],
270282
"44db318ee5": [
271283
{
272284
"type": 0,
@@ -333,12 +345,6 @@
333345
"value": "Unable to unpublish the file."
334346
}
335347
],
336-
"51317dcad5": [
337-
{
338-
"type": 0,
339-
"value": "This is your private workspace. Create a new workspace to invite teammates."
340-
}
341-
],
342348
"517bb809d9": [
343349
{
344350
"type": 0,
@@ -409,12 +415,6 @@
409415
"value": "My workspace"
410416
}
411417
],
412-
"5c6fe42bc2": [
413-
{
414-
"type": 0,
415-
"value": "Revoke this link and create a new one."
416-
}
417-
],
418418
"5d04a002ea": [
419419
{
420420
"type": 0,
@@ -595,6 +595,12 @@
595595
"value": "Got it"
596596
}
597597
],
598+
"78faf88f4d": [
599+
{
600+
"type": 0,
601+
"value": "Enable invite link"
602+
}
603+
],
598604
"797799f35e": [
599605
{
600606
"type": 0,
@@ -775,12 +781,6 @@
775781
"value": "Sign in with Google"
776782
}
777783
],
778-
"95b56f4cd6": [
779-
{
780-
"type": 0,
781-
"value": "Delete workspace…"
782-
}
783-
],
784784
"95d2109dc8": [
785785
{
786786
"type": 0,
@@ -903,6 +903,12 @@
903903
"value": "Submit"
904904
}
905905
],
906+
"a52945dbe2": [
907+
{
908+
"type": 0,
909+
"value": "Leave"
910+
}
911+
],
906912
"a6cc403d56": [
907913
{
908914
"type": 0,
@@ -973,6 +979,12 @@
973979
"value": "Guest user"
974980
}
975981
],
982+
"b2326c3a34": [
983+
{
984+
"type": 0,
985+
"value": "Regenerate"
986+
}
987+
],
976988
"b2c3c2e30c": [
977989
{
978990
"type": 0,
@@ -1003,12 +1015,32 @@
10031015
"value": "Copy link"
10041016
}
10051017
],
1018+
"b9a68fbd56": [
1019+
{
1020+
"type": 0,
1021+
"value": "Members ("
1022+
},
1023+
{
1024+
"type": 1,
1025+
"value": "count"
1026+
},
1027+
{
1028+
"type": 0,
1029+
"value": ")"
1030+
}
1031+
],
10061032
"ba0318cb81": [
10071033
{
10081034
"type": 0,
10091035
"value": "Sign in to share"
10101036
}
10111037
],
1038+
"bbaaf8b6bf": [
1039+
{
1040+
"type": 0,
1041+
"value": "Invites are disabled"
1042+
}
1043+
],
10121044
"bbbcefd7ce": [
10131045
{
10141046
"offset": 0,
@@ -1061,6 +1093,12 @@
10611093
"value": "ownerName"
10621094
}
10631095
],
1096+
"c17466812d": [
1097+
{
1098+
"type": 0,
1099+
"value": "Manage workspace"
1100+
}
1101+
],
10641102
"c2276c9127": [
10651103
{
10661104
"type": 0,
@@ -1129,12 +1167,6 @@
11291167
"value": "Dismiss"
11301168
}
11311169
],
1132-
"c9cc8cce24": [
1133-
{
1134-
"type": 0,
1135-
"value": "Save"
1136-
}
1137-
],
11381170
"cceaefbc20": [
11391171
{
11401172
"type": 0,
@@ -1259,12 +1291,6 @@
12591291
"value": "OK"
12601292
}
12611293
],
1262-
"e0c88179b3": [
1263-
{
1264-
"type": 0,
1265-
"value": "Invite members"
1266-
}
1267-
],
12681294
"e2198d6228": [
12691295
{
12701296
"type": 0,
@@ -1301,12 +1327,6 @@
13011327
"value": "You have been invited to join workspace:"
13021328
}
13031329
],
1304-
"ea0626ac5a": [
1305-
{
1306-
"type": 0,
1307-
"value": "Danger zone"
1308-
}
1309-
],
13101330
"ea4788705e": [
13111331
{
13121332
"type": 0,
@@ -1369,12 +1389,6 @@
13691389
"value": " to learn more."
13701390
}
13711391
],
1372-
"ef53538ae4": [
1373-
{
1374-
"type": 0,
1375-
"value": "Members"
1376-
}
1377-
],
13781392
"ef7de3f485": [
13791393
{
13801394
"type": 0,

0 commit comments

Comments
 (0)