feat: add notification warning alert to Tasks page (#20900)

## Problem

Users may not realize that task notifications are disabled by default.
To improve awareness, we show a warning alert on the Tasks page when all
task notifications are disabled.

**Alert visibility logic:**
- Shows when **all** task notification templates (Task Working, Task
Idle, Task Completed, Task Failed) are disabled
- Can be dismissed by the user, which stores the dismissal in the user
preferences API
- If the user later enables any task notification in Account Settings,
the dismissal state is cleared so the alert will show again if they
disable all notifications in the future

<img width="2980" height="1588" alt="Screenshot 2025-11-25 at 17 48 17"
src="https://github.com/user-attachments/assets/316bf097-d9d2-4489-bc16-2987ba45f45c"
/>

## Changes

- Added a warning alert to the Tasks page when all task notifications
are disabled
- Introduced new `/users/{user}/preferences` endpoint to manage user
preferences (stored in `user_configs` table)
- Alert is dismissible and stores the dismissal state via the new user
preferences API endpoint
- Enabling any task notification in Account Settings clears the
dismissal state via the preferences API
- Added comprehensive Storybook stories for both TasksPage and
NotificationsPage to test all alert visibility states and interactions

Closes: https://github.com/coder/internal/issues/1089
This commit is contained in:
Susana Ferreira
2025-11-28 16:50:59 +00:00
committed by GitHub
parent a8862be546
commit f8d9a8046f
24 changed files with 1028 additions and 40 deletions
+22
View File
@@ -3431,6 +3431,17 @@ func (q *querier) GetUserStatusCounts(ctx context.Context, arg database.GetUserS
return q.db.GetUserStatusCounts(ctx, arg)
}
func (q *querier) GetUserTaskNotificationAlertDismissed(ctx context.Context, userID uuid.UUID) (bool, error) {
user, err := q.db.GetUserByID(ctx, userID)
if err != nil {
return false, err
}
if err := q.authorizeContext(ctx, policy.ActionReadPersonal, user); err != nil {
return false, err
}
return q.db.GetUserTaskNotificationAlertDismissed(ctx, userID)
}
func (q *querier) GetUserTerminalFont(ctx context.Context, userID uuid.UUID) (string, error) {
u, err := q.db.GetUserByID(ctx, userID)
if err != nil {
@@ -5464,6 +5475,17 @@ func (q *querier) UpdateUserStatus(ctx context.Context, arg database.UpdateUserS
return updateWithReturn(q.log, q.auth, fetch, q.db.UpdateUserStatus)(ctx, arg)
}
func (q *querier) UpdateUserTaskNotificationAlertDismissed(ctx context.Context, arg database.UpdateUserTaskNotificationAlertDismissedParams) (bool, error) {
user, err := q.db.GetUserByID(ctx, arg.UserID)
if err != nil {
return false, err
}
if err := q.authorizeContext(ctx, policy.ActionUpdatePersonal, user); err != nil {
return false, err
}
return q.db.UpdateUserTaskNotificationAlertDismissed(ctx, arg)
}
func (q *querier) UpdateUserTerminalFont(ctx context.Context, arg database.UpdateUserTerminalFontParams) (database.UserConfig, error) {
u, err := q.db.GetUserByID(ctx, arg.UserID)
if err != nil {
+16
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"net"
"reflect"
"strconv"
"testing"
"time"
@@ -1477,6 +1478,21 @@ func (s *MethodTestSuite) TestUser() {
dbm.EXPECT().UpdateUserTerminalFont(gomock.Any(), arg).Return(uc, nil).AnyTimes()
check.Args(arg).Asserts(u, policy.ActionUpdatePersonal).Returns(uc)
}))
s.Run("GetUserTaskNotificationAlertDismissed", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
u := testutil.Fake(s.T(), faker, database.User{})
dbm.EXPECT().GetUserByID(gomock.Any(), u.ID).Return(u, nil).AnyTimes()
dbm.EXPECT().GetUserTaskNotificationAlertDismissed(gomock.Any(), u.ID).Return(false, nil).AnyTimes()
check.Args(u.ID).Asserts(u, policy.ActionReadPersonal).Returns(false)
}))
s.Run("UpdateUserTaskNotificationAlertDismissed", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
user := testutil.Fake(s.T(), faker, database.User{})
userConfig := database.UserConfig{UserID: user.ID, Key: "task_notification_alert_dismissed", Value: "false"}
userConfigValue, _ := strconv.ParseBool(userConfig.Value)
arg := database.UpdateUserTaskNotificationAlertDismissedParams{UserID: user.ID, TaskNotificationAlertDismissed: userConfigValue}
dbm.EXPECT().GetUserByID(gomock.Any(), user.ID).Return(user, nil).AnyTimes()
dbm.EXPECT().UpdateUserTaskNotificationAlertDismissed(gomock.Any(), arg).Return(false, nil).AnyTimes()
check.Args(arg).Asserts(user, policy.ActionUpdatePersonal).Returns(userConfigValue)
}))
s.Run("UpdateUserStatus", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
u := testutil.Fake(s.T(), faker, database.User{})
arg := database.UpdateUserStatusParams{ID: u.ID, Status: u.Status, UpdatedAt: u.UpdatedAt}