From 82070c72ec826b3844f660af86933e60f4a6cdc7 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 9 Oct 2026 10:01:57 +0200 Subject: [PATCH] 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. --- .../src/components/OAuth/ScopeGroupPicker.tsx | 31 +++----- .../OAuth/useScopeSelection.test.ts | 77 +++++++++++++++++++ .../src/components/OAuth/useScopeSelection.ts | 58 ++++++++++++++ .../screens/settings/MScopeGroupPicker.tsx | 34 +++----- 4 files changed, 156 insertions(+), 44 deletions(-) create mode 100644 client/src/components/OAuth/useScopeSelection.test.ts create mode 100644 client/src/components/OAuth/useScopeSelection.ts diff --git a/client/src/components/OAuth/ScopeGroupPicker.tsx b/client/src/components/OAuth/ScopeGroupPicker.tsx index 6bf6df654..37fcba42b 100644 --- a/client/src/components/OAuth/ScopeGroupPicker.tsx +++ b/client/src/components/OAuth/ScopeGroupPicker.tsx @@ -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>({}) - - 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 (
@@ -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" > @@ -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" > diff --git a/client/src/components/OAuth/useScopeSelection.test.ts b/client/src/components/OAuth/useScopeSelection.test.ts new file mode 100644 index 000000000..60b0b4c09 --- /dev/null +++ b/client/src/components/OAuth/useScopeSelection.test.ts @@ -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); + }); +}); diff --git a/client/src/components/OAuth/useScopeSelection.ts b/client/src/components/OAuth/useScopeSelection.ts new file mode 100644 index 000000000..99bcee327 --- /dev/null +++ b/client/src/components/OAuth/useScopeSelection.ts @@ -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>({}); + + 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 }; +} diff --git a/client/src/mobile/screens/settings/MScopeGroupPicker.tsx b/client/src/mobile/screens/settings/MScopeGroupPicker.tsx index c19a86bea..5865e7d62 100644 --- a/client/src/mobile/screens/settings/MScopeGroupPicker.tsx +++ b/client/src/mobile/screens/settings/MScopeGroupPicker.tsx @@ -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>({}) - - 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 (