Skip to content

feat(coderd): add ability to mark workspaces as favorite #11673

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 27 commits into from

Conversation

johnstcn
Copy link
Member

@johnstcn johnstcn commented Jan 17, 2024

Part of #7912

  • Update DB schema + queries (this PR)
  • Add API endpoints to favorite/unfavorite workspace (this PR)
  • Show favorite status in UI/CLI (follow-up)
  • Add CLI commands to favorite/unfavorite workspace (follow-up)
  • Add UI support to favorite/unfavorite workspace (follow-up)

Goal:

When you query workspaces in the UI, your favorite workspaces should show up first (that is, favorite workspaces of other users should be sorted as they would if they were not favorited at all).

Approach:

The 'obvious' approach is to add a boolean column favorite to the workspaces table. This will work for one user, but then all users' favorites go to the top when filtering by multiple users. I decided to avoid this approach unless absolutely required.

Instead, I first tried adding a separate table user_favorites in which we store (user_id, workspace_id) tuples and adding some joins to return the favorite status by user. This proved unwieldy for GetWorkspaceByID as that changes the return signature to a GetWorkspaceByIDRow and breaks everything.

I then changed tactics and instead added a nullable UUID column favorite_of. This stores the UUID of the workspace owner when marked as 'favorite', and is NULL otherwise. We can then pass in the UUID of the caller in order to control the ordering of 'favorites'.

We convert the Favorite status to a boolean value when converting between the respective database and codersdk structs.

