Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
168 changes: 168 additions & 0 deletions assets/js/liveview/member-rows.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
// Lets people add/remove member rows and pick a role instantly, without
// waiting on a server round trip - this is a JS hook rather than plain
// LiveView because that latency would be noticeable for something the
// server doesn't need to know about until the form is actually submitted.

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()
row.querySelector('input[type="email"]').focus()
},

updateAddButtonState() {
const atLimit = this.list.children.length >= this.maxRows

this.addButton.classList.toggle('hidden', atLimit)
this.addButton.classList.toggle('inline-flex', !atLimit)
},

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:
Comment thread
apata marked this conversation as resolved.
// 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')
}
}
81 changes: 51 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 All @@ -135,3 +126,33 @@ test('creating a team when the user name is long', async ({
'Chosen Team Name'
)
})

test('add another button moves focus to the new row and hides once the row limit is reached', async ({
page,
request
}) => {
await setupSite({ page, request })
await page.goto('/team/setup', { waitUntil: 'commit' })

await expectLiveViewConnected(page)

const addAnother = page.getByRole('button', { name: 'Add another' })
// 10 is the team member limit for a trial account
const maxRows = 10

await expect(addAnother).toBeVisible()

await addAnother.click()

await expect(
page.locator('#member-rows > div:last-child input[type="email"]')
).toBeFocused()

// one row already exists by default, one more was just added above
for (let i = 2; i < maxRows; i++) {
await addAnother.click()
}

await expect(page.locator('#member-rows > div')).toHaveCount(maxRows)
await expect(addAnother).toBeHidden()
})
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
21 changes: 17 additions & 4 deletions lib/plausible/teams/management/layout.ex
Original file line number Diff line number Diff line change
Expand Up @@ -107,17 +107,30 @@ defmodule Plausible.Teams.Management.Layout do
end)
end

@spec persist(t(), %{current_user: User.t(), current_team: Teams.Team.t()}) ::
{:ok, integer()} | {:error, any()}
@spec persist(t(), %{
required(:current_user) => User.t(),
required(:current_team) => Teams.Team.t(),
optional(:name_changeset) => Ecto.Changeset.t()
}) :: {:ok, integer()} | {:error, any()}
def persist(layout, context) do
result =
Repo.transaction(fn ->
Teams.complete_setup(context.current_team)
# An optional :name_changeset commits the team's own rename together
# with its membership/invitation changes, in the same transaction -
# so a name change can never persist on its own if anything below it
# fails (e.g. team_setup.ex renaming a team as part of its creation).
team =
case Map.get(context, :name_changeset) do
nil -> context.current_team
name_changeset -> Repo.update!(name_changeset)
end

Teams.complete_setup(team)

layout
|> sorted_for_persistence()
|> Enum.reduce([], fn {_, entry}, acc ->
persist_entry(entry, context, acc)
persist_entry(entry, %{context | current_team: team}, acc)
end)
end)

Expand Down
Loading
Loading