Skip to content

Commit 736048e

Browse files
Team management UI improvements (#6699)
* Pin prima to a git ref for in-progress Listbox work Also, temporarily make sure prima.js is built on CI * introduce PrimaListbox and use it in role_picker * rename role_picker to role_select_input * team management: extract remove member from dropdown and use PrimaListbox * a few styling improvements * Improve team management UI mobile layout and pending avatar styling * re-apply Sanne's changes to PrimaListbox Pulled these changes out in order to merge #6706 first. Re-applying them now. * Use phx-change instead of phx-click (role_switcher) * Remove unnecessary input_role server state and click handler * rely on Prima's disabled option * make sure screen readers announce listbox properly * include user email to acessible names per member row * fix confirmation dialog selection bug * do not disable currently selected role option * make disabled remove member button gray instead of faded-red * simplify applying disabled classes Make the global component styles in app.css respect data-disabled (used for disabling components in Prima) when applying disabled classes. * remove redundant phx-value-name * npm lint assets * fix mix.lock after merge commit * update Prima pin * move disabled attribute to listbox wrapper + update disabled variant * update Prima pin * fix matching trigger width * update Prima pin * add selected check icon for role_select_input too * move Prima dependency back to Hex and revert its build orchestration * revert unrelated mix.lock changes --------- Co-authored-by: Sanne de Vries <sannedv@protonmail.com>
1 parent 2c99a29 commit 736048e

13 files changed

Lines changed: 542 additions & 161 deletions

File tree

‎assets/css/app.css‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,11 @@
303303
@custom-variant phx-submit-loading (.phx-submit-loading&, .phx-submit-loading &);
304304
@custom-variant phx-change-loading (.phx-change-loading&, .phx-change-loading &);
305305

306+
/* Prima components usually mark their disabled state with `data-disabled` rather than
307+
the native attribute, so `disabled:` covers both. */
308+
309+
@custom-variant disabled (&:disabled, &[data-disabled="true"], [data-disabled="true"] &);
310+
306311
[x-cloak] {
307312
display: none;
308313
}

‎assets/js/liveview/confirm.js‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// An alternative for phoenix_html's `data-confirm`. Currently delegates to
2+
// `window.confirm` just as `data-confirm` does, but can be extended with custom
3+
// styling in the future.
4+
5+
// Unlike `data-confirm` (which uses a window-level click listener), this hook
6+
// intercepts the click on the element itself, so it also works when a listener
7+
// closer to the element acts on the click first - e.g. a Prima listbox selecting
8+
// the clicked option.
9+
10+
export default {
11+
mounted() {
12+
this.handleClick = (e) => {
13+
const message = this.el.dataset.confirmMessage
14+
15+
if (message && !window.confirm(message)) {
16+
e.preventDefault()
17+
e.stopImmediatePropagation()
18+
}
19+
}
20+
21+
this.el.addEventListener('click', this.handleClick, true)
22+
},
23+
24+
destroyed() {
25+
this.el.removeEventListener('click', this.handleClick, true)
26+
}
27+
}

‎assets/js/liveview/live_socket.js‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,20 @@
77
import 'phoenix_html'
88
import { Socket } from 'phoenix'
99
import { LiveSocket } from 'phoenix_live_view'
10-
import { Modal, Dropdown } from 'prima'
10+
import { Modal, Dropdown, Listbox } from 'prima'
1111
import topbar from 'topbar'
1212
/* eslint-enable import/no-unresolved */
1313

1414
import Alpine from 'alpinejs'
1515

16+
import Confirm from './confirm'
1617
import CopySnippet from './copy-snippet'
1718
import MemberRows from './member-rows'
1819

1920
let csrfToken = document.querySelector("meta[name='csrf-token']")
2021
let websocketUrl = document.querySelector("meta[name='websocket-url']")
2122
if (csrfToken && websocketUrl) {
22-
let Hooks = { Modal, Dropdown, CopySnippet, MemberRows }
23+
let Hooks = { Modal, Dropdown, Listbox, Confirm, CopySnippet, MemberRows }
2324

2425
Hooks.VerificationLifecycle = {
2526
mounted() {
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import { test, expect, Page } from '@playwright/test'
2+
import { setupSite } from '../fixtures'
3+
import {
4+
closeFlashMessage,
5+
expectLiveViewConnected,
6+
hasFlashMessage,
7+
hasNoFlashMessage,
8+
randomID
9+
} from '../test-utils'
10+
11+
async function createTeam(page: Page) {
12+
await page.goto('/team/setup', { waitUntil: 'commit' })
13+
await expectLiveViewConnected(page)
14+
await page.getByRole('button', { name: 'Create team' }).click()
15+
await expect(page).toHaveURL(/\/settings\/team\/general/)
16+
await expectLiveViewConnected(page)
17+
// so that tests start without a flash
18+
await closeFlashMessage(page)
19+
}
20+
21+
async function submitInviteForm(
22+
page: Page,
23+
{ email, roleOptionName }: { email: string; roleOptionName: string | RegExp }
24+
) {
25+
await page.getByPlaceholder('Enter e-mail').fill(email)
26+
await page.locator('#input-role-picker-trigger').click()
27+
await page
28+
.locator('#input-role-picker')
29+
.getByRole('option', { name: roleOptionName })
30+
.click()
31+
await page.getByRole('button', { name: 'Invite' }).click()
32+
}
33+
34+
test('lowering your own role asks for confirmation', async ({
35+
page,
36+
request
37+
}) => {
38+
const { user } = await setupSite({ page, request })
39+
await createTeam(page)
40+
41+
// The only owner can't change their own role, so a second (invited) owner
42+
// is needed for the self-demotion options to become selectable.
43+
const ownerEmail = `owner-${randomID()}@example.com`
44+
await submitInviteForm(page, { email: ownerEmail, roleOptionName: /^Owner/ })
45+
await expect(
46+
page.getByRole('button', { name: `Role for ${ownerEmail}:` })
47+
).toContainText('Owner')
48+
49+
const trigger = page.getByRole('button', { name: `Role for ${user.email}:` })
50+
const options = page.getByRole('listbox', {
51+
name: `Role for ${user.email}:`
52+
})
53+
54+
const dialogs: string[] = []
55+
const answerNextDialog = (accept: boolean) =>
56+
page.once('dialog', async (dialog) => {
57+
dialogs.push(dialog.message())
58+
await (accept ? dialog.accept() : dialog.dismiss())
59+
})
60+
61+
await expect(trigger).toBeEnabled()
62+
await expect(trigger).toContainText('Owner')
63+
64+
await test.step('lower your own role, but cancel the confirmation dialog (mouse)', async () => {
65+
answerNextDialog(false)
66+
67+
await trigger.click()
68+
await options.getByRole('option', { name: /^Viewer/ }).click()
69+
70+
expect(dialogs).toHaveLength(1)
71+
expect(dialogs[0]).toContain("You're about to lower your own role")
72+
await expect(trigger).toContainText('Owner')
73+
await hasNoFlashMessage(page)
74+
75+
// Close the listbox that remained open because the option selection click didn't
76+
// go through -- our confirmation dialog prevented it from bubbling up to Prima.
77+
await page.keyboard.press('Escape')
78+
})
79+
80+
await test.step('lower your own role, but cancel the confirmation dialog (keyboard)', async () => {
81+
answerNextDialog(false)
82+
83+
await trigger.focus()
84+
await page.keyboard.press('ArrowUp')
85+
await expect(
86+
options.getByRole('option', { name: /^Viewer/ })
87+
).toHaveAttribute('data-focus')
88+
await page.keyboard.press('Enter')
89+
90+
expect(dialogs).toHaveLength(2)
91+
await expect(trigger).toContainText('Owner')
92+
await hasNoFlashMessage(page)
93+
94+
// Close the listbox that remained open because the option selection click didn't
95+
// go through -- our confirmation dialog prevented it from bubbling up to Prima.
96+
await page.keyboard.press('Escape')
97+
})
98+
99+
await test.step('lower your own role and confirm (mouse)', async () => {
100+
answerNextDialog(true)
101+
102+
await trigger.click()
103+
await options.getByRole('option', { name: /^Viewer/ }).click()
104+
105+
// The change reaches the server, which rejects it: the other owner has only
106+
// been invited so far.
107+
await hasFlashMessage(page, 'The team has to have at least one owner')
108+
109+
// asked once: phoenix_html must not ask again as the confirmed click bubbles up
110+
expect(dialogs).toHaveLength(3)
111+
112+
// Known bug, to be fixed soon: the trigger keeps showing the rejected role
113+
// until the page is reloaded. Once fixed, the reload won't be necessary.
114+
await page.reload()
115+
await expectLiveViewConnected(page)
116+
117+
await expect(trigger).toContainText('Owner')
118+
})
119+
120+
await test.step('lower your own role and confirm (keyboard)', async () => {
121+
answerNextDialog(true)
122+
123+
await trigger.focus()
124+
await page.keyboard.press('ArrowUp')
125+
await expect(
126+
options.getByRole('option', { name: /^Viewer/ })
127+
).toHaveAttribute('data-focus')
128+
await page.keyboard.press('Enter')
129+
130+
// The change reaches the server, which rejects it: the other owner has only
131+
// been invited so far.
132+
await hasFlashMessage(page, 'The team has to have at least one owner')
133+
134+
// asked once: phoenix_html must not ask again as the confirmed click bubbles up
135+
expect(dialogs).toHaveLength(4)
136+
137+
// Known bug, to be fixed soon: the trigger keeps showing the rejected role
138+
// until the page is reloaded. Once fixed, the reload won't be necessary.
139+
await page.reload()
140+
await expectLiveViewConnected(page)
141+
142+
await expect(trigger).toContainText('Owner')
143+
})
144+
})
145+
146+
test('choosing the current role again just closes the listbox', async ({
147+
page,
148+
request
149+
}) => {
150+
await setupSite({ page, request })
151+
await createTeam(page)
152+
153+
const viewerEmail = `viewer-${randomID()}@example.com`
154+
await submitInviteForm(page, {
155+
email: viewerEmail,
156+
roleOptionName: /^Viewer/
157+
})
158+
159+
const trigger = page.getByRole('button', { name: `Role for ${viewerEmail}:` })
160+
const options = page.getByRole('listbox', {
161+
name: `Role for ${viewerEmail}:`
162+
})
163+
164+
await expect(trigger).toContainText('Viewer')
165+
166+
const dialogs: string[] = []
167+
page.on('dialog', async (dialog) => {
168+
dialogs.push(dialog.message())
169+
await dialog.dismiss()
170+
})
171+
172+
await trigger.click()
173+
await options.getByRole('option', { name: /^Viewer/ }).click()
174+
175+
await expect(trigger).toHaveAttribute('aria-expanded', 'false')
176+
await expect(trigger).toContainText('Viewer')
177+
expect(dialogs).toHaveLength(0)
178+
await hasNoFlashMessage(page)
179+
})

‎e2e/tests/test-utils.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,25 @@ export async function expectLiveViewConnected(page: Page) {
2121
.toBeGreaterThan(0)
2222
}
2323

24+
const visibleFlash = (page: Page) =>
25+
page.getByTestId('live-flash').filter({ visible: true })
26+
27+
export async function hasFlashMessage(page: Page, message?: string) {
28+
const flash = visibleFlash(page)
29+
await expect(
30+
message ? flash.filter({ hasText: message }) : flash
31+
).toBeVisible()
32+
}
33+
34+
export async function hasNoFlashMessage(page: Page) {
35+
await expect(visibleFlash(page)).toHaveCount(0)
36+
}
37+
38+
export async function closeFlashMessage(page: Page) {
39+
await visibleFlash(page).getByRole('button').click()
40+
await hasNoFlashMessage(page)
41+
}
42+
2443
export function randomID() {
2544
return Math.random().toString(16).slice(2)
2645
}

‎lib/plausible_web/components/prima_dropdown.ex‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,7 @@ defmodule PlausibleWeb.Components.PrimaDropdown do
6464
<Dropdown.dropdown_menu
6565
id={@id}
6666
placement="bottom-end"
67-
match_trigger_width={true}
68-
class="bg-white rounded-md shadow-lg ring-1 ring-black/5 focus:outline-none p-1.5 dark:bg-gray-800 relative z-10"
67+
class="min-w-[var(--reference-width)] bg-white rounded-md shadow-lg ring-1 ring-black/5 focus:outline-none p-1.5 dark:bg-gray-800 relative z-10"
6968
>
7069
{render_slot(@inner_block)}
7170
</Dropdown.dropdown_menu>

0 commit comments

Comments
 (0)