diff --git a/apps/backend/internal/github/mock_client.go b/apps/backend/internal/github/mock_client.go index b23381bb8b..0eba025f37 100644 --- a/apps/backend/internal/github/mock_client.go +++ b/apps/backend/internal/github/mock_client.go @@ -949,6 +949,26 @@ func (m *MockClient) AddPR(pr *PR) { } } +func (m *MockClient) ensurePRHeadSHA(owner, repo string, number int, fallback string) (string, bool) { + m.mu.Lock() + defer m.mu.Unlock() + key := prKey{owner, repo, number} + pr, ok := m.prs[key] + if !ok || pr == nil { + return "", false + } + if pr.HeadSHA == "" { + updated := *pr + updated.HeadSHA = fallback + pr = &updated + m.prs[key] = pr + if pr.HeadBranch != "" { + m.prsByBranch[branchKey{owner, repo, pr.HeadBranch}] = pr + } + } + return pr.HeadSHA, true +} + // SetPRMergeQueue replaces the provider-side queue snapshot for a PR and can // advance its head. It is intentionally separate from AddPR so E2E tests can // drive removal and requeue transitions without replacing the whole fixture. diff --git a/apps/backend/internal/github/mock_controller.go b/apps/backend/internal/github/mock_controller.go index 34ad64b6be..8f57f4ad1d 100644 --- a/apps/backend/internal/github/mock_controller.go +++ b/apps/backend/internal/github/mock_controller.go @@ -909,10 +909,11 @@ func (c *MockController) seedPRFeedback(ctx *gin.Context) { return } headSHA := mockHeadSHA(req.Owner, req.Repo, req.PRNumber) - // If associateTaskPR ran first, an underlying PR row exists with this - // HeadSHA. Otherwise synthesize a minimal one so getPRFeedback's GetPR - // call doesn't fail. - if pr, err := c.mock.GetPR(ctx.Request.Context(), req.Owner, req.Repo, req.PRNumber); err != nil || pr == nil { + // Feedback checks are looked up by the provider PR's head SHA. Create a + // minimal PR when no provider row exists yet. + if existingHeadSHA, found := c.mock.ensurePRHeadSHA(req.Owner, req.Repo, req.PRNumber, headSHA); found { + headSHA = existingHeadSHA + } else { c.mock.AddPR(&PR{ Number: req.PRNumber, RepoOwner: req.Owner, @@ -921,8 +922,6 @@ func (c *MockController) seedPRFeedback(ctx *gin.Context) { CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), }) - } else if pr.HeadSHA != "" { - headSHA = pr.HeadSHA } // Replace prior seeded checks/reviews/comments so a follow-up call gives // deterministic state; helpful for tests that drive a "then a check diff --git a/apps/backend/internal/github/mock_controller_test.go b/apps/backend/internal/github/mock_controller_test.go index 3adcb72fd6..a7c76b4a91 100644 --- a/apps/backend/internal/github/mock_controller_test.go +++ b/apps/backend/internal/github/mock_controller_test.go @@ -83,6 +83,38 @@ func TestMockControllerSeedPRFeedbackClearsPRCaches(t *testing.T) { } } +func TestMockControllerSeedPRFeedbackUsesHeadSHAForExistingPR(t *testing.T) { + router, svc, _ := setupWorkspaceAuthMockController(t) + mock, ok := svc.client.(*MockClient) + if !ok { + t.Fatalf("service client has type %T, want *MockClient", svc.client) + } + mock.AddPR(&PR{Number: 7, RepoOwner: "owner", RepoName: "repo"}) + + response := serveMockJSON(t, router, http.MethodPost, + "/api/v1/github/mock/pr-feedback", + `{"owner":"owner","repo":"repo","pr_number":7,"checks":[{"name":"CI","status":"completed","conclusion":"success"}]}`) + if response.Code != http.StatusOK { + t.Fatalf("seed PR feedback: %d %s", response.Code, response.Body.String()) + } + + pr, err := mock.GetPR(context.Background(), "owner", "repo", 7) + if err != nil { + t.Fatalf("GetPR: %v", err) + } + if pr.HeadSHA == "" { + t.Fatal("seeded feedback left the existing PR without a head SHA") + } + + feedback, err := mock.GetPRFeedback(context.Background(), "owner", "repo", 7) + if err != nil { + t.Fatalf("GetPRFeedback: %v", err) + } + if len(feedback.Checks) != 1 || feedback.Checks[0].Name != "CI" { + t.Fatalf("feedback checks = %#v, want the seeded CI check", feedback.Checks) + } +} + func TestMockControllerWorkflowMutationsClearCachesAndNormalizeEvidence(t *testing.T) { router, svc, _ := setupWorkspaceAuthMockController(t) mock, ok := svc.client.(*MockClient) diff --git a/apps/backend/internal/settingscatalog/defaults.go b/apps/backend/internal/settingscatalog/defaults.go index 153f2e4256..62242f3ff9 100644 --- a/apps/backend/internal/settingscatalog/defaults.go +++ b/apps/backend/internal/settingscatalog/defaults.go @@ -641,7 +641,7 @@ func userSettingsDomain() DomainDescriptor { "sidebar_active_view_id", "active sidebar view", "string", "sidebar_draft", "sidebar draft", "object", "thread_views", "thread views", "array", "thread_active_view_id", "active thread view", "string", "thread_view_draft", "thread draft", "object", "sidebar_task_prefs", "sidebar task preferences", "object", "sidebar_task_color_automation", "sidebar color automation", "object", "sidebar_task_colors", "sidebar task colors", "object", "sidebar_task_color_patch", "sidebar task color patch", "object", "task_create_last_used", "last task create values", "object", - "jira_saved_views", "Jira saved views", "object", "jira_task_presets", "Jira task presets", "object", "github_saved_presets", "GitHub saved presets", "object", + "jira_saved_views", "Jira saved views", "object", "jira_default_view_id", "Jira default view", "string", "jira_task_presets", "Jira task presets", "object", "github_saved_presets", "GitHub saved presets", "object", "github_default_query_presets", "GitHub query presets", "object", "gitlab_saved_presets", "GitLab saved presets", "object", "azure_devops_browse_preferences", "Azure DevOps browse preferences", "object", "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", diff --git a/apps/backend/internal/settingscatalog/defaults_test.go b/apps/backend/internal/settingscatalog/defaults_test.go index 6698642ff7..de4570c2d7 100644 --- a/apps/backend/internal/settingscatalog/defaults_test.go +++ b/apps/backend/internal/settingscatalog/defaults_test.go @@ -50,6 +50,27 @@ func TestDefaultProfileFieldsAreWritableAndBound(t *testing.T) { } } +func TestJiraDefaultViewPreferenceIsCallerOwnedString(t *testing.T) { + registry, err := DefaultRegistry() + if err != nil { + t.Fatal(err) + } + domain, ok := registry.Domain("user_settings") + if !ok { + t.Fatal("user_settings descriptor missing") + } + for _, field := range domain.Fields { + if field.Key != "user_settings.jira_default_view_id" { + continue + } + if field.JSONType != "string" || field.FieldPath != "jira_default_view_id" || field.Authority != "user.self" || field.Owner != "user-settings" { + t.Fatalf("Jira default view descriptor = %+v, want caller-owned string preference", field) + } + return + } + t.Fatal("Jira default view preference is missing from user settings catalog") +} + func TestWritableDomainsExposeConcreteOperationAuthorities(t *testing.T) { registry, err := DefaultRegistry() if err != nil { diff --git a/apps/backend/internal/user/controller/controller.go b/apps/backend/internal/user/controller/controller.go index 82ffe03e11..d0e3e3579f 100644 --- a/apps/backend/internal/user/controller/controller.go +++ b/apps/backend/internal/user/controller/controller.go @@ -130,6 +130,7 @@ func (c *Controller) UpdateUserSettings(ctx context.Context, req dto.UpdateUserS SidebarTaskColorPatch: req.SidebarTaskColorPatch, TaskCreateLastUsed: req.TaskCreateLastUsed, JiraSavedViews: req.JiraSavedViews.ServiceValue(), + JiraDefaultViewID: req.JiraDefaultViewID, JiraTaskPresets: req.JiraTaskPresets.ServiceValue(), GitHubSavedPresets: req.GitHubSavedPresets.ServiceValue(), GitHubDefaultQueryPresets: req.GitHubDefaultQueryPresets.ServiceValue(), diff --git a/apps/backend/internal/user/controller/controller_test.go b/apps/backend/internal/user/controller/controller_test.go index 90dc3d8a43..3627000094 100644 --- a/apps/backend/internal/user/controller/controller_test.go +++ b/apps/backend/internal/user/controller/controller_test.go @@ -80,6 +80,26 @@ func TestUpdateUserSettingsMapsMCPTaskAgentProfileDefault(t *testing.T) { } } +func TestUpdateUserSettingsMapsJiraDefaultViewID(t *testing.T) { + log, err := logger.NewFromZap(zap.NewNop()) + if err != nil { + t.Fatalf("logger.NewFromZap: %v", err) + } + repo := &settingsRepository{settings: &models.UserSettings{JiraDefaultViewID: "old-view"}} + controller := NewController(service.NewService(repo, nil, log)) + want := "custom-view" + + response, err := controller.UpdateUserSettings(context.Background(), dto.UpdateUserSettingsRequest{ + JiraDefaultViewID: &want, + }) + if err != nil { + t.Fatalf("UpdateUserSettings: %v", err) + } + if response.Settings.JiraDefaultViewID != want { + t.Fatalf("JiraDefaultViewID = %q, want %q", response.Settings.JiraDefaultViewID, want) + } +} + func TestUpdateUserSettingsMapsSidebarTaskColorPatch(t *testing.T) { log, err := logger.NewFromZap(zap.NewNop()) if err != nil { diff --git a/apps/backend/internal/user/dto/dto.go b/apps/backend/internal/user/dto/dto.go index 5803b33605..4657ed8029 100644 --- a/apps/backend/internal/user/dto/dto.go +++ b/apps/backend/internal/user/dto/dto.go @@ -62,6 +62,7 @@ type UserSettingsDTO struct { SidebarTaskColors map[string]*string `json:"sidebar_task_colors"` TaskCreateLastUsed models.TaskCreateLastUsed `json:"task_create_last_used"` JiraSavedViews json.RawMessage `json:"jira_saved_views,omitempty"` + JiraDefaultViewID string `json:"jira_default_view_id"` JiraTaskPresets json.RawMessage `json:"jira_task_presets,omitempty"` GitHubSavedPresets json.RawMessage `json:"github_saved_presets,omitempty"` GitHubDefaultQueryPresets json.RawMessage `json:"github_default_query_presets,omitempty"` @@ -178,6 +179,7 @@ type UpdateUserSettingsRequest struct { SidebarTaskColorPatch *models.SidebarTaskColorPatch `json:"sidebar_task_color_patch,omitempty"` TaskCreateLastUsed *models.TaskCreateLastUsed `json:"task_create_last_used,omitempty"` JiraSavedViews NullableRawMessage `json:"jira_saved_views,omitempty"` + JiraDefaultViewID *string `json:"jira_default_view_id,omitempty"` JiraTaskPresets NullableRawMessage `json:"jira_task_presets,omitempty"` GitHubSavedPresets NullableRawMessage `json:"github_saved_presets,omitempty"` GitHubDefaultQueryPresets NullableRawMessage `json:"github_default_query_presets,omitempty"` @@ -378,6 +380,7 @@ func FromUserSettings(settings *models.UserSettings) UserSettingsDTO { SidebarTaskColors: models.CloneSidebarTaskColors(settings.SidebarTaskColors), TaskCreateLastUsed: settings.TaskCreateLastUsed, JiraSavedViews: settings.JiraSavedViews, + JiraDefaultViewID: settings.JiraDefaultViewID, JiraTaskPresets: settings.JiraTaskPresets, GitHubSavedPresets: settings.GitHubSavedPresets, GitHubDefaultQueryPresets: settings.GitHubDefaultQueryPresets, diff --git a/apps/backend/internal/user/dto/dto_test.go b/apps/backend/internal/user/dto/dto_test.go index a7a025f0e5..25ba1a3b3b 100644 --- a/apps/backend/internal/user/dto/dto_test.go +++ b/apps/backend/internal/user/dto/dto_test.go @@ -16,6 +16,35 @@ func TestFromUserSettingsIncludesAtomicRevision(t *testing.T) { } } +func TestJiraDefaultViewSettingsContract(t *testing.T) { + t.Run("response returns saved ID", func(t *testing.T) { + got := FromUserSettings(&models.UserSettings{JiraDefaultViewID: "custom-view"}) + if got.JiraDefaultViewID != "custom-view" { + t.Fatalf("JiraDefaultViewID = %q, want custom-view", got.JiraDefaultViewID) + } + }) + + t.Run("patch distinguishes omission from set and clear", func(t *testing.T) { + var omitted UpdateUserSettingsRequest + if err := json.Unmarshal([]byte(`{}`), &omitted); err != nil { + t.Fatalf("decode omitted patch: %v", err) + } + if omitted.JiraDefaultViewID != nil { + t.Fatalf("omitted JiraDefaultViewID = %v, want nil", omitted.JiraDefaultViewID) + } + + for _, raw := range []string{`{"jira_default_view_id":"custom-view"}`, `{"jira_default_view_id":""}`} { + var req UpdateUserSettingsRequest + if err := json.Unmarshal([]byte(raw), &req); err != nil { + t.Fatalf("decode %s: %v", raw, err) + } + if req.JiraDefaultViewID == nil { + t.Fatalf("%s did not set JiraDefaultViewID", raw) + } + } + }) +} + func TestSidebarTaskColorAutomationDTOContract(t *testing.T) { value := models.SidebarTaskColorAutomation{ Enabled: true, diff --git a/apps/backend/internal/user/models/models.go b/apps/backend/internal/user/models/models.go index 53779641a5..cc56a087f6 100644 --- a/apps/backend/internal/user/models/models.go +++ b/apps/backend/internal/user/models/models.go @@ -157,6 +157,7 @@ type UserSettings struct { SidebarTaskColors map[string]*string `json:"sidebar_task_colors"` TaskCreateLastUsed TaskCreateLastUsed `json:"task_create_last_used"` JiraSavedViews json.RawMessage `json:"jira_saved_views"` + JiraDefaultViewID string `json:"jira_default_view_id"` JiraTaskPresets json.RawMessage `json:"jira_task_presets"` GitHubSavedPresets json.RawMessage `json:"github_saved_presets"` GitHubDefaultQueryPresets json.RawMessage `json:"github_default_query_presets"` diff --git a/apps/backend/internal/user/service/jira_default_view_test.go b/apps/backend/internal/user/service/jira_default_view_test.go new file mode 100644 index 0000000000..aac03b5479 --- /dev/null +++ b/apps/backend/internal/user/service/jira_default_view_test.go @@ -0,0 +1,41 @@ +package service + +import ( + "testing" + + "github.com/kandev/kandev/internal/user/models" +) + +func TestApplyBasicSettingsJiraDefaultViewPatch(t *testing.T) { + t.Run("omitted field preserves previous default", func(t *testing.T) { + settings := &models.UserSettings{JiraDefaultViewID: "old-view"} + if err := applyBasicSettings(settings, &UpdateUserSettingsRequest{}); err != nil { + t.Fatalf("apply basic settings: %v", err) + } + if settings.JiraDefaultViewID != "old-view" { + t.Fatalf("JiraDefaultViewID = %q, want old-view", settings.JiraDefaultViewID) + } + }) + + t.Run("nonempty value is trimmed and set", func(t *testing.T) { + value := " custom-view " + settings := &models.UserSettings{} + if err := applyBasicSettings(settings, &UpdateUserSettingsRequest{JiraDefaultViewID: &value}); err != nil { + t.Fatalf("apply basic settings: %v", err) + } + if settings.JiraDefaultViewID != "custom-view" { + t.Fatalf("JiraDefaultViewID = %q, want custom-view", settings.JiraDefaultViewID) + } + }) + + t.Run("empty value clears the default", func(t *testing.T) { + value := "" + settings := &models.UserSettings{JiraDefaultViewID: "old-view"} + if err := applyBasicSettings(settings, &UpdateUserSettingsRequest{JiraDefaultViewID: &value}); err != nil { + t.Fatalf("apply basic settings: %v", err) + } + if settings.JiraDefaultViewID != "" { + t.Fatalf("JiraDefaultViewID = %q, want empty", settings.JiraDefaultViewID) + } + }) +} diff --git a/apps/backend/internal/user/service/service.go b/apps/backend/internal/user/service/service.go index 3245539ce0..48c6f24eb1 100644 --- a/apps/backend/internal/user/service/service.go +++ b/apps/backend/internal/user/service/service.go @@ -91,6 +91,7 @@ type UpdateUserSettingsRequest struct { SidebarTaskColorPatch *models.SidebarTaskColorPatch TaskCreateLastUsed *models.TaskCreateLastUsed JiraSavedViews **json.RawMessage + JiraDefaultViewID *string JiraTaskPresets **json.RawMessage GitHubSavedPresets **json.RawMessage GitHubDefaultQueryPresets **json.RawMessage @@ -401,6 +402,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 req.JiraDefaultViewID != nil { + settings.JiraDefaultViewID = strings.TrimSpace(*req.JiraDefaultViewID) + } if err := applySidebarHoverSettings(settings, req); err != nil { return err } @@ -1189,6 +1193,7 @@ func (s *Service) publishUserSettingsEvent(ctx context.Context, settings *models "sidebar_task_colors": models.CloneSidebarTaskColors(settings.SidebarTaskColors), "task_create_last_used": settings.TaskCreateLastUsed, "jira_saved_views": settings.JiraSavedViews, + "jira_default_view_id": settings.JiraDefaultViewID, "jira_task_presets": settings.JiraTaskPresets, "github_saved_presets": settings.GitHubSavedPresets, "github_default_query_presets": settings.GitHubDefaultQueryPresets, diff --git a/apps/backend/internal/user/store/sqlite.go b/apps/backend/internal/user/store/sqlite.go index 9fea480cae..11c28fe722 100644 --- a/apps/backend/internal/user/store/sqlite.go +++ b/apps/backend/internal/user/store/sqlite.go @@ -658,6 +658,7 @@ func marshalUserSettingsPayload(settings *models.UserSettings) ([]byte, error) { "sidebar_task_colors": sidebarTaskColors, "task_create_last_used": settings.TaskCreateLastUsed, "jira_saved_views": settings.JiraSavedViews, + "jira_default_view_id": settings.JiraDefaultViewID, "jira_task_presets": settings.JiraTaskPresets, "github_saved_presets": settings.GitHubSavedPresets, "github_default_query_presets": settings.GitHubDefaultQueryPresets, @@ -850,6 +851,7 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin SidebarTaskColors json.RawMessage `json:"sidebar_task_colors"` TaskCreateLastUsed models.TaskCreateLastUsed `json:"task_create_last_used"` JiraSavedViews json.RawMessage `json:"jira_saved_views"` + JiraDefaultViewID string `json:"jira_default_view_id"` JiraTaskPresets json.RawMessage `json:"jira_task_presets"` GitHubSavedPresets json.RawMessage `json:"github_saved_presets"` GitHubDefaultQueryPresets json.RawMessage `json:"github_default_query_presets"` @@ -1017,6 +1019,7 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin settings.SidebarTaskColors = decodeSidebarTaskColors(payload.SidebarTaskColors) settings.TaskCreateLastUsed = payload.TaskCreateLastUsed settings.JiraSavedViews = payload.JiraSavedViews + settings.JiraDefaultViewID = payload.JiraDefaultViewID settings.JiraTaskPresets = payload.JiraTaskPresets settings.GitHubSavedPresets = payload.GitHubSavedPresets settings.GitHubDefaultQueryPresets = payload.GitHubDefaultQueryPresets diff --git a/apps/backend/internal/user/store/sqlite_test.go b/apps/backend/internal/user/store/sqlite_test.go index 1f58e49980..af71cdceeb 100644 --- a/apps/backend/internal/user/store/sqlite_test.go +++ b/apps/backend/internal/user/store/sqlite_test.go @@ -1452,6 +1452,7 @@ func TestSQLiteRepositorySidebarViewStateRoundTrip(t *testing.T) { ExecutorProfileID: "exec-1", } settings.JiraSavedViews = json.RawMessage(`[{"id":"view-1"}]`) + settings.JiraDefaultViewID = "view-1" settings.GitLabSavedPresets = json.RawMessage(`[{"id":"preset-1"}]`) settings.SidebarDraft = &models.SidebarViewDraft{ BaseViewID: "view-1", @@ -1487,6 +1488,9 @@ func TestSQLiteRepositorySidebarViewStateRoundTrip(t *testing.T) { if string(got.JiraSavedViews) != `[{"id":"view-1"}]` { t.Fatalf("expected Jira saved views to round-trip, got %s", string(got.JiraSavedViews)) } + if got.JiraDefaultViewID != "view-1" { + t.Fatalf("expected Jira default view ID to round-trip, got %q", got.JiraDefaultViewID) + } if string(got.GitLabSavedPresets) != `[{"id":"preset-1"}]` { t.Fatalf("expected GitLab presets to round-trip, got %s", string(got.GitLabSavedPresets)) } diff --git a/apps/web/app/jira/jira-page-client.test.tsx b/apps/web/app/jira/jira-page-client.test.tsx new file mode 100644 index 0000000000..092d0e8e94 --- /dev/null +++ b/apps/web/app/jira/jira-page-client.test.tsx @@ -0,0 +1,273 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { fetchUserSettings, updateUserSettings } from "@/lib/api/domains/settings-api"; +import { useJiraFilterState } from "@/components/jira/my-jira/use-jira-filter-state"; +import { + reconcileStatusesForQuery, + useProjectStatuses, +} from "@/components/jira/my-jira/use-project-statuses"; + +const PROJECT_KEY = "PROJ"; +const JIRA_PROJECT_KEY = "CLIP"; +const DEFAULT_VIEW_ID = "custom:default"; +const DEFAULT_VIEW_NAME = "Default"; +const UNASSIGNED_VIEW_ID = "builtin:unassigned"; +const READY_STATUS = "Ready"; +const CUSTOM_VIEW_JQL = `project = ${JIRA_PROJECT_KEY} ORDER BY priority DESC`; +const CUSTOM_STATUS_JQL = `project = ${JIRA_PROJECT_KEY} AND status = ${READY_STATUS} ORDER BY priority DESC`; + +const listJiraProjectStatusesMock = vi.hoisted(() => vi.fn()); + +vi.mock("@/lib/api/domains/settings-api", () => ({ + fetchUserSettings: vi.fn(), + updateUserSettings: vi.fn(), +})); + +vi.mock("@/lib/api/domains/jira-api", () => ({ + listJiraProjectStatuses: listJiraProjectStatusesMock, +})); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + +function makeDefaultView(customJql?: string) { + return { + id: DEFAULT_VIEW_ID, + name: DEFAULT_VIEW_NAME, + filters: { + projectKeys: [JIRA_PROJECT_KEY], + statuses: [READY_STATUS], + assignee: "anyone", + searchText: "", + sort: "priority", + }, + customJql, + }; +} + +function mockDefaultViewSettings(customJql?: string) { + vi.mocked(fetchUserSettings).mockResolvedValueOnce({ + settings: { + jira_saved_views: [makeDefaultView(customJql)], + jira_default_view_id: DEFAULT_VIEW_ID, + }, + } as Awaited>); +} + +function resetPageMocks() { + listJiraProjectStatusesMock.mockReset(); + vi.mocked(updateUserSettings).mockResolvedValue({ settings: {} } as Awaited< + ReturnType + >); +} + +describe("Jira page saved view hydration", () => { + beforeEach(resetPageMocks); + + it("does not replace a manual selection when settings hydrate later", async () => { + const settings = deferred>>(); + vi.mocked(fetchUserSettings).mockReturnValueOnce(settings.promise); + const { result } = renderHook(() => useJiraFilterState(PROJECT_KEY)); + + expect(result.current.initialSelectionResolved).toBe(false); + act(() => result.current.selectView(UNASSIGNED_VIEW_ID)); + settings.resolve({ + settings: { + jira_saved_views: [makeDefaultView('project = OTHER AND text ~ "custom query"')], + jira_default_view_id: DEFAULT_VIEW_ID, + }, + } as Awaited>); + + await waitFor(() => expect(result.current.initialSelectionResolved).toBe(true)); + expect(result.current.activeViewId).toBe(UNASSIGNED_VIEW_ID); + expect(result.current.filters.assignee).toBe("unassigned"); + expect(result.current.customJql).toBeNull(); + }); + + it("restores custom JQL before marking initial selection resolved", async () => { + mockDefaultViewSettings(CUSTOM_VIEW_JQL); + const { result } = renderHook(() => useJiraFilterState(PROJECT_KEY)); + + expect(result.current.initialSelectionResolved).toBe(false); + await waitFor(() => expect(result.current.initialSelectionResolved).toBe(true)); + expect(result.current.activeViewId).toBe(DEFAULT_VIEW_ID); + expect(result.current.filters).toEqual({ + projectKeys: [JIRA_PROJECT_KEY], + statuses: [READY_STATUS], + assignee: "anyone", + searchText: "", + sort: "priority", + }); + expect(result.current.customJql).toBe(CUSTOM_VIEW_JQL); + expect(result.current.showJqlEditor).toBe(true); + }); +}); + +describe("Jira page status lookup", () => { + beforeEach(resetPageMocks); + + it("preserves a saved custom-JQL default when its status lookup fails", async () => { + listJiraProjectStatusesMock.mockRejectedValueOnce(new Error("status lookup unavailable")); + mockDefaultViewSettings(CUSTOM_STATUS_JQL); + + const { result } = renderHook(() => { + const filterState = useJiraFilterState(PROJECT_KEY); + const statuses = useProjectStatuses(filterState.filters.projectKeys, "workspace"); + return { filterState, statuses }; + }); + await waitFor(() => { + expect(result.current.filterState.initialSelectionResolved).toBe(true); + expect(result.current.statuses.loaded).toBe(true); + }); + expect(result.current.statuses.options).toEqual([]); + + const reconciled = reconcileStatusesForQuery( + result.current.statuses.loaded, + result.current.filterState.customJql, + result.current.filterState.filters.statuses, + result.current.statuses.options, + result.current.statuses.authoritative, + ); + if (reconciled !== result.current.filterState.filters.statuses) { + act(() => + result.current.filterState.updateFilters({ + ...result.current.filterState.filters, + statuses: reconciled, + }), + ); + } + + expect(result.current.filterState.customJql).toBe(CUSTOM_STATUS_JQL); + expect(result.current.filterState.effectiveJql).toBe(CUSTOM_STATUS_JQL); + expect(result.current.filterState.filters.statuses).toEqual([READY_STATUS]); + }); + + it("preserves structured saved statuses when their status lookup fails", async () => { + listJiraProjectStatusesMock.mockRejectedValueOnce(new Error("status lookup unavailable")); + mockDefaultViewSettings(); + + const { result } = renderHook(() => { + const filterState = useJiraFilterState(PROJECT_KEY); + const statuses = useProjectStatuses(filterState.filters.projectKeys, "workspace"); + return { filterState, statuses }; + }); + await waitFor(() => { + expect(result.current.filterState.initialSelectionResolved).toBe(true); + expect(result.current.statuses.loaded).toBe(true); + }); + expect(result.current.statuses.authoritative).toBe(false); + + const reconciled = reconcileStatusesForQuery( + result.current.statuses.loaded, + result.current.filterState.customJql, + result.current.filterState.filters.statuses, + result.current.statuses.options, + result.current.statuses.authoritative, + ); + if (reconciled !== result.current.filterState.filters.statuses) { + act(() => + result.current.filterState.updateFilters({ + ...result.current.filterState.filters, + statuses: reconciled, + }), + ); + } + + expect(result.current.filterState.customJql).toBeNull(); + expect(result.current.filterState.effectiveJql).toContain(`status in ("${READY_STATUS}")`); + expect(result.current.filterState.filters.statuses).toEqual([READY_STATUS]); + }); +}); + +describe("Jira page default mutations", () => { + beforeEach(resetPageMocks); + + it("changes the future default without changing the current view", async () => { + mockDefaultViewSettings(); + const { result } = renderHook(() => useJiraFilterState(PROJECT_KEY)); + await waitFor(() => expect(result.current.activeViewId).toBe(DEFAULT_VIEW_ID)); + + await act(async () => { + await result.current.setDefaultView(UNASSIGNED_VIEW_ID); + }); + + expect(result.current.defaultViewId).toBe(UNASSIGNED_VIEW_ID); + expect(result.current.activeViewId).toBe(DEFAULT_VIEW_ID); + expect(result.current.filters).toMatchObject({ + projectKeys: [JIRA_PROJECT_KEY], + assignee: "anyone", + }); + }); + + it("returns to Assigned to me after deleting the active default", async () => { + mockDefaultViewSettings(); + const { result } = renderHook(() => useJiraFilterState(PROJECT_KEY)); + await waitFor(() => expect(result.current.activeViewId).toBe(DEFAULT_VIEW_ID)); + + await act(async () => { + await result.current.deleteView(DEFAULT_VIEW_ID); + }); + + expect(updateUserSettings).toHaveBeenCalledWith({ + jira_saved_views: [], + jira_default_view_id: "", + }); + expect(result.current.defaultViewId).toBe(""); + expect(result.current.activeViewId).toBe("builtin:assigned"); + expect(result.current.filters).toMatchObject({ + projectKeys: [PROJECT_KEY], + assignee: "me", + }); + }); + + it("keeps a later view selection when saving the prior view finishes", async () => { + mockDefaultViewSettings(); + const { result } = renderHook(() => useJiraFilterState(PROJECT_KEY)); + await waitFor(() => expect(result.current.activeViewId).toBe(DEFAULT_VIEW_ID)); + + const write = deferred>>(); + vi.mocked(updateUserSettings).mockReturnValueOnce(write.promise); + let savePromise!: Promise; + act(() => { + savePromise = result.current.saveCurrentAsView("Saved snapshot"); + }); + await waitFor(() => expect(updateUserSettings).toHaveBeenCalled()); + + act(() => result.current.selectView(UNASSIGNED_VIEW_ID)); + await act(async () => { + write.resolve({ settings: {} } as Awaited>); + await savePromise; + }); + + expect(result.current.activeViewId).toBe(UNASSIGNED_VIEW_ID); + expect(result.current.filters.assignee).toBe("unassigned"); + }); + + it("keeps a later view selection when deleting the active default finishes", async () => { + mockDefaultViewSettings(); + const { result } = renderHook(() => useJiraFilterState(PROJECT_KEY)); + await waitFor(() => expect(result.current.activeViewId).toBe(DEFAULT_VIEW_ID)); + + const write = deferred>>(); + vi.mocked(updateUserSettings).mockReturnValueOnce(write.promise); + let deletePromise!: Promise; + act(() => { + deletePromise = result.current.deleteView(DEFAULT_VIEW_ID); + }); + await waitFor(() => expect(updateUserSettings).toHaveBeenCalled()); + + act(() => result.current.selectView(UNASSIGNED_VIEW_ID)); + await act(async () => { + write.resolve({ settings: {} } as Awaited>); + await deletePromise; + }); + + expect(result.current.activeViewId).toBe(UNASSIGNED_VIEW_ID); + expect(result.current.filters.assignee).toBe("unassigned"); + }); +}); diff --git a/apps/web/app/jira/jira-page-client.tsx b/apps/web/app/jira/jira-page-client.tsx index 56e5e58340..e90630e9df 100644 --- a/apps/web/app/jira/jira-page-client.tsx +++ b/apps/web/app/jira/jira-page-client.tsx @@ -1,13 +1,13 @@ "use client"; import Link from "@/components/routing/app-link"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { Trans, useTranslation } from "react-i18next"; import { IconTicket } from "@tabler/icons-react"; import { Alert, AlertDescription } from "@kandev/ui/alert"; import { PageShell } from "@/components/page-shell"; import { getJiraConfig, listJiraProjects, searchJiraTickets } from "@/lib/api/domains/jira-api"; -import type { JiraProject, JiraTicket } from "@/lib/types/jira"; +import type { JiraProject, JiraStatus, JiraTicket } from "@/lib/types/jira"; import type { Workflow, WorkflowStep } from "@/lib/types/http"; import { TicketRow } from "@/components/jira/my-jira/ticket-row"; import { useJiraSearch } from "@/components/jira/my-jira/use-jira-search"; @@ -17,14 +17,11 @@ import { QuickTaskLauncher, type JiraLaunchPayload, } from "@/components/jira/my-jira/quick-task-launcher"; +import { DEFAULT_FILTERS } from "@/components/jira/my-jira/filter-model"; +import { isInitialJiraSearchLoading } from "@/components/jira/my-jira/jira-default-view"; +import { useJiraFilterState } from "@/components/jira/my-jira/use-jira-filter-state"; import { - DEFAULT_FILTERS, - filtersToJql, - type FilterState, -} from "@/components/jira/my-jira/filter-model"; -import { DEFAULT_VIEW, useSavedViews } from "@/components/jira/my-jira/use-saved-views"; -import { - reconcileStatuses, + reconcileStatusesForQuery, useProjectStatuses, } from "@/components/jira/my-jira/use-project-statuses"; import { ListToolbar } from "@/components/jira/my-jira/list-toolbar"; @@ -33,6 +30,7 @@ import { ResultsPagination } from "@/components/jira/my-jira/results-pagination" import { JqlEditor } from "@/components/jira/my-jira/jql-editor"; import { useJiraTaskPresets } from "@/components/jira/my-jira/use-task-presets"; import type { JiraTaskPreset } from "@/components/jira/my-jira/presets"; +import { useToast } from "@/components/toast-provider"; type JiraPageClientProps = { workspaceId?: string; @@ -94,12 +92,13 @@ function useJiraPageData(workspaceId?: string) { setConfigured(ok); setDefaultProjectKey(cfg?.defaultProjectKey ?? ""); if (ok) { - try { - const list = await loadUserProjects(workspaceId); - if (!cancelled) setProjects(list); - } catch { - // Non-fatal: pill will just show empty list. Users can still filter by other dims. - } + void loadUserProjects(workspaceId) + .then((list) => { + if (!cancelled) setProjects(list); + }) + .catch(() => { + // Non-fatal: the project pill can stay empty while users search other fields. + }); } } finally { if (!cancelled) setLoaded(true); @@ -114,15 +113,6 @@ function useJiraPageData(workspaceId?: string) { return { loaded, configured, projects, defaultProjectKey }; } -// initialFilters seeds the ticket list with the workspace's default project -// (issue #1588 follow-up) so opening /jira lands on that project pre-selected. -// An empty defaultProjectKey keeps the historical "no project" default. -function initialFilters(defaultProjectKey: string): FilterState { - const key = defaultProjectKey.trim(); - if (!key) return DEFAULT_VIEW.filters; - return { ...DEFAULT_VIEW.filters, projectKeys: [key] }; -} - function TicketResults({ items, loading, @@ -177,35 +167,28 @@ type AuthenticatedViewProps = { onOpenTicket: (ticket: JiraTicket) => void; }; -function AuthenticatedView({ - workspaceId, +type JiraFilterState = ReturnType; +type JiraSearchState = ReturnType; + +function AuthenticatedJiraContent({ + state, + search, + statusOptions, projects, - defaultProjectKey, presets, onStartTask, onOpenTicket, -}: AuthenticatedViewProps) { - const state = useFilterState(defaultProjectKey); - const search = useJiraSearch(workspaceId ?? null, state.effectiveJql); - const { options: statusOptions, loaded: statusesLoaded } = useProjectStatuses( - state.filters.projectKeys, - workspaceId, - ); - - // When the available status union changes (project selection changed, or - // statuses finished loading), drop any selected statuses that are no longer - // offered so the JQL never references a status absent from the selection. - // Gate on statusesLoaded so a saved view's statuses aren't stripped on the - // first render, before useProjectStatuses has fetched the current project's - // statuses (options is still [] until then). - const { filters, updateFilters } = state; - useEffect(() => { - if (!statusesLoaded) return; - const reconciled = reconcileStatuses(filters.statuses, statusOptions); - if (reconciled !== filters.statuses) { - updateFilters({ ...filters, statuses: reconciled }); - } - }, [statusesLoaded, statusOptions, filters, updateFilters]); +}: { + state: JiraFilterState; + search: JiraSearchState; + statusOptions: JiraStatus[]; + projects: JiraProject[]; + presets: JiraTaskPreset[]; + onStartTask: AuthenticatedViewProps["onStartTask"]; + onOpenTicket: AuthenticatedViewProps["onOpenTicket"]; +}) { + const { t } = useTranslation(); + const { toast } = useToast(); return ( <> @@ -214,9 +197,30 @@ function AuthenticatedView({ onSearchChange={(searchText) => state.updateFilters({ ...state.filters, searchText })} views={state.views} activeViewId={state.activeViewId} + defaultViewId={state.defaultViewId} + viewsReady={state.viewsReady} + defaultMutationPending={state.defaultMutationPending} + viewMutationPending={state.viewMutationPending} onSelectView={state.selectView} - onDeleteView={state.deleteView} - onSaveView={state.saveCurrentAsView} + onSetDefaultView={(id) => { + void state.setDefaultView(id).catch(() => { + toast({ description: t("jira:defaultViewSaveFailed"), variant: "error" }); + }); + }} + onDeleteView={(id) => { + void state.deleteView(id).catch(() => { + toast({ description: t("jira:defaultViewDeleteFailed"), variant: "error" }); + }); + }} + onSaveView={(name) => { + void state.saveCurrentAsView(name).catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + toast({ + description: t("jira:saveFailed", { error: message }), + variant: "error", + }); + }); + }} count={search.items.length} loading={search.loading} sort={state.filters.sort} @@ -266,63 +270,66 @@ function AuthenticatedView({ ); } -function useFilterState(defaultProjectKey: string) { - const savedViews = useSavedViews(); - const [filters, setFilters] = useState(() => initialFilters(defaultProjectKey)); - const [activeViewId, setActiveViewId] = useState(DEFAULT_VIEW.id); - const [customJql, setCustomJql] = useState(null); - const [showJqlEditor, setShowJqlEditor] = useState(false); - - const composedJql = useMemo(() => filtersToJql(filters), [filters]); - const effectiveJql = customJql ?? composedJql; - - const updateFilters = useCallback((next: FilterState) => { - setFilters(next); - setActiveViewId(null); - setCustomJql(null); - }, []); - - const selectView = useCallback( - (id: string) => { - const v = savedViews.views.find((x) => x.id === id); - if (!v) return; - setFilters(v.filters); - setActiveViewId(id); - const savedCustomJql = v.customJql ?? null; - setCustomJql(savedCustomJql); - // Auto-open the JQL editor when restoring a JQL-backed view so the user - // can see what's running. - if (savedCustomJql !== null) setShowJqlEditor(true); - }, - [savedViews.views], +function AuthenticatedView({ + workspaceId, + projects, + defaultProjectKey, + presets, + onStartTask, + onOpenTicket, +}: AuthenticatedViewProps) { + const state = useJiraFilterState(defaultProjectKey); + const search = useJiraSearch( + workspaceId ?? null, + state.effectiveJql, + state.initialSelectionResolved, ); + const searchState = isInitialJiraSearchLoading(search.loading, state.initialSelectionResolved) + ? { ...search, loading: true } + : search; + const { + options: statusOptions, + loaded: statusesLoaded, + authoritative: statusesAuthoritative, + } = useProjectStatuses(state.filters.projectKeys, workspaceId); - const saveCurrentAsView = useCallback( - (name: string) => { - const view = savedViews.save(name, filters, customJql); - setActiveViewId(view.id); - }, - [savedViews, filters, customJql], - ); - - const resetCustomJql = useCallback(() => setCustomJql(null), []); - - return { + // When the available status union changes (project selection changed, or + // statuses finished loading), drop unavailable selections from structured + // filters. Saved custom JQL remains the exact query the user chose. + // Gate on completed, authoritative lookups so a saved view's statuses aren't + // stripped before metadata arrives or when the status endpoint fails. + const { filters, updateFilters } = state; + useEffect(() => { + const reconciled = reconcileStatusesForQuery( + statusesLoaded, + state.customJql, + filters.statuses, + statusOptions, + statusesAuthoritative, + ); + if (reconciled !== filters.statuses) { + updateFilters({ ...filters, statuses: reconciled }); + } + }, [ + statusesLoaded, + statusesAuthoritative, + statusOptions, filters, + state.customJql, updateFilters, - views: savedViews.views, - activeViewId, - selectView, - deleteView: savedViews.remove, - saveCurrentAsView, - composedJql, - customJql, - effectiveJql, - applyCustomJql: setCustomJql, - resetCustomJql, - showJqlEditor, - setShowJqlEditor, - }; + ]); + + return ( + + ); } export function JiraPageClient({ workspaceId, workflows, steps }: JiraPageClientProps) { diff --git a/apps/web/components/jira/my-jira/jira-default-view-action.tsx b/apps/web/components/jira/my-jira/jira-default-view-action.tsx new file mode 100644 index 0000000000..b9979e20e1 --- /dev/null +++ b/apps/web/components/jira/my-jira/jira-default-view-action.tsx @@ -0,0 +1,44 @@ +import { IconStar } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { cn } from "@/lib/utils"; + +export function DefaultViewAction({ + viewId, + viewName, + isDefault, + disabled, + onSetDefault, + isFinePointer, +}: { + viewId: string; + viewName: string; + isDefault: boolean; + disabled: boolean; + onSetDefault: (id: string) => void; + isFinePointer: boolean; +}) { + const { t } = useTranslation(); + const label = isDefault + ? t("jira:clearViewAsDefault", { name: viewName }) + : t("jira:setViewAsDefault", { name: viewName }); + const finePointerVisibility = isDefault + ? "opacity-100" + : "opacity-0 group-hover:opacity-100 focus:opacity-100 disabled:opacity-50"; + const className = cn( + "flex shrink-0 cursor-pointer items-center justify-center rounded hover:bg-muted disabled:cursor-not-allowed", + isFinePointer ? cn("h-7 w-7", finePointerVisibility) : "h-12 w-12 opacity-100", + isDefault && "text-amber-500", + ); + return ( + + ); +} diff --git a/apps/web/components/jira/my-jira/jira-default-view.test.ts b/apps/web/components/jira/my-jira/jira-default-view.test.ts new file mode 100644 index 0000000000..f0ea4b2c1b --- /dev/null +++ b/apps/web/components/jira/my-jira/jira-default-view.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import type { SavedView } from "./use-saved-views"; +import { DEFAULT_VIEW } from "./use-saved-views"; +import { + initialFilters, + isInitialJiraSearchLoading, + resolveInitialJiraView, +} from "./jira-default-view"; + +const customView: SavedView = { + id: "custom:triaged", + name: "Triaged tickets", + filters: { + projectKeys: ["CLIP"], + statuses: ["In Development"], + assignee: "anyone", + searchText: "login", + sort: "priority", + }, + customJql: 'project = CLIP AND text ~ "login" ORDER BY priority DESC', +}; + +describe("Jira default view resolution", () => { + it("keeps the search result loading until the initial view is resolved", () => { + expect(isInitialJiraSearchLoading(false, false)).toBe(true); + expect(isInitialJiraSearchLoading(false, true)).toBe(false); + expect(isInitialJiraSearchLoading(true, true)).toBe(true); + }); + + it("restores an available saved custom view without changing its filters or JQL", () => { + expect(resolveInitialJiraView(customView.id, [DEFAULT_VIEW, customView], "PROJ")).toEqual({ + filters: customView.filters, + activeViewId: customView.id, + customJql: customView.customJql, + showJqlEditor: true, + }); + }); + + it("restores an available built-in view", () => { + expect( + resolveInitialJiraView( + "builtin:unassigned", + [ + DEFAULT_VIEW, + { + ...DEFAULT_VIEW, + id: "builtin:unassigned", + filters: { ...DEFAULT_VIEW.filters, assignee: "unassigned" }, + }, + ], + "PROJ", + ), + ).toMatchObject({ + activeViewId: "builtin:unassigned", + filters: { assignee: "unassigned", projectKeys: [] }, + customJql: null, + showJqlEditor: false, + }); + }); + + it.each(["", "custom:deleted"])( + "falls back when the default ID %s is absent", + (defaultViewId) => { + expect(resolveInitialJiraView(defaultViewId, [DEFAULT_VIEW], "PROJ")).toEqual({ + filters: initialFilters("PROJ"), + activeViewId: DEFAULT_VIEW.id, + customJql: null, + showJqlEditor: false, + }); + }, + ); + + it("keeps the existing Assigned to me landing filter when no project key is configured", () => { + expect(initialFilters("")).toEqual(DEFAULT_VIEW.filters); + expect(initialFilters(" CLIP ")).toEqual({ + ...DEFAULT_VIEW.filters, + projectKeys: ["CLIP"], + }); + }); +}); diff --git a/apps/web/components/jira/my-jira/jira-default-view.ts b/apps/web/components/jira/my-jira/jira-default-view.ts new file mode 100644 index 0000000000..7fffc9fe57 --- /dev/null +++ b/apps/web/components/jira/my-jira/jira-default-view.ts @@ -0,0 +1,43 @@ +import type { FilterState } from "./filter-model"; +import { DEFAULT_VIEW, type SavedView } from "./use-saved-views"; + +export type InitialJiraView = { + filters: FilterState; + activeViewId: string; + customJql: string | null; + showJqlEditor: boolean; +}; + +export function isInitialJiraSearchLoading(searchLoading: boolean, selectionResolved: boolean) { + return searchLoading || !selectionResolved; +} + +export function initialFilters(defaultProjectKey: string): FilterState { + const key = defaultProjectKey.trim(); + if (!key) return DEFAULT_VIEW.filters; + return { ...DEFAULT_VIEW.filters, projectKeys: [key] }; +} + +export function resolveInitialJiraView( + defaultViewId: string, + views: SavedView[], + defaultProjectKey: string, +): InitialJiraView { + const view = views.find((candidate) => candidate.id === defaultViewId.trim()); + if (!view) { + return { + filters: initialFilters(defaultProjectKey), + activeViewId: DEFAULT_VIEW.id, + customJql: null, + showJqlEditor: false, + }; + } + + const customJql = view.customJql ?? null; + return { + filters: view.filters, + activeViewId: view.id, + customJql, + showJqlEditor: customJql !== null, + }; +} diff --git a/apps/web/components/jira/my-jira/list-toolbar.test.tsx b/apps/web/components/jira/my-jira/list-toolbar.test.tsx index ed03d8eb89..e2e4c0f8d8 100644 --- a/apps/web/components/jira/my-jira/list-toolbar.test.tsx +++ b/apps/web/components/jira/my-jira/list-toolbar.test.tsx @@ -6,6 +6,7 @@ import type { SavedView } from "./use-saved-views"; import { ListToolbar } from "./list-toolbar"; const DELETE_VIEW_TITLE = "Delete view"; +const CUSTOM_VIEW_NAME = "Sprint bugs"; const responsive = vi.hoisted(() => ({ isFinePointer: false, isMobile: false })); @@ -22,20 +23,32 @@ const BUILTIN: SavedView = { }; const CUSTOM: SavedView = { id: "view-sprint-bugs", - name: "Sprint bugs", + name: CUSTOM_VIEW_NAME, filters: DEFAULT_FILTERS, }; -function renderToolbar(onDeleteView = vi.fn()) { +function renderToolbar( + onDeleteView = vi.fn(), + onSetDefaultView = vi.fn(), + options: { defaultMutationPending?: boolean; viewMutationPending?: boolean } = {}, +) { + const onSelectView = vi.fn(); return { onDeleteView, + onSetDefaultView, + onSelectView, ...render( { afterEach(() => { cleanup(); responsive.isMobile = false; + responsive.isFinePointer = false; }); it("hands a phone saved view to a named sheet outside the closed picker", async () => { responsive.isMobile = true; const { onDeleteView } = renderToolbar(); - const trigger = screen.getByRole("button", { name: "Sprint bugs" }); + const trigger = screen.getByRole("button", { name: CUSTOM_VIEW_NAME }); fireEvent.click(trigger); fireEvent.click(screen.getByTitle(DELETE_VIEW_TITLE)); - const sheet = await screen.findByRole("dialog", { name: "Delete Sprint bugs?" }); + const sheet = await screen.findByRole("dialog", { name: `Delete ${CUSTOM_VIEW_NAME}?` }); expect(sheet.getAttribute("data-slot")).toBe("drawer-content"); expect(document.querySelector('[data-slot="popover-content"]')).toBeNull(); fireEvent.click(within(sheet).getByRole("button", { name: "Cancel" })); @@ -70,19 +84,19 @@ describe("Jira ListToolbar saved views", () => { expect(onDeleteView).not.toHaveBeenCalled(); fireEvent.click(trigger); fireEvent.click(screen.getByTitle(DELETE_VIEW_TITLE)); - fireEvent.click(await screen.findByRole("button", { name: "Delete Sprint bugs" })); + fireEvent.click(await screen.findByRole("button", { name: `Delete ${CUSTOM_VIEW_NAME}` })); await waitFor(() => expect(onDeleteView).toHaveBeenCalledExactlyOnceWith(CUSTOM.id)); }); it("keeps built-ins protected and confirms custom deletion inline on coarse pointers", async () => { const { onDeleteView } = renderToolbar(); - fireEvent.click(screen.getByRole("button", { name: "Sprint bugs" })); + fireEvent.click(screen.getByRole("button", { name: CUSTOM_VIEW_NAME })); expect(screen.getAllByTitle(DELETE_VIEW_TITLE)).toHaveLength(1); fireEvent.click(screen.getByTitle(DELETE_VIEW_TITLE)); expect(onDeleteView).not.toHaveBeenCalled(); - const confirmation = screen.getByRole("group", { name: "Delete Sprint bugs?" }); + const confirmation = screen.getByRole("group", { name: `Delete ${CUSTOM_VIEW_NAME}?` }); expect(within(confirmation).getByRole("button", { name: "Cancel" }).className).toContain( "h-11", ); @@ -90,9 +104,65 @@ describe("Jira ListToolbar saved views", () => { expect(onDeleteView).not.toHaveBeenCalled(); fireEvent.click(screen.getByTitle(DELETE_VIEW_TITLE)); - fireEvent.click(screen.getByRole("button", { name: "Delete Sprint bugs" })); + fireEvent.click(screen.getByRole("button", { name: `Delete ${CUSTOM_VIEW_NAME}` })); await waitFor(() => expect(onDeleteView).toHaveBeenCalledWith(CUSTOM.id)); expect(onDeleteView).toHaveBeenCalledOnce(); }); + + it("sets and clears the default without selecting or deleting the view", async () => { + const { onDeleteView, onSelectView, onSetDefaultView } = renderToolbar(); + fireEvent.click(screen.getByRole("button", { name: CUSTOM_VIEW_NAME })); + + const makeBuiltinDefault = screen.getByRole("button", { + name: "Set Assigned to me as default view", + }); + fireEvent.click(makeBuiltinDefault); + + expect(onSetDefaultView).toHaveBeenCalledExactlyOnceWith(BUILTIN.id); + expect(onSelectView).not.toHaveBeenCalled(); + expect(onDeleteView).not.toHaveBeenCalled(); + }); + + it("keeps the default action touch-sized in the phone picker", () => { + responsive.isMobile = true; + renderToolbar(); + fireEvent.click(screen.getByRole("button", { name: CUSTOM_VIEW_NAME })); + + expect( + screen.getByRole("button", { name: "Set Assigned to me as default view" }).className, + ).toContain("h-12 w-12"); + }); + + it("keeps the active default star visible on fine-pointer desktop", () => { + responsive.isFinePointer = true; + renderToolbar(); + fireEvent.click(screen.getByRole("button", { name: CUSTOM_VIEW_NAME })); + + const star = screen.getByRole("button", { + name: `Clear ${CUSTOM_VIEW_NAME} as default view`, + }); + expect(star.className).toContain("opacity-100"); + expect(star.className).not.toContain("opacity-0"); + }); + + it.each(["defaultMutationPending", "viewMutationPending"] as const)( + "disables view mutations while %s", + (pendingFlag) => { + renderToolbar(vi.fn(), vi.fn(), { [pendingFlag]: true }); + fireEvent.click(screen.getByRole("button", { name: CUSTOM_VIEW_NAME })); + + expect( + (screen.getByTitle("Save current filters as a view") as HTMLButtonElement).disabled, + ).toBe(true); + expect( + ( + screen.getByRole("button", { + name: `Clear ${CUSTOM_VIEW_NAME} as default view`, + }) as HTMLButtonElement + ).disabled, + ).toBe(true); + expect((screen.getByTitle(DELETE_VIEW_TITLE) as HTMLButtonElement).disabled).toBe(true); + }, + ); }); diff --git a/apps/web/components/jira/my-jira/list-toolbar.tsx b/apps/web/components/jira/my-jira/list-toolbar.tsx index 705715cfe5..434e0e5d98 100644 --- a/apps/web/components/jira/my-jira/list-toolbar.tsx +++ b/apps/web/components/jira/my-jira/list-toolbar.tsx @@ -25,6 +25,7 @@ import type { SortKey } from "./filter-model"; import { useTranslation } from "react-i18next"; import { cn } from "@/lib/utils"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; +import { DefaultViewAction } from "./jira-default-view-action"; import { isActionConfirmationTarget } from "@/components/confirmation/action-confirm-popover"; import { SavedTaskViewDeleteConfirmation } from "@/components/confirmation/saved-task-view-delete-confirmation"; import { @@ -44,7 +45,12 @@ type ListToolbarProps = { onSearchChange: (text: string) => void; views: SavedView[]; activeViewId: string | null; + defaultViewId: string; + viewsReady: boolean; + defaultMutationPending: boolean; + viewMutationPending: boolean; onSelectView: (id: string) => void; + onSetDefaultView: (id: string) => void; onDeleteView: (id: string) => void; onSaveView: (name: string) => void; count: number; @@ -61,7 +67,12 @@ export function ListToolbar({ onSearchChange, views, activeViewId, + defaultViewId, + viewsReady, + defaultMutationPending, + viewMutationPending, onSelectView, + onSetDefaultView, onDeleteView, onSaveView, count, @@ -82,11 +93,19 @@ export function ListToolbar({ - +
{loading ? t("jira:loading2") : t("jira:ticketOnThisPage", { count })} @@ -168,13 +187,23 @@ function SearchInput({ value, onChange }: { value: string; onChange: (v: string) function ViewsDropdown({ views, activeViewId, + defaultViewId, + viewsReady, + defaultMutationPending, + viewMutationPending, onSelect, + onSetDefault, onDelete, activeName, }: { views: SavedView[]; activeViewId: string | null; + defaultViewId: string; + viewsReady: boolean; + defaultMutationPending: boolean; + viewMutationPending: boolean; onSelect: (id: string) => void; + onSetDefault: (id: string) => void; onDelete: (id: string) => void; activeName: string | undefined; }) { @@ -197,6 +226,10 @@ function ViewsDropdown({ onConfirmDelete: onDelete, onRegisterDeleteAnchor: deletion.registerAnchor, }; + const selectViewAndClose = (id: string) => { + onSelect(id); + deletion.onOpenChange(false); + }; return ( @@ -209,7 +242,7 @@ function ViewsDropdown({ { if (isActionConfirmationTarget(event.target)) event.preventDefault(); @@ -218,49 +251,127 @@ function ViewsDropdown({ if (isActionConfirmationTarget(event.target)) event.preventDefault(); }} > - { - onSelect(id); - deletion.onOpenChange(false); - }} - {...deleteProps} + defaultViewId={defaultViewId} + viewsReady={viewsReady} + defaultMutationPending={defaultMutationPending} + viewMutationPending={viewMutationPending} + onSetDefault={onSetDefault} + onSelectView={selectViewAndClose} + deleteProps={deleteProps} /> - {custom.length > 0 && ( - <> -
- { - onSelect(id); - deletion.onOpenChange(false); - }} - {...deleteProps} - /> - - )} - {(isMobile || isFinePointer) && deletion.target ? ( - { - if (!nextOpen) deletion.close(); - }} - onConfirm={onDelete} - /> - ) : null} + ); } +function ViewsPickerGroups({ + builtin, + custom, + activeViewId, + defaultViewId, + viewsReady, + defaultMutationPending, + viewMutationPending, + onSetDefault, + onSelectView, + deleteProps, +}: { + builtin: SavedView[]; + custom: SavedView[]; + activeViewId: string | null; + defaultViewId: string; + viewsReady: boolean; + defaultMutationPending: boolean; + viewMutationPending: boolean; + onSetDefault: (id: string) => void; + onSelectView: (id: string) => void; + deleteProps: ViewDeletionProps; +}) { + const { t } = useTranslation(); + return ( + <> + + {custom.length > 0 && ( + <> +
+ + + )} + + ); +} + +function ActiveViewDeleteConfirmation({ + target, + isMobile, + isFinePointer, + triggerRef, + anchorRef, + focusBoundaryRef, + onClose, + onConfirm, +}: { + target: SavedTaskViewDeleteTarget | null; + isMobile: boolean; + isFinePointer: boolean; + triggerRef: RefObject; + anchorRef: RefObject; + focusBoundaryRef: RefObject; + onClose: () => void; + onConfirm: (id: string) => void; +}) { + if (!(isMobile || isFinePointer) || !target) return null; + return ( + { + if (!nextOpen) onClose(); + }} + onConfirm={onConfirm} + /> + ); +} + type ViewDeletionProps = { isFinePointer: boolean; deleteTarget: SavedTaskViewDeleteTarget | null; @@ -275,6 +386,11 @@ function ViewsGroup({ label, views, activeViewId, + defaultViewId, + viewsReady, + defaultMutationPending, + viewMutationPending, + onSetDefault, onSelect, isFinePointer, deleteTarget, @@ -287,6 +403,11 @@ function ViewsGroup({ label: string; views: SavedView[]; activeViewId: string | null; + defaultViewId: string; + viewsReady: boolean; + defaultMutationPending: boolean; + viewMutationPending: boolean; + onSetDefault: (id: string) => void; onSelect: (id: string) => void; } & ViewDeletionProps) { return ( @@ -299,6 +420,9 @@ function ViewsGroup({ key={v.id} view={v} active={v.id === activeViewId} + isDefault={v.id === defaultViewId} + defaultActionDisabled={!viewsReady || defaultMutationPending || viewMutationPending} + onSetDefault={onSetDefault} onSelect={onSelect} isFinePointer={isFinePointer} deleteTarget={deleteTarget} @@ -316,6 +440,9 @@ function ViewsGroup({ function ViewRow({ view, active, + isDefault, + defaultActionDisabled, + onSetDefault, onSelect, isFinePointer, deleteTarget, @@ -327,6 +454,9 @@ function ViewRow({ }: { view: SavedView; active: boolean; + isDefault: boolean; + defaultActionDisabled: boolean; + onSetDefault: (id: string) => void; onSelect: (id: string) => void; } & ViewDeletionProps) { const { t } = useTranslation(); @@ -357,6 +487,14 @@ function ViewRow({ {label} + {!view.builtin && (