Skip to content

Conversation

@deppp
Copy link
Collaborator

@deppp deppp commented Jun 17, 2025

Reason for Change

Problem:
Label Studio has a rich set of keyboard shortcuts, but users previously had no way to customize them. This limited accessibility and forced users into rigid default behaviors.

Solution:
This PR introduces a fully-featured, user-level hotkey customization system. Users can override default shortcuts by section (e.g. annotation, regions, video), manage them in a dedicated Hotkeys tab in Account Settings, and import/export configurations for reuse or team sharing.

Rollout Strategy

  • No feature flag required - enabled for all users by default
  • Safe fallback to default keymap if no customizations are defined

Testing

  • ✅ Unit tests: HotkeysSerializer validation logic
  • ✅ API tests: POST /api/current-user/hotkeys
  • ✅ UI: Manual testing across editor, toolbar, and Account Settings
  • ✅ Migration: Adds custom_hotkeys field to User model
  • ✅ Import: Paste-in JSON UI with structural validation and error handling
  • ✅ Export: Users can copy current config via API (UI copy/export button can follow)

Risks

  • Low risk - no change to behavior unless the user customizes hotkeys
  • Strict validation guards against malformed keys
  • Edge cases: not tested when user has two different OSes and uses it simultaneously

Reviewer Notes

  • Key files:
    • users/serializers.py – Hotkey schema validation
    • Hotkey.ts – Lookup/merge logic
    • AccountSettings/sections/Hotkeys – Full UI implementation
  • Use lookupHotkey('section:action') across the app for dynamic resolution
  • Default hotkeys and sections defined in defaults.js

General Notes

This upgrade significantly improves usability and flexibility, especially for power users. It also sets the stage for future team-wide profiles or shareable hotkey presets.

Michael Malyuk added 6 commits June 16, 2025 20:11
@netlify
Copy link

netlify bot commented Jun 17, 2025

Deploy Preview for heartex-docs ready!

Name Link
🔨 Latest commit 5aa5c3c
🔍 Latest deploy log https://app.netlify.com/projects/heartex-docs/deploys/6869460342132b00083a3ac0
😎 Deploy Preview https://deploy-preview-7784--heartex-docs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@niklub niklub changed the title Fb bros 114 BROS-114: Global Custom Hotkeys Jun 17, 2025
@netlify
Copy link

netlify bot commented Jun 17, 2025

Deploy Preview for label-studio-docs-new-theme ready!

Name Link
🔨 Latest commit 5aa5c3c
🔍 Latest deploy log https://app.netlify.com/projects/label-studio-docs-new-theme/deploys/6869460310c3f70008a6b82d
😎 Deploy Preview https://deploy-preview-7784--label-studio-docs-new-theme.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify
Copy link

netlify bot commented Jun 17, 2025

Deploy Preview for label-studio-playground ready!

Name Link
🔨 Latest commit 5aa5c3c
🔍 Latest deploy log https://app.netlify.com/projects/label-studio-playground/deploys/68694603fa2c6c0008a32575
😎 Deploy Preview https://deploy-preview-7784--label-studio-playground.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify
Copy link

netlify bot commented Jun 17, 2025

Deploy Preview for label-studio-storybook ready!

Name Link
🔨 Latest commit 5aa5c3c
🔍 Latest deploy log https://app.netlify.com/projects/label-studio-storybook/deploys/686946036fd62400085e5344
😎 Deploy Preview https://deploy-preview-7784--label-studio-storybook.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

const api = useAPI();

// Function to identify which hotkeys were modified from defaults
const getModifiedHotkeys = (currentHotkeys) => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

const getModifiedHotkeys = useMemo(() => {
...
  }, [hotkeys]);

@@ -0,0 +1,599 @@
import { useEffect, useState } from "react";
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
import { useEffect, useState } from "react";
import { useEffect, useState, useMemo, useReact } from "react";

