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 (