diff --git a/e2e/admin-inline-edit.spec.ts b/e2e/admin-inline-edit.spec.ts index 599c009db..af9dc7846 100644 --- a/e2e/admin-inline-edit.spec.ts +++ b/e2e/admin-inline-edit.spec.ts @@ -25,7 +25,7 @@ async function createThrowawayCustomer(page: Page): Promise { const form = page.locator('.modal form.stack-form'); await expect(form).toBeVisible(); await form.locator('.field input[type="text"]').first().fill(name); - await form.locator('.field-check').filter({ hasText: /^Global$/ }).locator('input[type="checkbox"]').check(); + await form.getByRole('checkbox', { name: 'Global', exact: true }).check(); await form.locator('button[type="submit"]').filter({ hasText: SAVE }).click(); await expect(page.locator('.modal')).toHaveCount(0); await expect(adminRow(page, name)).toHaveCount(1); diff --git a/e2e/admin/admin-ui.spec.ts b/e2e/admin/admin-ui.spec.ts index 1b59061fe..8c4270743 100644 --- a/e2e/admin/admin-ui.spec.ts +++ b/e2e/admin/admin-ui.spec.ts @@ -72,7 +72,7 @@ test.describe('Administration UI', () => { const form = page.locator('.modal form.stack-form'); await expect(form).toBeVisible(); await form.locator('.field input[type="text"]').first().fill(name); - await form.locator('.field-check').filter({ hasText: /^Global$/ }).locator('input[type="checkbox"]').check(); + await form.getByRole('checkbox', { name: 'Global', exact: true }).check(); await form.locator('button[type="submit"]').filter({ hasText: SAVE }).click(); await expect(page.locator('.modal')).toHaveCount(0); await expect(row(page, name)).toHaveCount(1); diff --git a/frontend/messages/de.json b/frontend/messages/de.json index cf11c9df7..8e73c1f69 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -551,6 +551,12 @@ "settings_apitoken_scope_resource": "Ressource", "settings_apitoken_scope_all": "Vollzugriff", "settings_apitoken_scope_all_hint": "Gewährt alle Berechtigungen Ihres Kontos.", + "settings_apitoken_create_heading": "Neues Token erstellen", + "settings_apitoken_existing_heading": "Ihre Token", + "settings_apitoken_mode_label": "Zugriff", + "settings_apitoken_mode_scoped": "Nach Ressource", + "settings_apitoken_scope_all_warning": "gewährt den Wildcard-Scope (*): Das Token kann alles, was Ihr Konto kann — Lesen und Schreiben auf jeder Ressource, jetzt und auf jeder später hinzugefügten Ressource. Es ist keine Momentaufnahme der heutigen Berechtigungen; neue Rechte werden automatisch abgedeckt.", + "settings_apitoken_select_all": "Alle {action}", "settings_apitoken_expiry_label": "Läuft ab (optional)", "settings_apitoken_create": "Token erstellen", "settings_apitoken_created_title": "Token erstellt", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 6f0bfedf5..051f51e11 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -551,6 +551,12 @@ "settings_apitoken_scope_resource": "Resource", "settings_apitoken_scope_all": "Full access", "settings_apitoken_scope_all_hint": "Grants every permission your account has.", + "settings_apitoken_create_heading": "Create a new token", + "settings_apitoken_existing_heading": "Your tokens", + "settings_apitoken_mode_label": "Access", + "settings_apitoken_mode_scoped": "By resource", + "settings_apitoken_scope_all_warning": "grants the wildcard scope (*): the token can do everything your account can — read and write on every resource, now and on any resource added later. It is not a snapshot of today's permissions; new capabilities are covered automatically.", + "settings_apitoken_select_all": "Select all {action}", "settings_apitoken_expiry_label": "Expires (optional)", "settings_apitoken_create": "Create token", "settings_apitoken_created_title": "Token created", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 949b05252..2fa56c081 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -551,6 +551,12 @@ "settings_apitoken_scope_resource": "Recurso", "settings_apitoken_scope_all": "Acceso completo", "settings_apitoken_scope_all_hint": "Concede todos los permisos de tu cuenta.", + "settings_apitoken_create_heading": "Crear un nuevo token", + "settings_apitoken_existing_heading": "Tus tokens", + "settings_apitoken_mode_label": "Acceso", + "settings_apitoken_mode_scoped": "Por recurso", + "settings_apitoken_scope_all_warning": "concede el ámbito comodín (*): el token puede hacer todo lo que tu cuenta puede — leer y escribir en todos los recursos, ahora y en cualquier recurso añadido más adelante. No es una instantánea de los permisos actuales; las nuevas capacidades se cubren automáticamente.", + "settings_apitoken_select_all": "Seleccionar todo {action}", "settings_apitoken_expiry_label": "Caduca (opcional)", "settings_apitoken_create": "Crear token", "settings_apitoken_created_title": "Token creado", diff --git a/frontend/messages/fr.json b/frontend/messages/fr.json index d403cd383..3923f18ae 100644 --- a/frontend/messages/fr.json +++ b/frontend/messages/fr.json @@ -551,6 +551,12 @@ "settings_apitoken_scope_resource": "Ressource", "settings_apitoken_scope_all": "Accès complet", "settings_apitoken_scope_all_hint": "Accorde toutes les permissions de votre compte.", + "settings_apitoken_create_heading": "Créer un nouveau jeton", + "settings_apitoken_existing_heading": "Vos jetons", + "settings_apitoken_mode_label": "Accès", + "settings_apitoken_mode_scoped": "Par ressource", + "settings_apitoken_scope_all_warning": "accorde la portée générique (*) : le jeton peut tout faire ce que votre compte peut faire — lire et écrire sur chaque ressource, maintenant et sur toute ressource ajoutée ultérieurement. Ce n'est pas un instantané des permissions actuelles ; les nouvelles capacités sont couvertes automatiquement.", + "settings_apitoken_select_all": "Tout sélectionner {action}", "settings_apitoken_expiry_label": "Expire (facultatif)", "settings_apitoken_create": "Créer le jeton", "settings_apitoken_created_title": "Jeton créé", diff --git a/frontend/messages/ru.json b/frontend/messages/ru.json index 87bba7a1d..3e6be2835 100644 --- a/frontend/messages/ru.json +++ b/frontend/messages/ru.json @@ -551,6 +551,12 @@ "settings_apitoken_scope_resource": "Ресурс", "settings_apitoken_scope_all": "Полный доступ", "settings_apitoken_scope_all_hint": "Предоставляет все разрешения вашей учётной записи.", + "settings_apitoken_create_heading": "Создать новый токен", + "settings_apitoken_existing_heading": "Ваши токены", + "settings_apitoken_mode_label": "Доступ", + "settings_apitoken_mode_scoped": "По ресурсу", + "settings_apitoken_scope_all_warning": "предоставляет подстановочную область (*): токен может делать всё, что доступно вашей учётной записи — чтение и запись для любого ресурса, сейчас и для любого ресурса, добавленного позже. Это не снимок текущих разрешений; новые возможности покрываются автоматически.", + "settings_apitoken_select_all": "Выбрать все {action}", "settings_apitoken_expiry_label": "Истекает (необязательно)", "settings_apitoken_create": "Создать токен", "settings_apitoken_created_title": "Токен создан", diff --git a/frontend/src/components/AdminCrudShell.tsx b/frontend/src/components/AdminCrudShell.tsx index 4abaf2cd4..562faac41 100644 --- a/frontend/src/components/AdminCrudShell.tsx +++ b/frontend/src/components/AdminCrudShell.tsx @@ -940,11 +940,13 @@ function FieldControl(props: {
-
diff --git a/frontend/src/components/ApiTokenControls.test.tsx b/frontend/src/components/ApiTokenControls.test.tsx index 10038816f..a8dc14b38 100644 --- a/frontend/src/components/ApiTokenControls.test.tsx +++ b/frontend/src/components/ApiTokenControls.test.tsx @@ -114,6 +114,42 @@ describe('ApiTokenControls', () => { ) }) + it('column select-all fills its action column and shows a three-state header', async () => { + render(() => ) + await waitFor(() => screen.getByLabelText('entries:read')) + + const selectAllRead = screen.getByLabelText('Select all read') as HTMLInputElement + + // One of two read cells ticked → the header is partial (indeterminate). + fireEvent.change(screen.getByLabelText('entries:read'), { target: { checked: true } }) + await waitFor(() => expect(selectAllRead.indeterminate).toBe(true)) + expect(selectAllRead.checked).toBe(false) + + // The header select-all fills the whole read column, no longer partial. + fireEvent.change(selectAllRead, { target: { checked: true } }) + expect(screen.getByLabelText('entries:read')).toBeChecked() + expect(screen.getByLabelText('projects:read')).toBeChecked() + expect(screen.getByLabelText('entries:write')).not.toBeChecked() + await waitFor(() => expect(selectAllRead.indeterminate).toBe(false)) + expect(selectAllRead.checked).toBe(true) + + // Clearing the header empties the column again. + fireEvent.change(selectAllRead, { target: { checked: false } }) + expect(screen.getByLabelText('entries:read')).not.toBeChecked() + expect(screen.getByLabelText('projects:read')).not.toBeChecked() + }) + + it('full-access mode hides the scope grid and shows the wildcard warning', async () => { + render(() => ) + await waitFor(() => screen.getByLabelText('entries:read')) + + fireEvent.change(screen.getByLabelText('Full access', { exact: false }), { target: { checked: true } }) + + // The grid is gone and the caution note takes its place. + expect(screen.queryByLabelText('entries:read')).not.toBeInTheDocument() + expect(screen.getByRole('note')).toBeInTheDocument() + }) + it('applies a preset as exactly its scope set and marks it active', async () => { listApiTokens.mockResolvedValue({ tokens: [], resources: ['entries', 'sync'], actions: ['read', 'write'], wildcard: '*' }) render(() => ) diff --git a/frontend/src/components/ApiTokenControls.tsx b/frontend/src/components/ApiTokenControls.tsx index 5803db1b0..5aefbd1e6 100644 --- a/frontend/src/components/ApiTokenControls.tsx +++ b/frontend/src/components/ApiTokenControls.tsx @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { createMemo, createResource, createSignal, For, Show, type JSX } from 'solid-js' +import { createEffect, createMemo, createResource, createSignal, For, Show, type JSX } from 'solid-js' import { apiErrorMessage } from '../api/client' import { DateField } from './DateField' @@ -174,6 +174,36 @@ export function ApiTokenControls(): JSX.Element { setScopes(next) } + /** How many of an action column's checkboxes are ticked — drives the header + * select-all's checked / indeterminate (three) state. */ + function columnState(action: string): { checked: boolean; indeterminate: boolean } { + const resources = data()?.resources ?? [] + if (resources.length === 0) { + return { checked: false, indeterminate: false } + } + let checked = 0 + for (const resource of resources) { + if (scopes().has(`${resource}:${action}`)) { + checked += 1 + } + } + return { checked: checked === resources.length, indeterminate: checked > 0 && checked < resources.length } + } + + /** Header select-all: tick or clear every resource's scope for one action column. */ + function toggleColumn(action: string, on: boolean): void { + const next = new Set(scopes()) + for (const resource of data()?.resources ?? []) { + const scope = `${resource}:${action}` + if (on) { + next.add(scope) + } else { + next.delete(scope) + } + } + setScopes(next) + } + /** Drop a preset's (server-valid) scopes into the picker and leave wildcard off. */ function applyPreset(preset: ScopePreset): void { setScopes(presetScopeSet(preset, data())) @@ -276,65 +306,7 @@ export function ApiTokenControls(): JSX.Element {

{m.settings_apitoken_hint()}

- {/* The one-time plaintext of the token just created. */} - - {(token) => ( -
-

{m.settings_apitoken_created_title()}

-

{m.settings_apitoken_created_hint()}

-
- {token().token} - -
-
- )} -
- - 0} - fallback={

{m.settings_apitoken_none()}

} - > -
    - - {(token) => { - const state = tokenState(token) - return ( -
  • -
    - {token.name} - - {m.settings_apitoken_revoked()} - - - {m.settings_apitoken_expired()} - -
    -
    - {(scope) => {scope}} -
    -
    - - {m.settings_apitoken_last_used()}:{' '} - {token.lastUsedAt === null ? m.settings_apitoken_never_used() : formatDate(token.lastUsedAt)} - - - {m.settings_apitoken_expires()}:{' '} - {token.expiresAt === null ? m.settings_apitoken_no_expiry() : formatDate(token.expiresAt)} - -
    - - - -
  • - ) - }} -
    -
-
+

{m.settings_apitoken_create_heading()}

void submit(event)}>
+ + {(resource) => ( +
+ + {resource} + {resourceGloss(resource)} + + + {(action) => { + const scope = `${resource}:${action}` + return ( + toggleScope(scope, event.currentTarget.checked)} + /> + ) + }} + +
+ )} +
+ @@ -446,6 +454,68 @@ export function ApiTokenControls(): JSX.Element { {error()} + + {/* The one-time plaintext of the token just created. */} + + {(token) => ( +
+

{m.settings_apitoken_created_title()}

+

{m.settings_apitoken_created_hint()}

+
+ {token().token} + +
+
+ )} +
+ +

{m.settings_apitoken_existing_heading()}

+ + 0} + fallback={

{m.settings_apitoken_none()}

} + > +
    + + {(token) => { + const state = tokenState(token) + return ( +
  • +
    + {token.name} + + {m.settings_apitoken_revoked()} + + + {m.settings_apitoken_expired()} + +
    +
    + {(scope) => {scope}} +
    +
    + + {m.settings_apitoken_last_used()}:{' '} + {token.lastUsedAt === null ? m.settings_apitoken_never_used() : formatDate(token.lastUsedAt)} + + + {m.settings_apitoken_expires()}:{' '} + {token.expiresAt === null ? m.settings_apitoken_no_expiry() : formatDate(token.expiresAt)} + +
    + + + +
  • + ) + }} +
    +
+
) } diff --git a/frontend/src/components/SearchableSelect.test.tsx b/frontend/src/components/SearchableSelect.test.tsx index 147129c68..ab3260908 100644 --- a/frontend/src/components/SearchableSelect.test.tsx +++ b/frontend/src/components/SearchableSelect.test.tsx @@ -50,13 +50,16 @@ describe('SearchableSelect (jsdom)', () => { await waitFor(() => expect(onChange).toHaveBeenCalledWith(8)) }) - it('single: shows the current value as a chip and the "all" entry clears it to 0', async () => { + it('single: shows the current value in the input and the "all" entry clears it to 0', async () => { const onChange = vi.fn() render(() => ) - // Single and multi share the chip layout, so the chosen value shows as its chip - // (the input beside it stays a bare search box). - expect(screen.getByRole('listitem')).toHaveTextContent('Apollo') + // Single shows the chosen value IN the input (React-Select single), not as a chip. + expect(screen.getByRole('combobox', { name: 'Customer' })).toHaveValue('Apollo') + expect(screen.queryByRole('listitem')).not.toBeInTheDocument() + + // Opening lists every option (the selection highlights, it does not pre-filter): + // 3 real options + the prepended "all" entry. fireEvent.click(screen.getByRole('button', { name: 'Customer' })) await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(4)) @@ -64,6 +67,23 @@ describe('SearchableSelect (jsdom)', () => { await waitFor(() => expect(onChange).toHaveBeenCalledWith(0)) }) + it('single: reverts the input to the selected label when closed without a pick', async () => { + render(() => ) + + const input = screen.getByRole('combobox', { name: 'Customer' }) + expect(input).toHaveValue('Apollo') + + // Open, type a non-committed query (filters the list) … + fireEvent.click(screen.getByRole('button', { name: 'Customer' })) + fireEvent.input(input, { target: { value: 'Ze' } }) + await waitFor(() => expect(input).toHaveValue('Ze')) + + // … then close without picking (toggle the ▾): the input reverts to the chosen + // label, the typed query does not persist. + fireEvent.click(screen.getByRole('button', { name: 'Customer' })) + await waitFor(() => expect(input).toHaveValue('Apollo')) + }) + it('single: with no selection, shows an empty search control and no "all" entry', () => { render(() => ) diff --git a/frontend/src/components/SearchableSelect.tsx b/frontend/src/components/SearchableSelect.tsx index 9d9974b18..38c407d84 100644 --- a/frontend/src/components/SearchableSelect.tsx +++ b/frontend/src/components/SearchableSelect.tsx @@ -1,5 +1,5 @@ import { Combobox } from '@ark-ui/solid/combobox' -import { createMemo, createSignal, For, type JSX, Show } from 'solid-js' +import { createEffect, createMemo, createSignal, For, type JSX } from 'solid-js' import type { NamedOption } from '../api/queries' import { ComboboxContent, comboCollection, type ComboItem } from '../lib/comboboxParts' @@ -13,12 +13,14 @@ const ALL_VALUE = '__all__' /** * A searchable relation combobox for ordinary FORMS (not the admin grid). * - * Single and multi share ONE layout so they read the same (a maintainer request): - * the editable input IS the search field (WAI-ARIA 1.2 combobox), the chosen - * value(s) sit as chips beside it, and a trailing ▾ opens the list. Single differs - * only in that its chip is not removable (pick another option, or the "all" entry, - * to change it) and selecting closes the popup. Opening always shows ALL options — - * the current selection highlights, it never pre-filters the list. + * Single and multi read differently on purpose: + * - SINGLE displays the chosen option's label IN the editable input (a + * React-Select single control): the input IS both the value display and the + * search field. Opening lists ALL options (the selection highlights, it does + * NOT pre-filter); typing filters; picking commits and closes; closing without + * a pick reverts the input to the selected label. + * - MULTI keeps the chosen values as removable chips beside a bare inline search + * input, with a trailing ▾ that opens the (always complete) list. * * Boundary: Ark deals in string[]; relation ids are carried verbatim as the * option's string value and converted back to numbers only in onChange. @@ -37,7 +39,15 @@ export function SearchableSelect(props: { required?: boolean }): JSX.Element { let inputEl: HTMLInputElement | undefined - const [inputValue, setInputValue] = createSignal('') + // The input's text, controlled but driven BY Ark: on selection Ark echoes the + // chosen label into it (so a single select shows its value IN the input), while + // typing writes the query. A single select also seeds/reverts it to the selected + // label via the effect below (Ark echoes only on a selection event, not at mount). + const [inputText, setInputText] = createSignal('') + // Single-only filter gate: true while the user is actively typing a query. Set on + // real input (native onInput), reset on close — so opening lists ALL options (the + // resting label never pre-filters) and a typed-but-not-committed query never persists. + const [searching, setSearching] = createSignal(false) const isMulti = (): boolean => props.multiple === true @@ -58,11 +68,38 @@ export function SearchableSelect(props: { // Offer an explicit "all / none" entry only for an optional single select. const showAll = (): boolean => !isMulti() && props.allLabel !== undefined + + // Single: the label the input DISPLAYS when not searching — the chosen option's + // label, or EMPTY when nothing is chosen (value 0). The empty resting state (the + // search placeholder shows through) matches the existing callers' "all = blank + // field"; the "all / none" entry still lives in the dropdown to clear a selection. + const selectedLabel = createMemo(() => { + if (isMulti()) { + return '' + } + const values = selectedValues() + + return values.length > 0 ? labelOf(values[0]!) : '' + }) + + // Single: keep the input showing the selected label whenever the user is NOT typing + // — at mount, on external value changes, and (searching flips false) on close, which + // reverts a typed-but-not-committed query. Skipped while searching so typing shows. + createEffect(() => { + if (!isMulti() && !searching()) { + setInputText(selectedLabel()) + } + }) + + // What the list filters by. A single select only filters once the user is really + // typing, so opening shows ALL options (the selection highlights, never pre-filters). + const activeQuery = createMemo(() => (isMulti() || searching() ? inputText() : '')) + const filteredItems = createMemo(() => { - const query = inputValue().trim().toLowerCase() - const base = query === '' ? allItems() : allItems().filter((item) => item.label.toLowerCase().includes(query)) + const q = activeQuery().trim().toLowerCase() + const base = q === '' ? allItems() : allItems().filter((item) => item.label.toLowerCase().includes(q)) - return showAll() && query === '' ? [{ value: ALL_VALUE, label: props.allLabel ?? '' }, ...base] : base + return showAll() && q === '' ? [{ value: ALL_VALUE, label: props.allLabel ?? '' }, ...base] : base }) const collection = createMemo(() => comboCollection(filteredItems())) @@ -70,15 +107,14 @@ export function SearchableSelect(props: { props.onChange(selectedValues().filter((current) => current !== value).map(Number)) } - // The placeholder is only an invitation to search — shown while nothing is chosen. - // Once a value is picked its chip carries the display, so the input reads as a - // bare search box. const placeholder = (): string => { - if (selectedValues().length > 0) { - return '' + if (isMulti()) { + return selectedValues().length > 0 ? '' : m.app_type_to_add() } - return isMulti() ? m.app_type_to_add() : m.app_type_to_search() + // Single: the input carries its value, so this hint only shows through when that + // value is empty (nothing chosen and no "all" label, or the query is cleared). + return m.app_type_to_search() } return ( @@ -91,25 +127,23 @@ export function SearchableSelect(props: { {selectedValues().map(labelOf).join(', ')} - {/* The chosen value(s) as chips — multi: removable; single: one, non-removable. - Outside Root so they share the field's wrapping flex row with the - (display:contents) input + ▾. */} + {/* MULTI only: the chosen values as removable chips. A single select shows its + value IN the input (below), so it renders no chip. Outside Root so the chips + share the field's wrapping flex row with the (display:contents) input + ▾. */}
    - + {(value) => (
  • {labelOf(value)} - - - +
  • )}
    @@ -130,23 +164,35 @@ export function SearchableSelect(props: { const first = details.value[0] props.onChange(first === undefined || first === ALL_VALUE ? 0 : Number(first)) }} - inputValue={inputValue()} + inputValue={inputText()} onInputValueChange={(details) => { - // Ark echoes a single-select's chosen label back into the input; suppress that - // so the input stays a bare (empty) search box and the value shows only as its - // chip. That also means reopening never pre-filters by the current selection - // (WAI-ARIA APG: the selection highlights, it does not filter). A real query — - // which differs from the chosen label — passes through untouched, so typing - // (even the keystroke that opens the list) filters normally. - const isSelectionEcho = !isMulti() && selectedValues().some((value) => labelOf(value) === details.inputValue) - setInputValue(isSelectionEcho ? '' : details.inputValue) + // Ark owns the input text: typing writes the query, and on selection it + // echoes the chosen label back in (which is what a single select displays). + // The `searching` flag (set on real typing) decides whether that text + // filters — so a resting single label never pre-filters the list. + setInputText(details.inputValue) }} onOpenChange={(details) => { if (details.open) { - requestAnimationFrame(() => { if (inputEl?.isConnected) inputEl.focus() }) + requestAnimationFrame(() => { + if (!inputEl?.isConnected) { + return + } + inputEl.focus() + // Opened by click (not by typing): select the shown label so the first + // keystroke replaces it. + if (!isMulti() && !searching()) { + inputEl.select() + } + }) } else { - // Drop a typed query on close so the next open starts fresh with all options. - setInputValue('') + // Closing without committing: end search mode. For a single select the + // effect then reverts the input to the selected label; multi's search box + // clears. + setSearching(false) + if (isMulti()) { + setInputText('') + } } }} openOnClick @@ -163,6 +209,14 @@ export function SearchableSelect(props: { aria-label={props.label} aria-required={props.required === true ? 'true' : undefined} placeholder={placeholder()} + onInput={() => { + // Fires only on REAL user typing (not Ark's programmatic writes). Single: + // enter search mode so the typed text (captured by onInputValueChange) + // starts filtering the list. Multi always filters, so it is a no-op there. + if (!isMulti()) { + setSearching(true) + } + }} /> diff --git a/frontend/src/components/WorklogSyncPreferences.tsx b/frontend/src/components/WorklogSyncPreferences.tsx index e6a04f587..42f881e95 100644 --- a/frontend/src/components/WorklogSyncPreferences.tsx +++ b/frontend/src/components/WorklogSyncPreferences.tsx @@ -81,11 +81,20 @@ export function WorklogSyncPreferences(): JSX.Element {

    {preference.ticket_system_name}

    - {/* The help trigger stays OUTSIDE the