this file misses memoization pattern, which results in the degraded performance and uneccessary rerenders. I'd suggest wrapping places with useMemo and useCallback where it is necessary for:

  • prevent recomputations of potentially expensive operations (e.g. getModifiedHotkeys)
  • prevent components rerenders due to non-memoized handlers (e.g. handlers in <HotkeySection>)


<script id="app-settings" nonce="{{request.csp_nonce}}">

var __customHotkeys = {{ user.custom_hotkeys|json_dumps_ensure_ascii|safe }};
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not creating mergedEditorKeymap in Django code instead of custom inline js ?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Django doesn't know much about the defaults for the keys, and we don't want to have two sets of defaults, one on the backend, and another on the frontend, all of the definitions and processing is done at the frontend level, this follows DRY principle.

@@ -0,0 +1,88 @@

import json
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test suite should use pytest and follow the principles described in https://github.com/HumanSignal/label-studio-enterprise/blob/develop/.cursor/rules/backend-unit-tests.mdc
Example:

import json
import pytest
from users.serializers import HotkeysSerializer  # Import from your actual app


class TestHotkeysSerializer:
    """Tests for the HotkeysSerializer following pytest guidelines"""
    
    @pytest.fixture
    def valid_hotkeys(self):
        """Fixture providing valid hotkeys data for testing"""
        return {
            "editor:save": {"key": "ctrl+s", "active": True},
            "editor:open": {"key": "ctrl+o", "active": False},
            "editor:cut": {"key": "ctrl+x"},
            "navigation:home": {"key": "alt+h", "active": True}
        }
    
    @pytest.fixture
    def valid_serializer_data(self, valid_hotkeys):
        """Fixture providing valid serializer data"""
        return {"custom_hotkeys": valid_hotkeys}
    
    def test_valid_data(self, valid_serializer_data):
        """Test serializer validation with valid hotkeys data.
        
        This test validates:
        - Serializer accepts properly formatted hotkeys dictionary
        - All required fields are present and valid
        - Both active and inactive hotkeys are handled correctly
        - Optional 'active' field defaults appropriately
        
        Critical validation: Ensures well-formed hotkey configurations
        are accepted without errors for user customization features.
        """
        serializer = HotkeysSerializer(data=valid_serializer_data)
        assert serializer.is_valid()
    
    def test_invalid_format_not_dict(self):
        """Test serializer rejects non-dictionary custom_hotkeys.
        
        This test validates:
        - Serializer properly validates data type of custom_hotkeys field
        - Non-dictionary values (lists, strings, etc.) are rejected
        - Appropriate validation errors are returned
        
        Critical validation: Prevents malformed data from corrupting
        user hotkey preferences and ensures data integrity.
        """
        data = {"custom_hotkeys": ["not a dictionary"]}
        serializer = HotkeysSerializer(data=data)
        assert not serializer.is_valid()
        assert 'custom_hotkeys' in serializer.errors
    
    def test_invalid_action_key_format(self):
        """Test serializer rejects action keys without proper format.
        
        This test validates:
        - Action keys must follow 'category:action' format with colon separator
        - Malformed action keys (missing colon) are rejected
        - Validation error is properly raised and contains expected field
        
        Critical validation: Ensures hotkey action identifiers follow
        expected naming convention for proper system integration.
        """
        # Missing colon
        invalid_data = {
            "custom_hotkeys": {
                "editorsave": {"key": "ctrl+s"}
            }
        }
        serializer = HotkeysSerializer(data=invalid_data)
        assert not serializer.is_valid()
        assert 'custom_hotkeys' in serializer.errors
    
    def test_empty_action_key(self):
        """Test serializer rejects empty action keys.
        
        This test validates:
        - Empty string action keys are not allowed
        - Validation properly catches and rejects empty identifiers
        - Error response includes the problematic field
        
        Critical validation: Prevents creation of hotkeys with
        undefined action identifiers that would break functionality.
        """
        invalid_data = {
            "custom_hotkeys": {
                "": {"key": "ctrl+s"}
            }
        }
        serializer = HotkeysSerializer(data=invalid_data)
        assert not serializer.is_valid()
        assert 'custom_hotkeys' in serializer.errors
    
    def test_missing_key_in_hotkey_data(self):
        """Test serializer rejects hotkey data without required 'key' field.
        
        This test validates:
        - The 'key' field is mandatory for each hotkey configuration
        - Hotkey objects missing the 'key' field are rejected
        - Validation error properly identifies the missing required field
        
        Critical validation: Ensures all hotkeys have keyboard combinations
        defined, preventing non-functional hotkey configurations.
        """
        invalid_data = {
            "custom_hotkeys": {
                "editor:save": {"active": True}  # Missing 'key'
            }
        }
        serializer = HotkeysSerializer(data=invalid_data)
        assert not serializer.is_valid()
        assert 'custom_hotkeys' in serializer.errors
    
    def test_invalid_key_value(self):
        """Test serializer rejects invalid key values.
        
        This test validates:
        - Empty string key values are not allowed
        - Key field must contain actual keyboard combination
        - Validation catches and rejects meaningless key definitions
        
        Critical validation: Ensures hotkeys have valid keyboard
        combinations that can be processed by the frontend system.
        """
        invalid_data = {
            "custom_hotkeys": {
                "editor:save": {"key": ""}  # Empty key
            }
        }
        serializer = HotkeysSerializer(data=invalid_data)
        assert not serializer.is_valid()
        assert 'custom_hotkeys' in serializer.errors
    
    def test_invalid_active_flag(self):
        """Test serializer rejects non-boolean active flags.
        
        This test validates:
        - The 'active' field must be a boolean value when provided
        - String representations like 'yes'/'no' are not accepted
        - Type validation is properly enforced for optional fields
        
        Critical validation: Ensures hotkey enable/disable state
        is properly typed for consistent system behavior.
        """
        invalid_data = {
            "custom_hotkeys": {
                "editor:save": {"key": "ctrl+s", "active": "yes"}  # Should be boolean
            }
        }
        serializer = HotkeysSerializer(data=invalid_data)
        assert not serializer.is_valid()
        assert 'custom_hotkeys' in serializer.errors

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fine with me, but I have a meta question, why are we not using test frameworks provided by the web framework we are using?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

