Skip to content
3 changes: 2 additions & 1 deletion assets/js/liveview/live_socket.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,12 @@ import topbar from 'topbar'
import Alpine from 'alpinejs'

import CopySnippet from './copy-snippet'
import MemberRows from './member-rows'

let csrfToken = document.querySelector("meta[name='csrf-token']")
let websocketUrl = document.querySelector("meta[name='websocket-url']")
if (csrfToken && websocketUrl) {
let Hooks = { Modal, Dropdown, CopySnippet }
let Hooks = { Modal, Dropdown, CopySnippet, MemberRows }

Hooks.VerificationLifecycle = {
mounted() {
Expand Down
178 changes: 178 additions & 0 deletions assets/js/liveview/member-rows.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
// Instantly adds/removes rows, and selects a role, in the "create team"
// form's member list - entirely client-side, no server round trip. Row and
// role state is plain form data (an email input and a hidden role input per
// row), read once when the form is submitted.
//
// Expects a `template[data-row-template]` (the row blueprint, with the
Comment thread
apata marked this conversation as resolved.
// literal placeholder `__ROW_ID__` standing in for the row id in its
// attributes), a `[data-row-list]` container to append/remove rows from, a
// `[data-add-row]` button, `[data-remove-row]` buttons, role pickers built
// from a `details[data-role-picker]` (listbox-button pattern: a `<summary>`
// trigger, a `[role=listbox]` container, and `[data-role-item]`
// `[role=option]` buttons) containing a `[data-role-label]` span and a
// `[data-role-value]` hidden input, and a `data-max-rows` attribute capping
// how many rows can exist (computed server-side from the plan's team member
// limit) - the add button shows a not-allowed cursor once that cap is hit.

const ROW_ID_PLACEHOLDER = '__ROW_ID__'

const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1)
Comment thread
apata marked this conversation as resolved.

export default {
mounted() {
this.template = this.el.querySelector('template[data-row-template]')
this.list = this.el.querySelector('[data-row-list]')
this.maxRows = parseInt(this.el.dataset.maxRows, 10)
this.addButton = this.el.querySelector('[data-add-row]')

this.addButton.addEventListener('click', () => this.addRow())

this.list.addEventListener('click', (e) => {
const removeButton = e.target.closest('[data-remove-row]')
if (removeButton) return this.removeRow(removeButton)

const roleItem = e.target.closest('[data-role-item]')
if (roleItem) return this.selectRole(roleItem)
})

// Native <details> only closes on a second click on <summary> - close it
// on an outside click too, like any other dropdown.
this.handleOutsideClick = (e) => {
this.list
.querySelectorAll('[data-role-picker][open]')
.forEach((details) => {
if (!details.contains(e.target)) this.closeRolePicker(details)
})
}
document.addEventListener('click', this.handleOutsideClick)

this.list
.querySelectorAll('[data-role-picker]')
.forEach((details) => this.wireRolePicker(details))

this.updateAddButtonState()
},

destroyed() {
document.removeEventListener('click', this.handleOutsideClick)
},

addRow() {
if (this.list.children.length >= this.maxRows) return

const rowId =
window.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`

const html = this.template.innerHTML.replaceAll(ROW_ID_PLACEHOLDER, rowId)
const wrapper = document.createElement('div')
wrapper.innerHTML = html
const row = wrapper.firstElementChild

this.list.appendChild(row)
this.wireRolePicker(row.querySelector('[data-role-picker]'))
this.updateAddButtonState()
},

updateAddButtonState() {
this.addButton.classList.toggle(
'cursor-not-allowed',
this.list.children.length >= this.maxRows
)
},

removeRow(button) {
button.closest('[data-row]').remove()
this.updateAddButtonState()
},

selectRole(item) {
const row = item.closest('[data-row]')
const details = row.querySelector('[data-role-picker]')
const items = [...details.querySelectorAll('[data-role-item]')]
const role = item.dataset.roleItem

row.querySelector('[data-role-value]').value = role
row.querySelector('[data-role-label]').textContent = capitalize(role)
items.forEach((i) => i.setAttribute('aria-selected', i === item))

this.closeRolePicker(details)
details.querySelector('summary').focus()
},

// Wires up the WAI-ARIA listbox-button keyboard pattern for a role picker:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue, non-blocking: These handlers don't match the dashboard's top bar popovers from headless-ui (https://headlessui.com/react/popover). I think ideally, we should be consistent with that. Popover's handlers do less than this and that's good. Less to go wrong.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This consistency sounds nice but it's difficult to achieve and the scope expands far beyond this PR. There's a split between LiveViews and the dashboard, and I just logically followed the LiveView example, aiming for consistency with other Prima components (just pushed a PR that creates this component in Prima: plausible/prima#25).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the split justified though? From perspective of the user, it's all Plausible.

I understand the reluctance to rewrite this minor piece of UI again, but this extra complexity (over a headlessui popover) isn't justified for this component, at least I don't think so.

Who are these keyboard handlers for and what's our confidence that they serve the purpose?

All custom keyboard handlers risk messing with people that can't use a mouse to navigate, or see what's happening on the screen.

To them, the browser's default Home button behavior may be the escape from out of a messed up cookie banner that's preventing them from completing some essential task on a government website. Close your eyes, turn on screen reading, and try to set up an appointment to renew your passport. Another challenge: set up Plausible.

I'm not an expert on accessibility, but I do know that it takes expertise to build an accessible component.

Anyways, this is just me advising to take care here and in the future with custom keyboard handlers. It's a non-blocking comment because I can't prove that it's not perfectly accessible.

// arrow keys move a roving tabindex between options (opening the listbox on
// first use if needed), Home/End jump to the ends, Escape closes and
// returns focus to the trigger, and Tab closes the listbox on its way out.
wireRolePicker(details) {
const summary = details.querySelector('summary')
const items = [...details.querySelectorAll('[data-role-item]')]

details.addEventListener('toggle', () => {
summary.setAttribute('aria-expanded', details.open)
this.setRovingIndex(items, 0)
})

details.addEventListener('keydown', (e) => {
const currentIndex = items.indexOf(document.activeElement)

switch (e.key) {
case 'ArrowDown':
e.preventDefault()
details.open = true
this.focusItem(
items,
currentIndex === -1 ? 0 : (currentIndex + 1) % items.length
)
break

case 'ArrowUp':
e.preventDefault()
details.open = true
this.focusItem(
items,
currentIndex === -1
? items.length - 1
: (currentIndex - 1 + items.length) % items.length
)
break

case 'Home':
if (!details.open) return
e.preventDefault()
this.focusItem(items, 0)
break

case 'End':
if (!details.open) return
e.preventDefault()
this.focusItem(items, items.length - 1)
break

case 'Escape':
if (!details.open) return
this.closeRolePicker(details)
summary.focus()
break

case 'Tab':
this.closeRolePicker(details)
break
}
})
},

focusItem(items, index) {
this.setRovingIndex(items, index)
items[index].focus()
},

setRovingIndex(items, index) {
items.forEach((item, i) =>
item.setAttribute('tabindex', i === index ? '0' : '-1')
)
},

closeRolePicker(details) {
details.removeAttribute('open')
}
}
51 changes: 21 additions & 30 deletions e2e/tests/dashboard/team-setup.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,16 @@ test('submitting team name via Enter key does not crash', async ({

await expectLiveViewConnected(page)

await expect(page.getByRole('button', { name: 'Create Team' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Create team' })).toBeVisible()

const nameInput = page.locator('input[name="team[name]"]')

await nameInput.clear()
await nameInput.fill('My New Team')

// Enter submits the whole form directly (single phx-submit handler)
await nameInput.press('Enter')

await expect(nameInput).toHaveValue('My New Team')

// the form had no phx-submit handler and plain HTTP POST fallback was made
await page.getByRole('button', { name: 'Create Team' }).click()

await expect(page).toHaveURL(/\/settings\/team\/general/)

await expectLiveViewConnected(page)
Expand All @@ -34,7 +30,7 @@ test('submitting team name via Enter key does not crash', async ({
await expect(nameInput2).toHaveValue('My New Team')
})

test('create team is blocked while the name is rejected', async ({
test('create team is blocked when the name is rejected on submit', async ({
page,
request
}) => {
Expand All @@ -43,26 +39,24 @@ test('create team is blocked while the name is rejected', async ({

await expectLiveViewConnected(page)

const createTeam = page.getByRole('button', { name: 'Create Team' })
const createTeam = page.getByRole('button', { name: 'Create team' })
const nameInput = page.locator('input[name="team[name]"]')

await expect(createTeam).toBeEnabled()

await nameInput.fill('My personal sites')
await createTeam.click()

await expect(page.locator('#update-team-form')).toContainText('is reserved')
await expect(createTeam).toBeDisabled()
await expect(page.getByText('is reserved')).toBeVisible()
await expect(page).toHaveURL(/\/team\/setup/)

await test.step('recovers once the name is fixed', async () => {
await nameInput.fill('Fixed Team Name')
await createTeam.click()

await expect(createTeam).toBeEnabled()
await expect(page).toHaveURL(/\/settings\/team\/general/)
})

await createTeam.click()

await expect(page).toHaveURL(/\/settings\/team\/general/)

await expectLiveViewConnected(page)

await expect(page.locator('input[name="team[name]"]')).toHaveValue(
Expand Down Expand Up @@ -95,37 +89,34 @@ test('creating a team when the user name is long', async ({
await expectLiveViewConnected(page)

// the page mounts instead of crashing on the over-long suggested name
await expect(page.getByRole('button', { name: 'Create Team' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Create team' })).toBeVisible()

const nameInput = page.locator('input[name="team[name]"]')
const createTeam = page.getByRole('button', { name: 'Create team' })

await expect(nameInput).toHaveValue(expectedTeamName)

await test.step('a name over the limit is rejected', async () => {
await test.step('a name over the limit is rejected on submit', async () => {
await nameInput.fill('b'.repeat(51))
await createTeam.click()

await expect(page.locator('#update-team-form')).toContainText(
'should be at most 50 character(s)'
)
await expect(
page.getByRole('button', { name: 'Create Team' })
).toBeDisabled()
page.getByText('should be at most 50 character(s)')
).toBeVisible()
await expect(page).toHaveURL(/\/team\/setup/)
})

await test.step('a name carrying a URL scheme is rejected', async () => {
await test.step('a name carrying a URL scheme is rejected on submit', async () => {
await nameInput.fill('Cheap meds at https://spam.example.com')
await createTeam.click()

await expect(page.locator('#update-team-form')).toContainText(
'cannot contain a URL'
)
await expect(
page.getByRole('button', { name: 'Create Team' })
).toBeDisabled()
await expect(page.getByText('cannot contain a URL')).toBeVisible()
await expect(page).toHaveURL(/\/team\/setup/)
})

await nameInput.fill('Chosen Team Name')

await page.getByRole('button', { name: 'Create Team' }).click()
await createTeam.click()

await expect(page).toHaveURL(/\/settings\/team\/general/)

Expand Down
12 changes: 11 additions & 1 deletion lib/plausible/teams/billing.ex
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ defmodule Plausible.Teams.Billing do
Teams.owned_sites_count(team)
end

@spec team_member_limit(Teams.Team.t() | nil) :: non_neg_integer() | :unlimited
on_ee do
@team_member_limit_for_trials 10

Expand All @@ -256,7 +257,16 @@ defmodule Plausible.Teams.Billing do
team_member_limit(team) == 0
end
else
def team_member_limit(_team), do: :unlimited
def team_member_limit(_team) do
# The `else` branch is not reachable.
# This a workaround for Elixir 1.18+ compiler
# being too smart.
if :erlang.phash2(1, 1) == 0 do
:unlimited
else
0
end
end

def solo?(_team), do: always(false)
end
Expand Down
20 changes: 15 additions & 5 deletions lib/plausible/teams/team.ex
Original file line number Diff line number Diff line change
Expand Up @@ -147,11 +147,21 @@ defmodule Plausible.Teams.Team do
end

def name_changeset(team, attrs \\ %{}) do
team
|> cast(attrs, [:name])
|> validate_required(:name)
|> validate_name()
|> validate_exclusion(:name, [Plausible.Teams.default_name()])
changeset =
team
|> cast(attrs, [:name])
|> validate_name()
|> validate_required(:name)

# validate_exclusion/3 only runs its check when the field actually changed
# relative to the struct's current value, which would let a freshly
# auto-created team (whose name already equals the reserved default) keep
# that name simply by resubmitting it unchanged. Check unconditionally.
if get_field(changeset, :name) == Plausible.Teams.default_name() do
add_error(changeset, :name, "is reserved")
else
changeset
end
Comment on lines +156 to +164

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion, non-blocking: There's a force_change function in Ecto. We ended up using it for annotations for a similar situation where I originally had built a workaround.

end

def setup_changeset(team, now \\ NaiveDateTime.utc_now(:second)) do
Expand Down
Loading
Loading