I originally considered co-opting the owner parameter for this ordering but instead elected to add a separate parameter to avoid surprises (e.g. querying for owner:larry as curly would then show Larry's favorite workspaces first).

This means that:

  • You must be able to write a workspsace in order to mark it as favorite.
  • Marking someone else's workspace as favorite will still make it show up as their favorite, and not yours.

@johnstcn johnstcn self-assigned this Jan 17, 2024
@johnstcn johnstcn force-pushed the cj/pin-workspaces branch 4 times, most recently from f154058 to 321aca1 Compare January 22, 2024 08:53
@johnstcn johnstcn changed the title wip: feat(coderd): add ability to pin workspaces wip: feat(coderd): add ability to mark workspaces as favo(u?)rite Jan 22, 2024
@johnstcn johnstcn force-pushed the cj/pin-workspaces branch 2 times, most recently from 61da6b9 to 4469c84 Compare January 22, 2024 22:33
@johnstcn johnstcn changed the title wip: feat(coderd): add ability to mark workspaces as favo(u?)rite wip: feat(coderd): add ability to mark workspaces as favorite Jan 22, 2024
@johnstcn johnstcn changed the title wip: feat(coderd): add ability to mark workspaces as favorite feat(coderd): add ability to mark workspaces as favorite Jan 23, 2024
Comment on lines 265 to 267
CASE WHEN workspaces.favorite_of = @order_by_favorite THEN
workspaces.favorite_of = @order_by_favorite
END ASC,
Copy link
Member Author

Choose a reason for hiding this comment

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

self-review: this new parameter order_by_favorite is passed in and contains the requestor's user ID. I tried multiple permutations without the CASE statement but all of them did not result in the desired sort order.

Copy link
Member

Choose a reason for hiding this comment

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

Interesting that you do not need an ELSE here.

I think we can drop the body of the case, and just put true. Just to remove a bit of duplication.

Suggested change
CASE WHEN workspaces.favorite_of = @order_by_favorite THEN
workspaces.favorite_of = @order_by_favorite
END ASC,
CASE WHEN workspaces.favorite_of = @order_by_favorite THEN
true
END ASC,

Comment on lines +7761 to +7766
if strings.Compare(preloadedUsers[w1.ID].Username, preloadedUsers[w2.ID].Username) < 0 {
return true
}

// Order by: workspace names
return sort.StringsAreSorted([]string{w1.Name, w2.Name})
return strings.Compare(w1.Name, w2.Name) < 0
Copy link
Member Author

Choose a reason for hiding this comment

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

Fixed a pre-existing sort ordering bug in dbmem.

@@ -479,55 +479,85 @@ func TestAdminViewAllWorkspaces(t *testing.T) {
func TestWorkspacesSortOrder(t *testing.T) {
Copy link
Member Author

Choose a reason for hiding this comment

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

self-review: this test has been significantly beefed up

Comment on lines +1581 to +1590
s.Run("FavoriteWorkspace", s.Subtest(func(db database.Store, check *expects) {
u := dbgen.User(s.T(), db, database.User{})
ws := dbgen.Workspace(s.T(), db, database.Workspace{OwnerID: u.ID})
check.Args(ws.ID).Asserts(ws, rbac.ActionUpdate).Returns()
}))
s.Run("UnfavoriteWorkspace", s.Subtest(func(db database.Store, check *expects) {
u := dbgen.User(s.T(), db, database.User{})
ws := dbgen.Workspace(s.T(), db, database.Workspace{OwnerID: u.ID})
check.Args(ws.ID).Asserts(ws, rbac.ActionUpdate).Returns()
}))
Copy link
Member Author

Choose a reason for hiding this comment

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

self-review: you need to be able to update a workspace in order to favorite it.

Comment on lines +1 to +3
ALTER TABLE ONLY workspaces
ADD COLUMN favorite_of uuid DEFAULT NULL;
COMMENT ON COLUMN workspaces.favorite_of IS 'FavoriteOf contains the UUID of the workspace owner if the workspace has been favorited.';
Copy link
Member Author

Choose a reason for hiding this comment

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

self-review: just storing a boolean value here doesn't provide enough context to the query to be able to know how to sort favorites.

Copy link
Member

@Emyrk Emyrk Jan 23, 2024

Choose a reason for hiding this comment

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

Do you think there is any benefit to using a zero uuid here instead of null? It just makes you not need to use uuid.NullUUID{...}

Copy link
Member

Choose a reason for hiding this comment

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

Doesn't a workspace have the owner uuid already in the table? If so can't we just use a boolean + that? When I hear "favorite of" I kind of expect the data type to be an array and favorable by many.

If we don't have owner uuid in there, should we just add it vs. the current workaround?

Copy link
Member Author

Choose a reason for hiding this comment

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

I was originally wary of making that conditional on owner_id, but now it's looking like it makes more sense in its current shape.

@johnstcn johnstcn marked this pull request as ready for review January 23, 2024 12:01
@johnstcn johnstcn requested review from Emyrk and mtojek January 23, 2024 12:02
Copy link
Member

@mtojek mtojek left a comment

Choose a reason for hiding this comment

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

I then changed tactics and instead added a nullable UUID column favorite_of. This stores the UUID of the workspace owner when marked as 'favorite', and is NULL otherwise. We can then pass in the UUID of the caller in order to control the ordering of 'favorites'.

A comment on the design. Let's assume that Alice has workspace, and Bob is admin, but would like to mark Alice's workspace as favorite. Is it doable with this design?

@johnstcn
Copy link
Member Author

I then changed tactics and instead added a nullable UUID column favorite_of. This stores the UUID of the workspace owner when marked as 'favorite', and is NULL otherwise. We can then pass in the UUID of the caller in order to control the ordering of 'favorites'.

A comment on the design. Let's assume that Alice has workspace, and Bob is admin, but would like to mark Alice's workspace as favorite. Is it doable with this design?

Bob can mark Alice's workspace as Alice's favorite on her behalf. Favoriting a workspace that you do not own isn't supported here, but is doable with some minor modifications:

  • favorite_of becomes a uuid[]
  • Add user_id to parameters of FavoriteWorkspace / UnfavoriteWorkspace

Is this something you think is worthwhile?

Copy link
Member

@mtojek mtojek left a comment

Choose a reason for hiding this comment

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

Is this something you think is worthwhile?

I would say so, unless PMs have different preferences. Alice might be scared that somebody "hacked" her account and started interacting with "favorites" markers.

favorite_of becomes a uuid[]

I admit that I prefer to have a separate "favorites" table than expanding the workspaces table schema, but I'm not a database expert. I wouldn't like to mix concerns as there is always the risk of growing up the workspace table. You may want to consult our database guy @mafredri :)

@johnstcn
Copy link
Member Author

Is this something you think is worthwhile?

I would say so, unless PMs have different preferences. Alice might be scared that somebody "hacked" her account and started interacting with "favorites" markers.

To be fair, the same can be said of any action an admin takes on a user's resources.

@johnstcn
Copy link
Member Author

I admit that I prefer to have a separate "favorites" table than expanding the workspaces table schema, but I'm not a database expert. I wouldn't like to mix concerns as there is always the risk of growing up the workspace table.

Yeah, this was my first attempt as well. Suffice it to say that adding any sort of join to the GetWorkspaceByID query will break many, many things and requires some annoying workarounds. Migrating this to a separate table down the line is definitely possible if required.

@mtojek
Copy link
Member

mtojek commented Jan 23, 2024

query will break many, many things and requires some annoying workarounds.

... unless you identify API calls that truly require "favorites" data. Maybe it is just the GetWorkspaces API call, so we can replace the database call with GetWorkspacesWithShares. Array field favorites_of []uuid could be an option too if we acknowledge risks above.

Anyway, I understand the complexity, and I don't intend to stop you. I'd like to save us from reimplementing this feature in the future if we make wrong product assumptions.

@Emyrk
Copy link
Member

Emyrk commented Jan 23, 2024

@mtojek Joining tables is more a limitation of our SQLc design. When you do any joins, the data structs become a mess.

So some of our database design, and likely this, is because of that. I think in a pure academic database setting, this could be normalized to another table. But the headache that gives up the stack makes a []uuid column simpler imo.

And table normalization would only really show value if we then use that table for some other queries somehow? Like "Show me the number of favorited workpaces for each template". Then the []uuid column starts to fall short.

We can always normalize later too, that migration doesn't feel too hard.

@mtojek
Copy link
Member

mtojek commented Jan 23, 2024

Like "Show me the number of favorited workpaces for each template". Then the []uuid column starts to fall short.

I'm afraid that devs could perceive it as an invitation to add more joined arrays, and build the single table model.

One more question, I might have missed it in the review, but will it be possible to expose the "favorite" information only to the requester? It would be useless to leak the information about who really "likes" the workspace.

@johnstcn
Copy link
Member Author

Like "Show me the number of favorited workpaces for each template". Then the []uuid column starts to fall short.

I'm afraid that devs could perceive it as an invitation to add more joined arrays, and build the single table model.

One more question, I might have missed it in the review, but will it be possible to expose the "favorite" information only to the requester? It would be useless to leak the information about who really "likes" the workspace.

I think this is covered by convertWorkspace taking the user ID of the requestor and only showing it as 'favorite' if the database struct favorite_of field matches the requestor's ID.

@mtojek
Copy link
Member

mtojek commented Jan 23, 2024

Ok, thanks for clarifying my doubts. Feel free to pick the implementation you prefer.

Copy link
Member

@Emyrk Emyrk left a comment

Choose a reason for hiding this comment

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

Left some nits, I think we should drop allowing admins to favorite other workspaces for now.

My nits are not blocking.

Comment on lines 265 to 267
CASE WHEN workspaces.favorite_of = @order_by_favorite THEN
workspaces.favorite_of = @order_by_favorite
END ASC,
Copy link
Member

Choose a reason for hiding this comment

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

Interesting that you do not need an ELSE here.

I think we can drop the body of the case, and just put true. Just to remove a bit of duplication.

Suggested change
CASE WHEN workspaces.favorite_of = @order_by_favorite THEN
workspaces.favorite_of = @order_by_favorite
END ASC,
CASE WHEN workspaces.favorite_of = @order_by_favorite THEN
true
END ASC,

Comment on lines +1057 to +1064
err := api.Database.FavoriteWorkspace(ctx, workspace.ID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error setting workspace as favorite",
Detail: err.Error(),
})
return
}
Copy link
Member