valid question, mostly bc of historical reasons for adopting pytest, which has great support for various extensions.

return (
<div className={styles.accountSettings}>
<SidebarMenu menuItems={menuItems} path={AccountSettingsPage.path}>
<div className={contentClassName}>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By removing this, we're losing the ability to scroll on a single page - is this intentional?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no sure I'm fully following the question here. Account settings page was a single long page, instead we're breaking it into a few smaller ones (which is consistent with the project settings).

@niklub niklub marked this pull request as ready for review June 27, 2025 15:53
@niklub niklub requested review from a team, Gondragos, hlomzik and nick-skriabin as code owners June 27, 2025 15:53
@niklub
Copy link
Collaborator

niklub commented Jun 27, 2025

/git merge

Workflow run
Successfully merged: delete mode 100644 web/libs/ui/src/typography/sub.tsx

Comment on lines +136 to +138
// If active is explicitly false, return a copy with key set to null
if (hotkeyValue && hotkeyValue.active === false) {
return {...hotkeyValue, key: null};
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be great to have README file describing general principles of hotkeys we use. For example questions regarding this part:

  • what does active: false mean? when do we have it?
  • what does key: null mean? what do we do with such hotkey?

@niklub
Copy link
Collaborator

niklub commented Jun 30, 2025

/git merge

Workflow run
Successfully merged: create mode 100644 web/libs/ui/src/assets/icons/upright-pin.svg

@niklub
Copy link
Collaborator

niklub commented Jul 4, 2025

/git merge

Workflow run
Successfully merged: 104 files changed, 5346 insertions(+), 5431 deletions(-)

@niklub niklub merged commit f0fb90a into develop Jul 5, 2025
52 of 53 checks passed
@robot-ci-heartex robot-ci-heartex deleted the fb-bros-114 branch July 5, 2025 16:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants