feat(quick-actions): let each user reorder quick actions and rebind their shortcuts

Merged
EV
14d ago
+3503-414

Summary

Each user can now customize the Quick Actions dialog. A pencil button switches the dialog to edit mode, where the user can:

  • reorder the actions inside each group, by drag and drop or with the move up / move down buttons;
  • rebind a shortcut: click the shortcut, then press the new keys;
  • clear a shortcut;
  • reset everything to the built-in layout.

Save stores the result in the user's server-side UI preferences, under uiPreferences.quickActions, so it follows the user to every browser and device. The header re-registers the shortcuts as soon as the save succeeds. A user who never customizes anything gets exactly today's actions, order and shortcuts.

Why

Closes #7091: users asked to adapt Quick Actions and their keyboard shortcuts to the way they work, instead of one fixed layout for everybody.

What changed

packages/contracts

  • user.model.ts: adds IQuickActionsUiPreferences ({ order?: string[]; shortcuts?: Record<string, string> }), the optional quickActions key on IUserUiPreferences, and quickActions on IUserUiPreferencesUpdateInput (null removes it).
  • No backend change: PUT /user/ui-preferences already accepts any plain-object feature key and keeps the 16 KB / depth 8 limits. A full customization is well under 2 KB.

packages/ui-core/core: new services/quick-actions/, four small modules:

  • quick-action.definitions.ts: the built-in catalogue, one row per action, in today's header order:
    • a stable id (the menu item's data.action);
    • its group and label;
    • its default shortcut, exactly as the header has always registered it.
  • quick-action-shortcut.util.ts: pure shortcut helpers:
    • canonical spelling and display form;
    • mapping hotkeys-js key codes and KeyboardEvents to key names;
    • the rule checker, conflict detection and validateQuickActionShortcut;
    • ShortcutRecorder, a small state machine behind "click, then press the keys". It reports the release of a key it never saw go down as ignored.
  • quick-actions-preferences.util.ts: pure merge helpers:
    • sanitizeQuickActionsPreferences keeps only known ids and valid shortcuts, ignores prototype keys, and returns null when nothing usable is left;
    • resolveQuickActionShortcuts and resolveQuickActionOrder: the user's choices win, unknown ids are ignored, and actions added later appear in their default place with their default shortcut;
    • withQuickActionShortcut applies one choice and drops any other choice that clashes with it;
    • getAvailableQuickActionIds gives the actions the user may run: those the menu lists, plus the timer actions;
    • resolveAvailableQuickActionShortcuts resolves conflicts among those actions only, and getQuickActionBindings lists what to bind from that result;
    • reorderSubset reorders only the actions the user can see; actions hidden by permissions keep their slot;
    • toQuickActionsPreferences stores only the user's own choices that differ from the defaults (null = default layout).
  • quick-actions-preferences.service.ts:
    • preferences$, read from Store.user;
    • save() and reset() through the existing UsersService.updateUiPreferences. Only the quickActions key of the stored user's preferences is replaced;
    • editing (read-only signal), setEditing() and editingEnded$: the global shortcuts pause during the customization, and the header binds them again each time it ends.
  • chat-sidebar.service.ts: after its write, the chat panel now replaces only aiChat in the stored user's preferences, as the Quick Actions save replaces only quickActions. Whichever answer arrives last no longer reverts the other feature's preferences in the open page.

packages/ui-core/theme: header.component.ts

  • Which shortcuts are registered:
    • the catalogue merged with the user's preferences, for the actions the user may run only: those in the menu (permissions, feature flags), plus the timer actions as before. Conflicts are resolved among those actions, the same way the dialog resolves its badges;
    • re-registered when the shortcuts change, when the menu changes, and each time the customization ends, even unchanged.
  • The hotkeys-js '*' handler and the defaultShortcuts scope are unchanged, and so is the default filter that ignores typing in inputs, textareas, selects and contentEditable.
  • The 29 hard-coded badge blocks are gone: the dialog now renders each badge from the effective shortcut.
  • ngOnDestroy now unbinds the header's hotkeys.

packages/ui-core/shared: Quick Actions dialog

  • Edit mode:
    • a pencil button in the dialog header;
    • one cdkDropList per group, using @angular/cdk/drag-drop, which is already a dependency;
    • move up / move down buttons, so reordering also works without a mouse;
    • a shortcut button that records the keys, plus a clear button;
    • an inline error message for a refused shortcut;
    • Reset, Cancel and Save in a footer.
  • Accessibility:
    • every control is a real button with a translated aria-label;
    • each list is labelled by its group header;
    • errors are linked to the shortcut button with aria-describedby;
    • reorders are announced in a polite live region, and focus stays on the moved row;
    • the close "x" is now keyboard-reachable (role="button", Enter/Space).
  • Display mode: the existing markup is kept (p.group-header, nb-menu, span.cancel, nb-card-header h6), so the e2e selectors still match.
  • DialogsModule imports DragDropModule.

packages/ui-core/i18n

  • A QUICK_ACTIONS_CUSTOMIZE block in all 14 language files. Keys were added only; no file was reformatted. ach uses English like its other keys.

Flags / settings and their defaults

  • No feature flag and no new setting.
  • Default: no uiPreferences.quickActions, which means today's order and today's shortcuts.
  • Reset + Save deletes the stored key (quickActions: null).

Decisions taken (owner asleep, please override if you disagree)

  1. Shortcut rules.
    • Ctrl, Alt and Cmd/Meta are refused, so the browser's and the OS's own combinations (Ctrl+T, Cmd+W, Alt+F4, ...) can never be shadowed. Shift is allowed, as in today's defaults (shift+I).
    • / and ' are refused anywhere in a shortcut without Shift (/, /+y): they are Firefox's quick-find keys. shift+/ is allowed.
    • Ctrl+Q (open/close Quick Actions) is reserved.
    • A shortcut has at most two keys, like today's A+E.
    • Only letters, digits and punctuation can be used, so no F-keys, arrows, Enter or Space.
    • Two listed actions can't share a shortcut, and neither can one shortcut be a prefix of another (A vs A+E). The dialog names the action that already uses it.
  2. Actions the user cannot see never block a key.
    • A recorded shortcut is checked only against the actions the dialog lists.
    • A hidden action (no permission) that held the key loses it: its own saved choice is dropped, and its default is displaced.
    • The header never binds a key to an action the user cannot run, so such a key never swallows a press.
    • An action the user cannot run neither keeps a key nor displaces another action's default, whether through its saved shortcut or its default. A saved createTask: 'i' leaves Create Invoice its I while Create Task is unavailable. The saved choice is kept for when the action is available again.
  3. Only the user's own choices are stored.
    • shortcuts holds only the choices that differ from the default; '' means removed on purpose.
    • A default left without a shortcut because one of the user's choices took its key is not stored. It comes back once the key is free again.
    • order is stored only if a group's sequence changed.
  4. Reordering happens within a group. Groups keep today's "biggest group first" ordering.
  5. Reset puts the defaults into the draft; nothing is stored until Save. Cancel discards the draft.
    • While a save is pending, nothing that changes the draft works (Reset, Cancel, moves, drag and drop, recording, clearing).
    • A save that completes after its dialog closed leaves the editing state alone, so it cannot resume the shortcuts over a newer dialog.
  6. Global shortcuts pause while the dialog is in edit mode.
    • Recording keys never navigates away or closes the dialog.
    • Escape cancels a recording without closing the dialog, Backspace/Delete clears the shortcut, and Tab moves on.
    • The release of the Enter that started a recording is let through to the page.
    • The header binds its shortcuts again when edit mode ends, which also resets the keys hotkeys-js thinks are held.
  7. The timer actions' ids are now startTimer / stopTimer, instead of START_TIMER / STOP_TIMER, so that every stored id has the same camelCase shape. These ids are internal to the header menu and this dialog; nothing else used them.

Fixes found on the way

  • Function and numpad keys triggered letter shortcuts. The old header turned key codes into characters with String.fromCharCode, so:

    • F1 opened Create Payment and F4 started the timer;
    • numpad 9 opened Create Invoice, and so on.

    Key codes are now mapped by name, and unnamed keys do nothing.

  • The dialog never unsubscribed from NbMenuService.onItemClick(). Every time the dialog was opened, another subscription was added. Later timer clicks then ran the start/stop handler once per past opening. It now uses untilDestroyed.

  • The header never unbound its hotkeys on destroy.

  • Shortcuts for actions the user cannot open were still bound. Pressing one only closed the dialog and did nothing else.

Follow-up for the owner (not changed here)

  • Lost update on the server. UserService.updateUiPreferences (packages/core/src/lib/user/user.service.ts) reads the user's preferences, merges the patch and writes the result back, with no lock or atomic JSON update. Two features saving at the same moment (the chat panel and this dialog, say) can still lose one write on the server. This predates this PR. Both client writers now merge only their own key, so the open page no longer shows a reverted value.

Tests

All commands were run from the repo root with Node 26.10.0, against hotkeys-js 4.0.8 (the pinned ^4.0.8, resolved from the root node_modules).

  • npx jest -c packages/ui-core/jest.config.ts --rootDir packages/ui-core --testPathPatterns 'services/quick-actions/|dialogs/quick-actions/': 4 suites, 119 tests passed.
    • quick-action-shortcut.util.spec.ts:
      • every default shortcut maps exactly as the old header algorithm did;
      • key names match hotkeys-js keyMap / modifier;
      • the reserved, invalid, too-many-keys, conflict and prefix rules;
      • the recorder, including ignored releases.
    • quick-actions-preferences.util.spec.ts:
      • the catalogue matches today's shortcuts;
      • sanitize, resolve, withQuickActionShortcut, getQuickActionBindings and reorder;
      • availability: menu-listed actions plus the timer actions, resolution among them only, and the bindings;
      • displaced defaults are never stored, and round-trips.
    • quick-actions-preferences.service.spec.ts:
      • reads and distinct emissions;
      • setEditing / editingEnded$;
      • save, reset and the local fallback;
      • an overlapping chat-panel save is kept;
      • request failure, a user switch during a save, and no user.
    • quick-actions.component.spec.ts:
      • default grouping and badges, and saved order/badges;
      • edit mode, aria labels, keyboard moves, drop;
      • recording, conflict, reserved, clearing, Escape and Tab;
      • hidden actions: a default key, the start of a two-key shortcut, a saved key, Stop Timer's key;
      • a saved key of an unavailable action takes no badge away;
      • displaced defaults;
      • Enter activation followed by Cancel, against the real hotkeys-js: no key stays held;
      • reset → save(null), cancel, save failure, and closing while editing;
      • the draft locked while saving, and a save that completes after the dialog closed.
  • npx jest -c packages/ui-core/jest.config.ts --rootDir packages/ui-core (the whole ui-core jest project, after merging the integration branch): 101 suites, 892 tests passed (the chat-sidebar spec included).
  • node --test tools/egress-audit/static-hostnames.test.mjs: 7/7 passed on the tracked tree. In a working copy that has the git-ignored, generated packages/ui-config/src/lib/environments/environment*.ts (written for local builds and jest), the walker also counts the hosts in those files and the first test fails. They are not part of this PR, and the check passes with them moved aside.
  • npx tsc -p packages/ui-core/tsconfig.lib.json --noEmit: no errors outside spec files. The errors that remain in *.spec.ts were already there: that tsconfig has no jest types.
  • eslint on the new and rewritten quick-actions files: 0 problems. The header keeps its existing lint findings, none of them introduced here.
  • prettier --check on the new and rewritten TypeScript files: clean.

Manual verification still needed

  • yarn start:gauzy, sign in, then press Ctrl+Q (or click "+ Create") and click the pencil:
    • Drag and drop: drag a row inside the nb-dialog overlay. The preview is kept inside the dialog (cdkDragPreviewContainer="parent").
    • Rebind, then reload: rebind an action (for example Create Task → Y), Save, then reload. Y should open Create Task, T should do nothing, and the badge should show Y.
    • Refused shortcuts: try Ctrl+K, /, or another listed action's shortcut. Each should be refused with a message.
    • Hidden actions: as an employee without invoice permission, I can be given to another action.
    • Enter on the shortcut button: focus a shortcut button with Tab, press Enter, then Cancel. Ctrl+Q and the other shortcuts must still work.
    • Reset: Reset → Save → reload. The defaults should be back, and uiPreferences.quickActions should be gone from GET /user/me.
    • Typing in inputs: shortcuts still don't fire while typing in an input.
  • RTL (ar/he) and dark theme layout of the edit rows.
  • Electron desktop app, which shares the header.

Risk and rollback

  • Risk: low to medium.
    • The header's shortcut wiring was refactored, but the spec pins every default shortcut and every key mapping to the old behaviour.
    • Stored preferences are sanitized on every read, so a malformed value falls back to the defaults.
    • No migration and no backend change.
  • Rollback: revert this PR. Stored uiPreferences.quickActions values are then simply ignored; they are inert JSON.

Closes #7091

šŸ¤– Generated with Claude Code