feat(ui): reveal collapsed sidebar on configurable hover (#3678)
This commit is contained in:
@@ -32,3 +32,14 @@ func TestMapUserSettingsStateIncludesResolveSessionHostnames(t *testing.T) {
|
||||
t.Fatalf("resolveSessionHostnames = %#v, want true", state["resolveSessionHostnames"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapUserSettingsStateIncludesSidebarHoverSettings(t *testing.T) {
|
||||
var response userdto.UserSettingsResponse
|
||||
if err := json.Unmarshal([]byte(`{"settings":{"sidebar_hover_enabled":false,"sidebar_hover_delay_ms":0}}`), &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state := mapUserSettingsState(response, "workspace-1")
|
||||
if state["sidebarHoverEnabled"] != false || state["sidebarHoverDelayMs"] != 0 {
|
||||
t.Fatalf("hover boot fields: %v, %v", state["sidebarHoverEnabled"], state["sidebarHoverDelayMs"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -733,6 +733,8 @@ func mapUserSettingsState(response userdto.UserSettingsResponse, workspaceID str
|
||||
"simplified": settings.SystemMetricsDisplay.Simplified,
|
||||
},
|
||||
"appStatusBarEnabled": settings.AppStatusBarEnabled,
|
||||
"sidebarHoverEnabled": settings.SidebarHoverEnabled,
|
||||
"sidebarHoverDelayMs": settings.SidebarHoverDelayMs,
|
||||
"resolveSessionHostnames": settings.ResolveSessionHostnames,
|
||||
"appStatusBarOrder": mapAppStatusBarOrder(settings.AppStatusBarOrder),
|
||||
"quickChatTabOrderByWorkspace": settings.QuickChatTabOrderByWorkspace,
|
||||
|
||||
@@ -646,6 +646,7 @@ func userSettingsDomain() DomainDescriptor {
|
||||
"default_utility_agent_id", "default utility agent", "string", "default_utility_model", "default utility model", "string", "default_utility_agent_profile_id", "default utility profile", "string",
|
||||
"keyboard_shortcuts", "keyboard shortcuts", "object", "terminal_link_behavior", "terminal link behavior", "string", "terminal_font_family", "terminal font family", "string",
|
||||
"terminal_font_size", "terminal font size", "integer", "changes_panel_layout", "changes panel layout", "string", "last_seen_display", "last seen display", "string",
|
||||
"sidebar_hover_enabled", "sidebar hover enabled", "boolean", "sidebar_hover_delay_ms", "sidebar hover delay", "integer",
|
||||
"system_metrics_display", "system metrics display", "object", "app_status_bar_enabled", "status bar", "boolean", "resolve_session_hostnames", "resolve hostnames", "boolean",
|
||||
"app_status_bar_order", "status bar order", "object", "quick_chat_tab_order_by_workspace", "quick chat tab order", "object", "kanban_hidden_step_ids", "hidden kanban steps", "object",
|
||||
"workflow_ids_with_auto_hide_empty_steps", "auto-hide workflows", "array", "kanban_sort", "kanban board sort", "string", "kanban_priority_filter_tokens", "kanban priority filter", "array",
|
||||
|
||||
@@ -144,6 +144,8 @@ func (c *Controller) UpdateUserSettings(ctx context.Context, req dto.UpdateUserS
|
||||
LastSeenDisplay: req.LastSeenDisplay,
|
||||
SystemMetricsDisplay: systemMetricsDisplayPatch(req.SystemMetricsDisplay),
|
||||
AppStatusBarEnabled: req.AppStatusBarEnabled,
|
||||
SidebarHoverEnabled: req.SidebarHoverEnabled,
|
||||
SidebarHoverDelayMs: req.SidebarHoverDelayMs,
|
||||
ResolveSessionHostnames: req.ResolveSessionHostnames,
|
||||
AppStatusBarOrder: req.AppStatusBarOrder,
|
||||
QuickChatTabOrderByWorkspace: req.QuickChatTabOrderByWorkspace,
|
||||
|
||||
@@ -76,6 +76,8 @@ type UserSettingsDTO struct {
|
||||
LastSeenDisplay string `json:"last_seen_display"`
|
||||
SystemMetricsDisplay models.SystemMetricsDisplaySettings `json:"system_metrics_display"`
|
||||
AppStatusBarEnabled bool `json:"app_status_bar_enabled"`
|
||||
SidebarHoverEnabled bool `json:"sidebar_hover_enabled"`
|
||||
SidebarHoverDelayMs int `json:"sidebar_hover_delay_ms"`
|
||||
ResolveSessionHostnames bool `json:"resolve_session_hostnames"`
|
||||
AppStatusBarOrder models.AppStatusBarOrder `json:"app_status_bar_order"`
|
||||
QuickChatTabOrderByWorkspace map[string][]string `json:"quick_chat_tab_order_by_workspace"`
|
||||
@@ -188,6 +190,8 @@ type UpdateUserSettingsRequest struct {
|
||||
LastSeenDisplay *string `json:"last_seen_display,omitempty"`
|
||||
SystemMetricsDisplay *SystemMetricsDisplaySettingsPatch `json:"system_metrics_display,omitempty"`
|
||||
AppStatusBarEnabled *bool `json:"app_status_bar_enabled,omitempty"`
|
||||
SidebarHoverEnabled *bool `json:"sidebar_hover_enabled,omitempty"`
|
||||
SidebarHoverDelayMs *int `json:"sidebar_hover_delay_ms,omitempty"`
|
||||
ResolveSessionHostnames *bool `json:"resolve_session_hostnames,omitempty"`
|
||||
AppStatusBarOrder *models.AppStatusBarOrder `json:"app_status_bar_order,omitempty"`
|
||||
QuickChatTabOrderByWorkspace *map[string][]string `json:"quick_chat_tab_order_by_workspace,omitempty"`
|
||||
@@ -384,6 +388,8 @@ func FromUserSettings(settings *models.UserSettings) UserSettingsDTO {
|
||||
LastSeenDisplay: models.NormalizeLastSeenDisplay(settings.LastSeenDisplay),
|
||||
SystemMetricsDisplay: settings.SystemMetricsDisplay,
|
||||
AppStatusBarEnabled: settings.AppStatusBarEnabled,
|
||||
SidebarHoverEnabled: settings.SidebarHoverEnabled,
|
||||
SidebarHoverDelayMs: settings.SidebarHoverDelayMs,
|
||||
ResolveSessionHostnames: settings.ResolveSessionHostnames,
|
||||
AppStatusBarOrder: settings.AppStatusBarOrder,
|
||||
QuickChatTabOrderByWorkspace: settings.QuickChatTabOrderByWorkspace,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// @covers AC-UI-SIDEBAR-HOVER-002.1, AC-UI-SIDEBAR-HOVER-002.2, AC-UI-SIDEBAR-HOVER-002.3
|
||||
func TestSidebarHoverSettingsRoundTrip(t *testing.T) {
|
||||
router := newTestUserSettingsRouter(t)
|
||||
read := func() map[string]any {
|
||||
return readThreadPresentation(t, threadSettingsRequest(t, router, http.MethodGet, ""))
|
||||
}
|
||||
defaults := read()
|
||||
if defaults["sidebar_hover_enabled"] != true || defaults["sidebar_hover_delay_ms"] != float64(500) {
|
||||
t.Fatalf("hover defaults = %v, %v", defaults["sidebar_hover_enabled"], defaults["sidebar_hover_delay_ms"])
|
||||
}
|
||||
for _, body := range []string{`{"sidebar_hover_enabled":false,"sidebar_hover_delay_ms":0}`, `{"app_status_bar_enabled":true}`} {
|
||||
readThreadPresentation(t, threadSettingsRequest(t, router, http.MethodPatch, body))
|
||||
}
|
||||
got := read()
|
||||
if got["sidebar_hover_enabled"] != false || got["sidebar_hover_delay_ms"] != float64(0) {
|
||||
t.Fatalf("hover round trip = %v, %v", got["sidebar_hover_enabled"], got["sidebar_hover_delay_ms"])
|
||||
}
|
||||
readThreadPresentation(t, threadSettingsRequest(t, router, http.MethodPatch, `{"sidebar_hover_delay_ms":5000}`))
|
||||
got = read()
|
||||
if got["sidebar_hover_enabled"] != false || got["sidebar_hover_delay_ms"] != float64(5000) {
|
||||
t.Fatal("delay patch reset toggle or did not persist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSidebarHoverSettingsRejectInvalidWrites(t *testing.T) {
|
||||
for _, value := range []string{"-1", "5001", "1.5", `""`, `"500"`} {
|
||||
t.Run(value, func(t *testing.T) {
|
||||
router := newTestUserSettingsRouter(t)
|
||||
before := threadSettingsRequest(t, router, http.MethodGet, "").Body.String()
|
||||
response := threadSettingsRequest(t, router, http.MethodPatch, `{"sidebar_hover_enabled":false,"sidebar_hover_delay_ms":`+value+`}`)
|
||||
if response.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d: %s", response.Code, response.Body.String())
|
||||
}
|
||||
after := threadSettingsRequest(t, router, http.MethodGet, "").Body.String()
|
||||
if before != after {
|
||||
t.Fatal("invalid request changed saved settings")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -170,6 +170,8 @@ type UserSettings struct {
|
||||
LastSeenDisplay string `json:"last_seen_display"` // "absolute" | "relative"
|
||||
SystemMetricsDisplay SystemMetricsDisplaySettings `json:"system_metrics_display"`
|
||||
AppStatusBarEnabled bool `json:"app_status_bar_enabled"`
|
||||
SidebarHoverEnabled bool `json:"sidebar_hover_enabled"`
|
||||
SidebarHoverDelayMs int `json:"sidebar_hover_delay_ms"`
|
||||
ResolveSessionHostnames bool `json:"resolve_session_hostnames"`
|
||||
AppStatusBarOrder AppStatusBarOrder `json:"app_status_bar_order"`
|
||||
QuickChatTabOrderByWorkspace map[string][]string `json:"quick_chat_tab_order_by_workspace"`
|
||||
|
||||
@@ -104,6 +104,8 @@ type UpdateUserSettingsRequest struct {
|
||||
LastSeenDisplay *string
|
||||
SystemMetricsDisplay *SystemMetricsDisplaySettingsPatch
|
||||
AppStatusBarEnabled *bool
|
||||
SidebarHoverEnabled *bool
|
||||
SidebarHoverDelayMs *int
|
||||
ResolveSessionHostnames *bool
|
||||
AppStatusBarOrder *models.AppStatusBarOrder
|
||||
QuickChatTabOrderByWorkspace *map[string][]string
|
||||
@@ -348,6 +350,9 @@ func taskCreateLastUsedPatchEmpty(patch models.TaskCreateLastUsed) bool {
|
||||
|
||||
// applyBasicSettings copies simple (non-validated) fields from req to settings.
|
||||
func applyBasicSettings(settings *models.UserSettings, req *UpdateUserSettingsRequest) error {
|
||||
if err := applySidebarHoverSettings(settings, req); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := applyWorkspaceAndTaskListPreferences(settings, req); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1125,6 +1130,8 @@ func (s *Service) publishUserSettingsEvent(ctx context.Context, settings *models
|
||||
"last_seen_display": models.NormalizeLastSeenDisplay(settings.LastSeenDisplay),
|
||||
"system_metrics_display": settings.SystemMetricsDisplay,
|
||||
"app_status_bar_enabled": settings.AppStatusBarEnabled,
|
||||
"sidebar_hover_enabled": settings.SidebarHoverEnabled,
|
||||
"sidebar_hover_delay_ms": settings.SidebarHoverDelayMs,
|
||||
"resolve_session_hostnames": settings.ResolveSessionHostnames,
|
||||
"app_status_bar_order": settings.AppStatusBarOrder,
|
||||
"kanban_hidden_step_ids": settings.KanbanHiddenStepIDs,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/kandev/kandev/internal/user/models"
|
||||
)
|
||||
|
||||
func applySidebarHoverSettings(settings *models.UserSettings, req *UpdateUserSettingsRequest) error {
|
||||
if req.SidebarHoverDelayMs != nil && (*req.SidebarHoverDelayMs < 0 || *req.SidebarHoverDelayMs > 5000) {
|
||||
return fmt.Errorf("sidebar_hover_delay_ms must be between 0 and 5000")
|
||||
}
|
||||
if req.SidebarHoverEnabled != nil {
|
||||
settings.SidebarHoverEnabled = *req.SidebarHoverEnabled
|
||||
}
|
||||
if req.SidebarHoverDelayMs != nil {
|
||||
settings.SidebarHoverDelayMs = *req.SidebarHoverDelayMs
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/user/models"
|
||||
"go.uber.org/zap"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSidebarHoverSettingsPublishAndReject(t *testing.T) {
|
||||
log, err := logger.NewFromZap(zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bus := &recordingEventBus{}
|
||||
repo := &recordingUserRepository{getSettings: &models.UserSettings{SidebarHoverEnabled: true, SidebarHoverDelayMs: 500}}
|
||||
svc := NewService(repo, bus, log)
|
||||
invalid := 5001
|
||||
if _, err = svc.UpdateUserSettings(context.Background(), &UpdateUserSettingsRequest{SidebarHoverDelayMs: &invalid}); err == nil {
|
||||
t.Fatal("accepted invalid delay")
|
||||
}
|
||||
if repo.preservingInput != nil || len(bus.publishedEvents) != 0 {
|
||||
t.Fatal("invalid delay persisted or broadcast")
|
||||
}
|
||||
svc.publishUserSettingsEvent(context.Background(), &models.UserSettings{SidebarHoverEnabled: false, SidebarHoverDelayMs: 0})
|
||||
data := bus.publishedEvents[0].Data.(map[string]interface{})
|
||||
if data["sidebar_hover_enabled"] != false || data["sidebar_hover_delay_ms"] != 0 {
|
||||
t.Fatalf("hover event fields: %v, %v", data["sidebar_hover_enabled"], data["sidebar_hover_delay_ms"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSidebarHoverStoredDefaults(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
raw string
|
||||
enabled bool
|
||||
delay float64
|
||||
}{
|
||||
{`{}`, true, 500},
|
||||
{`{"sidebar_hover_delay_ms":"invalid"}`, true, 500},
|
||||
{`{"sidebar_hover_delay_ms":0.5}`, true, 500},
|
||||
{`{"sidebar_hover_delay_ms":null}`, true, 500},
|
||||
{`{"sidebar_hover_enabled":false,"sidebar_hover_delay_ms":0}`, false, 0},
|
||||
{`{"sidebar_hover_delay_ms":1200}`, true, 1200},
|
||||
{`{"sidebar_hover_delay_ms":-1}`, true, 500},
|
||||
{`{"sidebar_hover_delay_ms":5001}`, true, 500},
|
||||
} {
|
||||
t.Run(tc.raw, func(t *testing.T) {
|
||||
settings, err := scanUserSettings(settingsScanner{raw: tc.raw}, DefaultUserID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := marshalUserSettingsPayload(settings)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got map[string]any
|
||||
if err = json.Unmarshal(raw, &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got["sidebar_hover_enabled"] != tc.enabled || got["sidebar_hover_delay_ms"] != tc.delay {
|
||||
t.Fatalf("hover stored = %v, %v", got["sidebar_hover_enabled"], got["sidebar_hover_delay_ms"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -671,6 +671,8 @@ func marshalUserSettingsPayload(settings *models.UserSettings) ([]byte, error) {
|
||||
"last_seen_display": models.NormalizeLastSeenDisplay(settings.LastSeenDisplay),
|
||||
"system_metrics_display": settings.SystemMetricsDisplay,
|
||||
"app_status_bar_enabled": settings.AppStatusBarEnabled,
|
||||
"sidebar_hover_enabled": settings.SidebarHoverEnabled,
|
||||
"sidebar_hover_delay_ms": settings.SidebarHoverDelayMs,
|
||||
"resolve_session_hostnames": settings.ResolveSessionHostnames,
|
||||
"app_status_bar_order": normalizeAppStatusBarOrder(settings.AppStatusBarOrder),
|
||||
"quick_chat_tab_order_by_workspace": quickChatTabOrderByWorkspace,
|
||||
@@ -760,6 +762,8 @@ func defaultUserSettings(userID string) *models.UserSettings {
|
||||
SidebarTaskColorAutomation: models.DefaultSidebarTaskColorAutomation(),
|
||||
SidebarTaskColors: map[string]*string{},
|
||||
AppStatusBarEnabled: false,
|
||||
SidebarHoverEnabled: true,
|
||||
SidebarHoverDelayMs: 500,
|
||||
ResolveSessionHostnames: false,
|
||||
AppStatusBarOrder: normalizeAppStatusBarOrder(models.AppStatusBarOrder{}),
|
||||
QuickChatTabOrderByWorkspace: map[string][]string{},
|
||||
@@ -855,6 +859,8 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin
|
||||
LastSeenDisplay json.RawMessage `json:"last_seen_display"`
|
||||
SystemMetricsDisplay models.SystemMetricsDisplaySettings `json:"system_metrics_display"`
|
||||
AppStatusBarEnabled *bool `json:"app_status_bar_enabled"`
|
||||
SidebarHoverEnabled *bool `json:"sidebar_hover_enabled"`
|
||||
SidebarHoverDelayMs json.RawMessage `json:"sidebar_hover_delay_ms"`
|
||||
ResolveSessionHostnames *bool `json:"resolve_session_hostnames"`
|
||||
AppStatusBarOrder models.AppStatusBarOrder `json:"app_status_bar_order"`
|
||||
QuickChatTabOrderByWorkspace map[string][]string `json:"quick_chat_tab_order_by_workspace"`
|
||||
@@ -1018,6 +1024,13 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin
|
||||
settings.TerminalFontFamily = payload.TerminalFontFamily
|
||||
settings.TerminalFontSize = payload.TerminalFontSize
|
||||
settings.SystemMetricsDisplay = payload.SystemMetricsDisplay
|
||||
if payload.SidebarHoverEnabled != nil {
|
||||
settings.SidebarHoverEnabled = *payload.SidebarHoverEnabled
|
||||
}
|
||||
var hoverDelay *int
|
||||
if json.Unmarshal(payload.SidebarHoverDelayMs, &hoverDelay) == nil && hoverDelay != nil && *hoverDelay >= 0 && *hoverDelay <= 5000 {
|
||||
settings.SidebarHoverDelayMs = *hoverDelay
|
||||
}
|
||||
if payload.AppStatusBarEnabled != nil {
|
||||
settings.AppStatusBarEnabled = *payload.AppStatusBarEnabled
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user