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}
+14
View File
@@ -1845,6 +1845,13 @@ func (m queryMetricsStore) GetUserStatusCounts(ctx context.Context, arg database
return r0, r1
}
func (m queryMetricsStore) GetUserTaskNotificationAlertDismissed(ctx context.Context, userID uuid.UUID) (bool, error) {
start := time.Now()
r0, r1 := m.s.GetUserTaskNotificationAlertDismissed(ctx, userID)
m.queryLatencies.WithLabelValues("GetUserTaskNotificationAlertDismissed").Observe(time.Since(start).Seconds())
return r0, r1
}
func (m queryMetricsStore) GetUserTerminalFont(ctx context.Context, userID uuid.UUID) (string, error) {
start := time.Now()
r0, r1 := m.s.GetUserTerminalFont(ctx, userID)
@@ -3350,6 +3357,13 @@ func (m queryMetricsStore) UpdateUserStatus(ctx context.Context, arg database.Up
return user, err
}
func (m queryMetricsStore) UpdateUserTaskNotificationAlertDismissed(ctx context.Context, arg database.UpdateUserTaskNotificationAlertDismissedParams) (bool, error) {
start := time.Now()
r0, r1 := m.s.UpdateUserTaskNotificationAlertDismissed(ctx, arg)
m.queryLatencies.WithLabelValues("UpdateUserTaskNotificationAlertDismissed").Observe(time.Since(start).Seconds())
return r0, r1
}
func (m queryMetricsStore) UpdateUserTerminalFont(ctx context.Context, arg database.UpdateUserTerminalFontParams) (database.UserConfig, error) {
start := time.Now()
r0, r1 := m.s.UpdateUserTerminalFont(ctx, arg)
+30
View File
@@ -3942,6 +3942,21 @@ func (mr *MockStoreMockRecorder) GetUserStatusCounts(ctx, arg any) *gomock.Call
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserStatusCounts", reflect.TypeOf((*MockStore)(nil).GetUserStatusCounts), ctx, arg)
}
// GetUserTaskNotificationAlertDismissed mocks base method.
func (m *MockStore) GetUserTaskNotificationAlertDismissed(ctx context.Context, userID uuid.UUID) (bool, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetUserTaskNotificationAlertDismissed", ctx, userID)
ret0, _ := ret[0].(bool)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetUserTaskNotificationAlertDismissed indicates an expected call of GetUserTaskNotificationAlertDismissed.
func (mr *MockStoreMockRecorder) GetUserTaskNotificationAlertDismissed(ctx, userID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserTaskNotificationAlertDismissed", reflect.TypeOf((*MockStore)(nil).GetUserTaskNotificationAlertDismissed), ctx, userID)
}
// GetUserTerminalFont mocks base method.
func (m *MockStore) GetUserTerminalFont(ctx context.Context, userID uuid.UUID) (string, error) {
m.ctrl.T.Helper()
@@ -7174,6 +7189,21 @@ func (mr *MockStoreMockRecorder) UpdateUserStatus(ctx, arg any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserStatus", reflect.TypeOf((*MockStore)(nil).UpdateUserStatus), ctx, arg)
}
// UpdateUserTaskNotificationAlertDismissed mocks base method.
func (m *MockStore) UpdateUserTaskNotificationAlertDismissed(ctx context.Context, arg database.UpdateUserTaskNotificationAlertDismissedParams) (bool, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateUserTaskNotificationAlertDismissed", ctx, arg)
ret0, _ := ret[0].(bool)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// UpdateUserTaskNotificationAlertDismissed indicates an expected call of UpdateUserTaskNotificationAlertDismissed.
func (mr *MockStoreMockRecorder) UpdateUserTaskNotificationAlertDismissed(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserTaskNotificationAlertDismissed", reflect.TypeOf((*MockStore)(nil).UpdateUserTaskNotificationAlertDismissed), ctx, arg)
}
// UpdateUserTerminalFont mocks base method.
func (m *MockStore) UpdateUserTerminalFont(ctx context.Context, arg database.UpdateUserTerminalFontParams) (database.UserConfig, error) {
m.ctrl.T.Helper()
+2
View File
@@ -447,6 +447,7 @@ type sqlcQuerier interface {
// We do not start counting from 0 at the start_time. We check the last status change before the start_time for each user. As such,
// the result shows the total number of users in each status on any particular day.
GetUserStatusCounts(ctx context.Context, arg GetUserStatusCountsParams) ([]GetUserStatusCountsRow, error)
GetUserTaskNotificationAlertDismissed(ctx context.Context, userID uuid.UUID) (bool, error)
GetUserTerminalFont(ctx context.Context, userID uuid.UUID) (string, error)
GetUserThemePreference(ctx context.Context, userID uuid.UUID) (string, error)
GetUserWorkspaceBuildParameters(ctx context.Context, arg GetUserWorkspaceBuildParametersParams) ([]GetUserWorkspaceBuildParametersRow, error)
@@ -714,6 +715,7 @@ type sqlcQuerier interface {
UpdateUserRoles(ctx context.Context, arg UpdateUserRolesParams) (User, error)
UpdateUserSecret(ctx context.Context, arg UpdateUserSecretParams) (UserSecret, error)
UpdateUserStatus(ctx context.Context, arg UpdateUserStatusParams) (User, error)
UpdateUserTaskNotificationAlertDismissed(ctx context.Context, arg UpdateUserTaskNotificationAlertDismissedParams) (bool, error)
UpdateUserTerminalFont(ctx context.Context, arg UpdateUserTerminalFontParams) (UserConfig, error)
UpdateUserThemePreference(ctx context.Context, arg UpdateUserThemePreferenceParams) (UserConfig, error)
UpdateVolumeResourceMonitor(ctx context.Context, arg UpdateVolumeResourceMonitorParams) error
+44
View File
@@ -16324,6 +16324,23 @@ func (q *sqlQuerier) GetUserCount(ctx context.Context, includeSystem bool) (int6
return count, err
}
const getUserTaskNotificationAlertDismissed = `-- name: GetUserTaskNotificationAlertDismissed :one
SELECT
value::boolean as task_notification_alert_dismissed
FROM
user_configs
WHERE
user_id = $1
AND key = 'preference_task_notification_alert_dismissed'
`
func (q *sqlQuerier) GetUserTaskNotificationAlertDismissed(ctx context.Context, userID uuid.UUID) (bool, error) {
row := q.db.QueryRowContext(ctx, getUserTaskNotificationAlertDismissed, userID)
var task_notification_alert_dismissed bool
err := row.Scan(&task_notification_alert_dismissed)
return task_notification_alert_dismissed, err
}
const getUserTerminalFont = `-- name: GetUserTerminalFont :one
SELECT
value as terminal_font
@@ -17076,6 +17093,33 @@ func (q *sqlQuerier) UpdateUserStatus(ctx context.Context, arg UpdateUserStatusP
return i, err
}
const updateUserTaskNotificationAlertDismissed = `-- name: UpdateUserTaskNotificationAlertDismissed :one
INSERT INTO
user_configs (user_id, key, value)
VALUES
($1, 'preference_task_notification_alert_dismissed', ($2::boolean)::text)
ON CONFLICT
ON CONSTRAINT user_configs_pkey
DO UPDATE
SET
value = $2
WHERE user_configs.user_id = $1
AND user_configs.key = 'preference_task_notification_alert_dismissed'
RETURNING value::boolean AS task_notification_alert_dismissed
`
type UpdateUserTaskNotificationAlertDismissedParams struct {
UserID uuid.UUID `db:"user_id" json:"user_id"`
TaskNotificationAlertDismissed bool `db:"task_notification_alert_dismissed" json:"task_notification_alert_dismissed"`
}
func (q *sqlQuerier) UpdateUserTaskNotificationAlertDismissed(ctx context.Context, arg UpdateUserTaskNotificationAlertDismissedParams) (bool, error) {
row := q.db.QueryRowContext(ctx, updateUserTaskNotificationAlertDismissed, arg.UserID, arg.TaskNotificationAlertDismissed)
var task_notification_alert_dismissed bool
err := row.Scan(&task_notification_alert_dismissed)
return task_notification_alert_dismissed, err
}
const updateUserTerminalFont = `-- name: UpdateUserTerminalFont :one
INSERT INTO
user_configs (user_id, key, value)
+23
View File
@@ -168,6 +168,29 @@ WHERE user_configs.user_id = @user_id
AND user_configs.key = 'terminal_font'
RETURNING *;
-- name: GetUserTaskNotificationAlertDismissed :one
SELECT
value::boolean as task_notification_alert_dismissed
FROM
user_configs
WHERE
user_id = @user_id
AND key = 'preference_task_notification_alert_dismissed';
-- name: UpdateUserTaskNotificationAlertDismissed :one
INSERT INTO
user_configs (user_id, key, value)
VALUES
(@user_id, 'preference_task_notification_alert_dismissed', (@task_notification_alert_dismissed::boolean)::text)
ON CONFLICT
ON CONSTRAINT user_configs_pkey
DO UPDATE
SET
value = @task_notification_alert_dismissed
WHERE user_configs.user_id = @user_id
AND user_configs.key = 'preference_task_notification_alert_dismissed'
RETURNING value::boolean AS task_notification_alert_dismissed;
-- name: UpdateUserRoles :one
UPDATE
users