-
-
Notifications
You must be signed in to change notification settings - Fork 1.9k
New team setup UI #6666
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
New team setup UI #6666
Changes from all commits
74bcbd2
1953ae5
0116bee
40328b8
92ee57e
4114b81
6ce7b9c
aaf9577
99d4841
5d31cf9
25bfc33
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
| // 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) | ||
|
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: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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).
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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') | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.