Skip to content

Commit 0524b44

Browse files
feat(views): teach saved views to remember who loves them. Views can now be favorited/unfavorited end-to-end with backend support, route handling, and client service helpers. Also added favorite-change event wiring so UI surfaces can stay in sync instead of pretending nothing happened.
1 parent 1bed63a commit 0524b44

6 files changed

Lines changed: 81 additions & 0 deletions

File tree

api/internal/handler/issue_view.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,27 @@ func (h *IssueViewHandler) List(c *gin.Context) {
4343
c.JSON(http.StatusOK, list)
4444
}
4545

46+
// ListFavorites returns issue views favorited by the current user in this workspace.
47+
// GET /api/workspaces/:slug/views/favorites/
48+
func (h *IssueViewHandler) ListFavorites(c *gin.Context) {
49+
user := middleware.GetUser(c)
50+
if user == nil {
51+
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
52+
return
53+
}
54+
slug := c.Param("slug")
55+
list, err := h.IssueView.ListFavorites(c.Request.Context(), slug, user.ID)
56+
if err != nil {
57+
if err == service.ErrProjectForbidden {
58+
c.JSON(http.StatusNotFound, gin.H{"error": "Not found"})
59+
return
60+
}
61+
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list favorite views"})
62+
return
63+
}
64+
c.JSON(http.StatusOK, list)
65+
}
66+
4667
// Create creates a saved issue view.
4768
// POST /api/workspaces/:slug/views/
4869
func (h *IssueViewHandler) Create(c *gin.Context) {
@@ -163,6 +184,18 @@ func (h *IssueViewHandler) Delete(c *gin.Context) {
163184
c.Status(http.StatusNoContent)
164185
}
165186

187+
// FavoriteWrongMethod handles GET (and any unsupported verb) on the favorite URL.
188+
// Browsers open links with GET, so users who paste the API path see a clear message
189+
// instead of a generic 404 — the real actions are POST (favorite) and DELETE (unfavorite).
190+
func (h *IssueViewHandler) FavoriteWrongMethod(c *gin.Context) {
191+
c.Header("Allow", "POST, DELETE")
192+
c.JSON(http.StatusMethodNotAllowed, gin.H{
193+
"error": "Method not allowed",
194+
"detail": "Use POST (while signed in) to favorite this view or DELETE to unfavorite it. " +
195+
"Opening this URL in a tab sends GET, which does not change favorites.",
196+
})
197+
}
198+
166199
// AddFavorite favorites a saved view for the current user.
167200
// POST /api/workspaces/:slug/views/:viewId/favorite
168201
func (h *IssueViewHandler) AddFavorite(c *gin.Context) {

api/internal/router/router.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,11 +228,17 @@ func New(cfg Config) *gin.Engine {
228228

229229
api.GET("/workspaces/:slug/views/", issueViewHandler.List)
230230
api.POST("/workspaces/:slug/views/", issueViewHandler.Create)
231+
api.GET("/workspaces/:slug/views/favorites/", issueViewHandler.ListFavorites)
231232
api.GET("/workspaces/:slug/views/:viewId/", issueViewHandler.Get)
232233
api.PATCH("/workspaces/:slug/views/:viewId/", issueViewHandler.Update)
233234
api.DELETE("/workspaces/:slug/views/:viewId/", issueViewHandler.Delete)
235+
// Favorite: GET is not an action — register so browser opens are explicit 405, not 404.
236+
api.GET("/workspaces/:slug/views/:viewId/favorite", issueViewHandler.FavoriteWrongMethod)
237+
api.GET("/workspaces/:slug/views/:viewId/favorite/", issueViewHandler.FavoriteWrongMethod)
234238
api.POST("/workspaces/:slug/views/:viewId/favorite", issueViewHandler.AddFavorite)
239+
api.POST("/workspaces/:slug/views/:viewId/favorite/", issueViewHandler.AddFavorite)
235240
api.DELETE("/workspaces/:slug/views/:viewId/favorite", issueViewHandler.RemoveFavorite)
241+
api.DELETE("/workspaces/:slug/views/:viewId/favorite/", issueViewHandler.RemoveFavorite)
236242

237243
api.GET("/workspaces/:slug/pages/", pageHandler.List)
238244
api.POST("/workspaces/:slug/pages/", pageHandler.Create)

api/internal/service/issue_view.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,22 @@ func (s *IssueViewService) List(ctx context.Context, workspaceSlug string, proje
8383
return list, nil
8484
}
8585

86+
// ListFavorites returns saved views the current user favorited in the workspace (for sidebar / quick access).
87+
func (s *IssueViewService) ListFavorites(ctx context.Context, workspaceSlug string, userID uuid.UUID) ([]model.IssueView, error) {
88+
workspaceID, err := s.ensureWorkspaceAccess(ctx, workspaceSlug, userID)
89+
if err != nil {
90+
return nil, err
91+
}
92+
list, err := s.ivs.ListFavoritedByUserInWorkspace(ctx, workspaceID, userID)
93+
if err != nil {
94+
return nil, err
95+
}
96+
for i := range list {
97+
list[i].IsFavorite = true
98+
}
99+
return list, nil
100+
}
101+
86102
func (s *IssueViewService) Create(ctx context.Context, workspaceSlug string, projectID *uuid.UUID, userID uuid.UUID, name, description string, query, filters, displayFilters, displayProperties model.JSONMap) (*model.IssueView, error) {
87103
workspaceID, err := s.ensureWorkspaceAccess(ctx, workspaceSlug, userID)
88104
if err != nil {

api/internal/store/issue_view.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,18 @@ func (s *IssueViewStore) ListByWorkspaceID(ctx context.Context, workspaceID uuid
3838
return list, err
3939
}
4040

41+
// ListFavoritedByUserInWorkspace returns issue views the user favorited in this workspace (project or workspace-scoped).
42+
func (s *IssueViewStore) ListFavoritedByUserInWorkspace(ctx context.Context, workspaceID, userID uuid.UUID) ([]model.IssueView, error) {
43+
var list []model.IssueView
44+
err := s.db.WithContext(ctx).
45+
Table("issue_views").
46+
Joins(`INNER JOIN user_favorites ON user_favorites.entity_identifier = issue_views.id AND user_favorites.entity_type = ?`, FavoriteEntityTypeIssueView).
47+
Where("issue_views.workspace_id = ? AND user_favorites.user_id = ? AND issue_views.deleted_at IS NULL", workspaceID, userID).
48+
Order("issue_views.name ASC").
49+
Find(&list).Error
50+
return list, err
51+
}
52+
4153
func (s *IssueViewStore) Update(ctx context.Context, v *model.IssueView) error {
4254
return s.db.WithContext(ctx).Save(v).Error
4355
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/** Dispatched after the user favorites/unfavorites a saved issue view (project or workspace scope). */
2+
export const ISSUE_VIEW_FAVORITES_CHANGED_EVENT = "issue-view-favorites-changed";
3+
4+
export type IssueViewFavoritesChangedDetail = {
5+
workspaceSlug: string;
6+
};

ui/src/services/viewService.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,14 @@ export const viewService = {
2323
return data;
2424
},
2525

26+
/** Favorited saved views for the current user in this workspace (sidebar). */
27+
async listFavorites(workspaceSlug: string): Promise<IssueViewApiResponse[]> {
28+
const { data } = await apiClient.get<IssueViewApiResponse[]>(
29+
`/api/workspaces/${encodeURIComponent(workspaceSlug)}/views/favorites/`,
30+
);
31+
return data ?? [];
32+
},
33+
2634
async get(
2735
workspaceSlug: string,
2836
viewId: string,

0 commit comments

Comments
 (0)