Choose a reason for hiding this comment

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

It feels weird not to verify that the caller is the person. I understand this is so an admin can favorite a workspace for someone else, but still feels odd 🤔.

Should we just not allow anyone to favorite someone else's workspace for now? If a feature request is made, we can add it back.

Copy link
Member Author

Choose a reason for hiding this comment

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

I'm happy to do that, it does smell like RBAC creep though.

Copy link
Member

Choose a reason for hiding this comment

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

We do a lot of this outside rbac already unfortunately 😢. This stuff doesn't fit in rbac atm.

@Emyrk
Copy link
Member

Emyrk commented Jan 23, 2024

Some notes for posterity:

Sorting by booleans is weird:

SELECT * FROM (VALUES(true), (false), (null)) s(a) ORDER BY a DESC;

| a     |
| ----- |
| null  |
| true  |
| false |
SELECT * FROM (VALUES(true), (false), (null)) s(a) ORDER BY a ASC;

| a     |
| ----- |
| false |
| true  |
| null  |

There is ORDER BY x NULLS FIRST/LAST, but it is better to just remove the possibility of nulls.

Comment on lines +265 to +267
-- COALESCE because the result of the comparison is NULL if not true
-- and this messes up the ordering.
COALESCE(workspaces.favorite_of = @order_by_favorite, false) DESC,
Copy link
Member

Choose a reason for hiding this comment

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

👍

@johnstcn
Copy link
Member Author

This has gotten a bit big and unwieldy so I'm going to close this out and open a new PR with the feedback from this one.

@johnstcn johnstcn closed this Jan 23, 2024
@github-actions github-actions bot locked and limited conversation to collaborators Jan 23, 2024
@github-actions github-actions bot deleted the cj/pin-workspaces branch July 24, 2024 00:06
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants