refactor(oauth): share the scope group picker logic between desktop and phone

The grouped scopes, the folded groups and the select all, group and single scope toggles now live in one useScopeSelection hook. Both pickers keep their own rows.
This commit is contained in:
Maurice
2026-10-09 10:01:57 +02:00
parent 309753b799
commit 82070c72ec
4 changed files with 156 additions and 44 deletions
@@ -1,9 +1,9 @@
import React, { useState } from 'react'
import React from 'react'
import { Check, ChevronDown, ChevronRight, Minus } from 'lucide-react'
import { getScopesByGroup } from '../../api/oauthScopes'
import { useTranslation } from '../../i18n'
import { fs } from '../shared/DialogShell'
import { SettingRows, SETTINGS_BUTTON } from '../Settings/settingsKit'
import { useScopeSelection } from './useScopeSelection'
interface Props {
selected: string[]
@@ -32,18 +32,15 @@ function CheckBox({ checked, mixed = false }: { checked: boolean; mixed?: boolea
*/
export default function ScopeGroupPicker({ selected, onChange }: Props): React.ReactElement {
const { t } = useTranslation()
const [open, setOpen] = useState<Record<string, boolean>>({})
const scopesByGroup = getScopesByGroup(t)
const allScopeKeys = Object.values(scopesByGroup).flat().map(s => s.scope)
const allSelected = allScopeKeys.every(s => selected.includes(s))
const { scopesByGroup, allSelected, open, groupState, toggleOpen, toggleAll, toggleGroup, toggleScope } =
useScopeSelection(selected, onChange)
return (
<div className="flex flex-col gap-2.5">
<div className="flex justify-end">
<button
type="button"
onClick={() => onChange(allSelected ? [] : allScopeKeys)}
onClick={toggleAll}
className={`${SETTINGS_BUTTON} px-3 py-1.5`}
style={fs(12, 'body')}
>
@@ -53,16 +50,14 @@ export default function ScopeGroupPicker({ selected, onChange }: Props): React.R
<div className="max-h-96 overflow-y-auto overscroll-contain">
<SettingRows>
{Object.entries(scopesByGroup).map(([group, groupScopes]) => {
const groupScopeKeys = groupScopes.map(s => s.scope)
const allGroupSelected = groupScopeKeys.every(s => selected.includes(s))
const someGroupSelected = groupScopeKeys.some(s => selected.includes(s))
const mixed = someGroupSelected && !allGroupSelected
const state = groupState(groupScopes)
const { allSelected: allGroupSelected, someSelected: someGroupSelected, mixed } = state
return (
<div key={group}>
<div className="flex items-center gap-2 px-3.5 py-2.5">
<button
type="button"
onClick={() => setOpen(prev => ({ ...prev, [group]: !prev[group] }))}
onClick={() => toggleOpen(group)}
aria-expanded={!!open[group]}
className="flex min-w-0 flex-1 items-center gap-1.5 text-start text-content-secondary hover:text-content"
>
@@ -72,7 +67,7 @@ export default function ScopeGroupPicker({ selected, onChange }: Props): React.R
<span className="truncate font-geist font-bold uppercase tracking-[.08em]" style={fs(10.5)}>{group}</span>
{someGroupSelected && (
<span className="flex-none font-geist tabular-nums text-content-faint" style={fs(11)}>
({groupScopeKeys.filter(s => selected.includes(s)).length}/{groupScopeKeys.length})
({state.selectedCount}/{state.keys.length})
</span>
)}
</button>
@@ -81,11 +76,7 @@ export default function ScopeGroupPicker({ selected, onChange }: Props): React.R
role="checkbox"
aria-checked={mixed ? 'mixed' : allGroupSelected}
aria-label={allGroupSelected ? `Deselect all ${group}` : `Select all ${group}`}
onClick={() => onChange(
allGroupSelected
? selected.filter(s => !groupScopeKeys.includes(s))
: [...new Set([...selected, ...groupScopeKeys])]
)}
onClick={() => toggleGroup(state)}
className="grid h-7 w-7 flex-none place-items-center rounded-[8px] hover:bg-surface-secondary"
>
<CheckBox checked={allGroupSelected} mixed={mixed} />
@@ -101,7 +92,7 @@ export default function ScopeGroupPicker({ selected, onChange }: Props): React.R
type="button"
role="checkbox"
aria-checked={on}
onClick={() => onChange(on ? selected.filter(s => s !== scope) : [...selected, scope])}
onClick={() => toggleScope(scope)}
className="flex w-full items-start gap-2.5 px-3.5 py-2.5 text-start transition-colors hover:bg-surface-tertiary"
>
<span className="mt-px"><CheckBox checked={on} /></span>
@@ -0,0 +1,77 @@
// FE-COMP-SCOPESEL-001 to -006: the scope picker logic the desktop picker and its phone twin share.
import { act, renderHook } from '@testing-library/react';
import { SCOPE_GROUPS } from '../../api/oauthScopes';
import { useScopeSelection } from './useScopeSelection';
vi.mock('../../i18n', () => ({ useTranslation: () => ({ t: (k: string) => k }) }));
const TRIPS = 'oauth.scope.group.trips';
const ALL = Object.keys(SCOPE_GROUPS);
const TRIP_SCOPES = ALL.filter((s) => SCOPE_GROUPS[s].groupKey === TRIPS);
function setup(selected: string[]) {
const onChange = vi.fn();
const hook = renderHook(({ sel }) => useScopeSelection(sel, onChange), { initialProps: { sel: selected } });
return { ...hook, onChange };
}
describe('useScopeSelection', () => {
it('FE-COMP-SCOPESEL-001: groups every scope under its translated group', () => {
const { result } = setup([]);
expect(result.current.scopesByGroup[TRIPS].map((s) => s.scope)).toEqual(TRIP_SCOPES);
expect(Object.values(result.current.scopesByGroup).flat()).toHaveLength(ALL.length);
});
it('FE-COMP-SCOPESEL-002: select all ticks every scope, and with all ticked it clears them', () => {
const { result, rerender, onChange } = setup([]);
expect(result.current.allSelected).toBe(false);
const grouped = Object.values(result.current.scopesByGroup)
.flat()
.map((s) => s.scope);
act(() => result.current.toggleAll());
expect(onChange).toHaveBeenLastCalledWith(grouped);
rerender({ sel: ALL });
expect(result.current.allSelected).toBe(true);
act(() => result.current.toggleAll());
expect(onChange).toHaveBeenLastCalledWith([]);
});
it('FE-COMP-SCOPESEL-003: group state counts the ticked scopes and flags a partly ticked group as mixed', () => {
const { result } = setup([TRIP_SCOPES[0]]);
const state = result.current.groupState(result.current.scopesByGroup[TRIPS]);
expect(state).toEqual({
keys: TRIP_SCOPES,
allSelected: false,
someSelected: true,
mixed: true,
selectedCount: 1,
});
});
it('FE-COMP-SCOPESEL-004: toggling a group adds its missing scopes once, or removes all of them', () => {
const other = ALL.find((s) => !TRIP_SCOPES.includes(s)) as string;
const { result, rerender, onChange } = setup([other, TRIP_SCOPES[0]]);
act(() => result.current.toggleGroup(result.current.groupState(result.current.scopesByGroup[TRIPS])));
expect(onChange).toHaveBeenLastCalledWith([other, ...TRIP_SCOPES]);
rerender({ sel: [other, ...TRIP_SCOPES] });
act(() => result.current.toggleGroup(result.current.groupState(result.current.scopesByGroup[TRIPS])));
expect(onChange).toHaveBeenLastCalledWith([other]);
});
it('FE-COMP-SCOPESEL-005: toggling one scope appends or removes just that scope', () => {
const { result, onChange } = setup([TRIP_SCOPES[0]]);
act(() => result.current.toggleScope(TRIP_SCOPES[1]));
expect(onChange).toHaveBeenLastCalledWith([TRIP_SCOPES[0], TRIP_SCOPES[1]]);
act(() => result.current.toggleScope(TRIP_SCOPES[0]));
expect(onChange).toHaveBeenLastCalledWith([]);
});
it('FE-COMP-SCOPESEL-006: groups start folded and fold out and back per group', () => {
const { result } = setup([]);
expect(result.current.open[TRIPS]).toBeUndefined();
act(() => result.current.toggleOpen(TRIPS));
expect(result.current.open[TRIPS]).toBe(true);
act(() => result.current.toggleOpen(TRIPS));
expect(result.current.open[TRIPS]).toBe(false);
});
});
@@ -0,0 +1,58 @@
import { useState } from 'react';
import { getScopesByGroup } from '../../api/oauthScopes';
import { useTranslation } from '../../i18n';
/** How much of one scope group is ticked. `mixed` is some but not all of it. */
export interface ScopeGroupState {
keys: string[];
allSelected: boolean;
someSelected: boolean;
mixed: boolean;
selectedCount: number;
}
/**
* The scope picker logic behind the desktop picker and its phone twin, which
* render their own rows over it: the grouped scopes, which groups are folded
* out, and the select-all, per-group and per-scope toggles. `selected` and
* `onChange` stay owned by the caller.
*/
export function useScopeSelection(selected: string[], onChange: (scopes: string[]) => void) {
const { t } = useTranslation();
const [open, setOpen] = useState<Record<string, boolean>>({});
const scopesByGroup = getScopesByGroup(t);
const allScopeKeys = Object.values(scopesByGroup)
.flat()
.map((s) => s.scope);
const allSelected = allScopeKeys.every((s) => selected.includes(s));
const groupState = (groupScopes: { scope: string }[]): ScopeGroupState => {
const keys = groupScopes.map((s) => s.scope);
const allGroupSelected = keys.every((s) => selected.includes(s));
const someGroupSelected = keys.some((s) => selected.includes(s));
return {
keys,
allSelected: allGroupSelected,
someSelected: someGroupSelected,
mixed: someGroupSelected && !allGroupSelected,
selectedCount: keys.filter((s) => selected.includes(s)).length,
};
};
const toggleOpen = (group: string) => setOpen((prev) => ({ ...prev, [group]: !prev[group] }));
const toggleAll = () => onChange(allSelected ? [] : allScopeKeys);
const toggleGroup = (group: ScopeGroupState) =>
onChange(
group.allSelected ? selected.filter((s) => !group.keys.includes(s)) : [...new Set([...selected, ...group.keys])]
);
const toggleScope = (scope: string) => {
const on = selected.includes(scope);
onChange(on ? selected.filter((s) => s !== scope) : [...selected, scope]);
};
return { scopesByGroup, allSelected, open, groupState, toggleOpen, toggleAll, toggleGroup, toggleScope };
}
@@ -5,9 +5,8 @@
* group cards, a square tri-state group selector and tap-to-toggle scope rows
* with a confirm-coloured check.
*/
import { useState } from 'react'
import { Check, ChevronDown, ChevronRight, Minus } from 'lucide-react'
import { getScopesByGroup } from '../../../api/oauthScopes'
import { useScopeSelection } from '../../../components/OAuth/useScopeSelection'
import { useTranslation } from '../../../i18n'
interface Props {
@@ -17,18 +16,15 @@ interface Props {
export default function MScopeGroupPicker({ selected, onChange }: Props) {
const { t } = useTranslation()
const [open, setOpen] = useState<Record<string, boolean>>({})
const scopesByGroup = getScopesByGroup(t)
const allScopeKeys = Object.values(scopesByGroup).flat().map((s) => s.scope)
const allSelected = allScopeKeys.every((s) => selected.includes(s))
const { scopesByGroup, allSelected, open, groupState, toggleOpen, toggleAll, toggleGroup, toggleScope } =
useScopeSelection(selected, onChange)
return (
<div className="space-y-2">
<div className="flex justify-end">
<button
type="button"
onClick={() => onChange(allSelected ? [] : allScopeKeys)}
onClick={toggleAll}
className="rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-3 py-[5px] font-geist text-[0.6875rem] font-bold text-m-ink"
>
{allSelected ? t('settings.oauth.modal.deselectAll') : t('settings.oauth.modal.selectAll')}
@@ -37,10 +33,8 @@ export default function MScopeGroupPicker({ selected, onChange }: Props) {
<div className="max-h-[22rem] space-y-2 overflow-y-auto pe-[2px]">
{Object.entries(scopesByGroup).map(([group, groupScopes]) => {
const groupScopeKeys = groupScopes.map((s) => s.scope)
const allGroupSelected = groupScopeKeys.every((s) => selected.includes(s))
const someGroupSelected = groupScopeKeys.some((s) => selected.includes(s))
const selectedInGroup = groupScopeKeys.filter((s) => selected.includes(s)).length
const state = groupState(groupScopes)
const { allSelected: allGroupSelected, someSelected: someGroupSelected, selectedCount: selectedInGroup } = state
const isOpen = !!open[group]
return (
@@ -48,7 +42,7 @@ export default function MScopeGroupPicker({ selected, onChange }: Props) {
<div className="flex items-center gap-1 bg-[color:var(--m-sheet)] px-3 py-2">
<button
type="button"
onClick={() => setOpen((prev) => ({ ...prev, [group]: !prev[group] }))}
onClick={() => toggleOpen(group)}
className="flex flex-1 items-center gap-1 text-start text-[0.75rem] font-bold text-m-ink"
>
{isOpen ? (
@@ -59,19 +53,13 @@ export default function MScopeGroupPicker({ selected, onChange }: Props) {
<span className="min-w-0 truncate">{group}</span>
{someGroupSelected && (
<span className="ms-[6px] flex-none font-geist text-[0.625rem] font-normal text-m-faint">
({selectedInGroup}/{groupScopeKeys.length})
({selectedInGroup}/{state.keys.length})
</span>
)}
</button>
<button
type="button"
onClick={() =>
onChange(
allGroupSelected
? selected.filter((s) => !groupScopeKeys.includes(s))
: [...new Set([...selected, ...groupScopeKeys])]
)
}
onClick={() => toggleGroup(state)}
aria-label={allGroupSelected ? `Deselect all ${group}` : `Select all ${group}`}
className={`flex h-[22px] w-[22px] flex-none items-center justify-center rounded-[7px] border ${
allGroupSelected
@@ -97,9 +85,7 @@ export default function MScopeGroupPicker({ selected, onChange }: Props) {
<button
key={scope}
type="button"
onClick={() =>
onChange(on ? selected.filter((s) => s !== scope) : [...selected, scope])
}
onClick={() => toggleScope(scope)}
className="flex w-full items-start gap-2.5 border-t border-[color:var(--m-rowbr)] px-3 py-2 text-start"
>
<div className="min-w-0 flex-1">