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
+94
View File
@@ -8387,6 +8387,84 @@ const docTemplate = `{
}
}
},
"/users/{user}/preferences": {
"get": {
"security": [
{
"CoderSessionToken": []
}
],
"produces": [
"application/json"
],
"tags": [
"Users"
],
"summary": "Get user preference settings",
"operationId": "get-user-preference-settings",
"parameters": [
{
"type": "string",
"description": "User ID, name, or me",
"name": "user",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.UserPreferenceSettings"
}
}
}
},
"put": {
"security": [
{
"CoderSessionToken": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Users"
],
"summary": "Update user preference settings",
"operationId": "update-user-preference-settings",
"parameters": [
{
"type": "string",
"description": "User ID, name, or me",
"name": "user",
"in": "path",
"required": true
},
{
"description": "New preference settings",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.UpdateUserPreferenceSettingsRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.UserPreferenceSettings"
}
}
}
}
},
"/users/{user}/profile": {
"put": {
"security": [
@@ -19254,6 +19332,14 @@ const docTemplate = `{
}
}
},
"codersdk.UpdateUserPreferenceSettingsRequest": {
"type": "object",
"properties": {
"task_notification_alert_dismissed": {
"type": "boolean"
}
}
},
"codersdk.UpdateUserProfileRequest": {
"type": "object",
"required": [
@@ -19639,6 +19725,14 @@ const docTemplate = `{
}
}
},
"codersdk.UserPreferenceSettings": {
"type": "object",
"properties": {
"task_notification_alert_dismissed": {
"type": "boolean"
}
}
},
"codersdk.UserQuietHoursScheduleConfig": {
"type": "object",
"properties": {
+84
View File
@@ -7418,6 +7418,74 @@
}
}
},
"/users/{user}/preferences": {
"get": {
"security": [
{
"CoderSessionToken": []
}
],
"produces": ["application/json"],
"tags": ["Users"],
"summary": "Get user preference settings",
"operationId": "get-user-preference-settings",
"parameters": [
{
"type": "string",
"description": "User ID, name, or me",
"name": "user",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.UserPreferenceSettings"
}
}
}
},
"put": {
"security": [
{
"CoderSessionToken": []
}
],
"consumes": ["application/json"],
"produces": ["application/json"],
"tags": ["Users"],
"summary": "Update user preference settings",
"operationId": "update-user-preference-settings",
"parameters": [
{
"type": "string",
"description": "User ID, name, or me",
"name": "user",
"in": "path",
"required": true
},
{
"description": "New preference settings",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.UpdateUserPreferenceSettingsRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.UserPreferenceSettings"
}
}
}
}
},
"/users/{user}/profile": {
"put": {
"security": [
@@ -17660,6 +17728,14 @@
}
}
},
"codersdk.UpdateUserPreferenceSettingsRequest": {
"type": "object",
"properties": {
"task_notification_alert_dismissed": {
"type": "boolean"
}
}
},
"codersdk.UpdateUserProfileRequest": {
"type": "object",
"required": ["username"],
@@ -18020,6 +18096,14 @@
}
}
},
"codersdk.UserPreferenceSettings": {
"type": "object",
"properties": {
"task_notification_alert_dismissed": {
"type": "boolean"
}
}
},
"codersdk.UserQuietHoursScheduleConfig": {
"type": "object",
"properties": {
+2
View File
@@ -1336,6 +1336,8 @@ func New(options *Options) *API {
})
r.Get("/appearance", api.userAppearanceSettings)
r.Put("/appearance", api.putUserAppearanceSettings)
r.Get("/preferences", api.userPreferenceSettings)
r.Put("/preferences", api.putUserPreferenceSettings)
r.Route("/password", func(r chi.Router) {
r.Use(httpmw.RateLimit(options.LoginRateLimit, time.Minute))
r.Put("/", api.putUserPassword)
+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
+68
View File
@@ -1077,6 +1077,74 @@ func (api *API) putUserAppearanceSettings(rw http.ResponseWriter, r *http.Reques
})
}
// @Summary Get user preference settings
// @ID get-user-preference-settings
// @Security CoderSessionToken
// @Produce json
// @Tags Users
// @Param user path string true "User ID, name, or me"
// @Success 200 {object} codersdk.UserPreferenceSettings
// @Router /users/{user}/preferences [get]
func (api *API) userPreferenceSettings(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
user = httpmw.UserParam(r)
)
taskAlertDismissed, err := api.Database.GetUserTaskNotificationAlertDismissed(ctx, user.ID)
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Error reading user preference settings.",
Detail: err.Error(),
})
return
}
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.UserPreferenceSettings{
TaskNotificationAlertDismissed: taskAlertDismissed,
})
}
// @Summary Update user preference settings
// @ID update-user-preference-settings
// @Security CoderSessionToken
// @Accept json
// @Produce json
// @Tags Users
// @Param user path string true "User ID, name, or me"
// @Param request body codersdk.UpdateUserPreferenceSettingsRequest true "New preference settings"
// @Success 200 {object} codersdk.UserPreferenceSettings
// @Router /users/{user}/preferences [put]
func (api *API) putUserPreferenceSettings(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
user = httpmw.UserParam(r)
)
var params codersdk.UpdateUserPreferenceSettingsRequest
if !httpapi.Read(ctx, rw, r, &params) {
return
}
updatedTaskAlertDismissed, err := api.Database.UpdateUserTaskNotificationAlertDismissed(ctx, database.UpdateUserTaskNotificationAlertDismissedParams{
UserID: user.ID,
TaskNotificationAlertDismissed: params.TaskNotificationAlertDismissed,
})
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error updating user task notification alert dismissed.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.UserPreferenceSettings{
TaskNotificationAlertDismissed: updatedTaskAlertDismissed,
})
}
func isValidFontName(font codersdk.TerminalFontName) bool {
return slices.Contains(codersdk.TerminalFontNames, font)
}
+78 -9
View File
@@ -2192,16 +2192,16 @@ func TestUserTerminalFont(t *testing.T) {
firstUser := coderdtest.CreateFirstUser(t, adminClient)
client, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
// given
initial, err := client.GetUserAppearanceSettings(ctx, "me")
initial, err := client.GetUserAppearanceSettings(ctx, codersdk.Me)
require.NoError(t, err)
require.Equal(t, codersdk.TerminalFontName(""), initial.TerminalFont)
// when
updated, err := client.UpdateUserAppearanceSettings(ctx, "me", codersdk.UpdateUserAppearanceSettingsRequest{
updated, err := client.UpdateUserAppearanceSettings(ctx, codersdk.Me, codersdk.UpdateUserAppearanceSettingsRequest{
ThemePreference: "light",
TerminalFont: "fira-code",
})
@@ -2218,16 +2218,16 @@ func TestUserTerminalFont(t *testing.T) {
firstUser := coderdtest.CreateFirstUser(t, adminClient)
client, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
// given
initial, err := client.GetUserAppearanceSettings(ctx, "me")
initial, err := client.GetUserAppearanceSettings(ctx, codersdk.Me)
require.NoError(t, err)
require.Equal(t, codersdk.TerminalFontName(""), initial.TerminalFont)
// when
_, err = client.UpdateUserAppearanceSettings(ctx, "me", codersdk.UpdateUserAppearanceSettingsRequest{
_, err = client.UpdateUserAppearanceSettings(ctx, codersdk.Me, codersdk.UpdateUserAppearanceSettingsRequest{
ThemePreference: "light",
TerminalFont: "foobar",
})
@@ -2243,16 +2243,16 @@ func TestUserTerminalFont(t *testing.T) {
firstUser := coderdtest.CreateFirstUser(t, adminClient)
client, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
// given
initial, err := client.GetUserAppearanceSettings(ctx, "me")
initial, err := client.GetUserAppearanceSettings(ctx, codersdk.Me)
require.NoError(t, err)
require.Equal(t, codersdk.TerminalFontName(""), initial.TerminalFont)
// when
_, err = client.UpdateUserAppearanceSettings(ctx, "me", codersdk.UpdateUserAppearanceSettingsRequest{
_, err = client.UpdateUserAppearanceSettings(ctx, codersdk.Me, codersdk.UpdateUserAppearanceSettingsRequest{
ThemePreference: "light",
TerminalFont: "",
})
@@ -2262,6 +2262,75 @@ func TestUserTerminalFont(t *testing.T) {
})
}
func TestUserTaskNotificationAlertDismissed(t *testing.T) {
t.Parallel()
t.Run("defaults to false", func(t *testing.T) {
t.Parallel()
adminClient := coderdtest.New(t, nil)
firstUser := coderdtest.CreateFirstUser(t, adminClient)
client, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
// When: getting user preference settings for a user
settings, err := client.GetUserPreferenceSettings(ctx, codersdk.Me)
require.NoError(t, err)
// Then: the task notification alert dismissed should default to false
require.False(t, settings.TaskNotificationAlertDismissed)
})
t.Run("update to true", func(t *testing.T) {
t.Parallel()
adminClient := coderdtest.New(t, nil)
firstUser := coderdtest.CreateFirstUser(t, adminClient)
client, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
// When: user dismisses the task notification alert
updated, err := client.UpdateUserPreferenceSettings(ctx, codersdk.Me, codersdk.UpdateUserPreferenceSettingsRequest{
TaskNotificationAlertDismissed: true,
})
require.NoError(t, err)
// Then: the setting is updated to true
require.True(t, updated.TaskNotificationAlertDismissed)
})
t.Run("update to false", func(t *testing.T) {
t.Parallel()
adminClient := coderdtest.New(t, nil)
firstUser := coderdtest.CreateFirstUser(t, adminClient)
client, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
// Given: user has dismissed the task notification alert
_, err := client.UpdateUserPreferenceSettings(ctx, codersdk.Me, codersdk.UpdateUserPreferenceSettingsRequest{
TaskNotificationAlertDismissed: true,
})
require.NoError(t, err)
// When: the task notification alert dismissal is cleared
// (e.g., when user enables a task notification in the UI settings)
updated, err := client.UpdateUserPreferenceSettings(ctx, codersdk.Me, codersdk.UpdateUserPreferenceSettingsRequest{
TaskNotificationAlertDismissed: false,
})
require.NoError(t, err)
// Then: the setting is updated to false
require.False(t, updated.TaskNotificationAlertDismissed)
})
}
func TestWorkspacesByUser(t *testing.T) {
t.Parallel()
t.Run("Empty", func(t *testing.T) {
+36
View File
@@ -217,6 +217,14 @@ type UpdateUserAppearanceSettingsRequest struct {
TerminalFont TerminalFontName `json:"terminal_font" validate:"required"`
}
type UserPreferenceSettings struct {
TaskNotificationAlertDismissed bool `json:"task_notification_alert_dismissed"`
}
type UpdateUserPreferenceSettingsRequest struct {
TaskNotificationAlertDismissed bool `json:"task_notification_alert_dismissed"`
}
type UpdateUserPasswordRequest struct {
OldPassword string `json:"old_password" validate:""`
Password string `json:"password" validate:"required"`
@@ -514,6 +522,34 @@ func (c *Client) UpdateUserAppearanceSettings(ctx context.Context, user string,
return resp, json.NewDecoder(res.Body).Decode(&resp)
}
// GetUserPreferenceSettings fetches the preference settings for a user.
func (c *Client) GetUserPreferenceSettings(ctx context.Context, user string) (UserPreferenceSettings, error) {
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/preferences", user), nil)
if err != nil {
return UserPreferenceSettings{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return UserPreferenceSettings{}, ReadBodyAsError(res)
}
var resp UserPreferenceSettings
return resp, json.NewDecoder(res.Body).Decode(&resp)
}
// UpdateUserPreferenceSettings updates the preference settings for a user.
func (c *Client) UpdateUserPreferenceSettings(ctx context.Context, user string, req UpdateUserPreferenceSettingsRequest) (UserPreferenceSettings, error) {
res, err := c.Request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/users/%s/preferences", user), req)
if err != nil {
return UserPreferenceSettings{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return UserPreferenceSettings{}, ReadBodyAsError(res)
}
var resp UserPreferenceSettings
return resp, json.NewDecoder(res.Body).Decode(&resp)
}
// UpdateUserPassword updates a user password.
// It calls PUT /users/{user}/password
func (c *Client) UpdateUserPassword(ctx context.Context, user string, req UpdateUserPasswordRequest) error {
+28
View File
@@ -9358,6 +9358,20 @@ Restarts will only happen on weekdays in this list on weeks which line up with W
| `old_password` | string | false | | |
| `password` | string | true | | |
## codersdk.UpdateUserPreferenceSettingsRequest
```json
{
"task_notification_alert_dismissed": true
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
|-------------------------------------|---------|----------|--------------|-------------|
| `task_notification_alert_dismissed` | boolean | false | | |
## codersdk.UpdateUserProfileRequest
```json
@@ -9846,6 +9860,20 @@ If the schedule is empty, the user will be updated to use the default schedule.|
| `name` | string | false | | |
| `value` | string | false | | |
## codersdk.UserPreferenceSettings
```json
{
"task_notification_alert_dismissed": true
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
|-------------------------------------|---------|----------|--------------|-------------|
| `task_notification_alert_dismissed` | boolean | false | | |
## codersdk.UserQuietHoursScheduleConfig
```json
+84
View File
@@ -1241,6 +1241,90 @@ curl -X PUT http://coder-server:8080/api/v2/users/{user}/password \
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## Get user preference settings
### Code samples
```shell
# Example request using curl
curl -X GET http://coder-server:8080/api/v2/users/{user}/preferences \
-H 'Accept: application/json' \
-H 'Coder-Session-Token: API_KEY'
```
`GET /users/{user}/preferences`
### Parameters
| Name | In | Type | Required | Description |
|--------|------|--------|----------|----------------------|
| `user` | path | string | true | User ID, name, or me |
### Example responses
> 200 Response
```json
{
"task_notification_alert_dismissed": true
}
```
### Responses
| Status | Meaning | Description | Schema |
|--------|---------------------------------------------------------|-------------|------------------------------------------------------------------------------|
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.UserPreferenceSettings](schemas.md#codersdkuserpreferencesettings) |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## Update user preference settings
### Code samples
```shell
# Example request using curl
curl -X PUT http://coder-server:8080/api/v2/users/{user}/preferences \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'Coder-Session-Token: API_KEY'
```
`PUT /users/{user}/preferences`
> Body parameter
```json
{
"task_notification_alert_dismissed": true
}
```
### Parameters
| Name | In | Type | Required | Description |
|--------|------|--------------------------------------------------------------------------------------------------------|----------|-------------------------|
| `user` | path | string | true | User ID, name, or me |
| `body` | body | [codersdk.UpdateUserPreferenceSettingsRequest](schemas.md#codersdkupdateuserpreferencesettingsrequest) | true | New preference settings |
### Example responses
> 200 Response
```json
{
"task_notification_alert_dismissed": true
}
```
### Responses
| Status | Meaning | Description | Schema |
|--------|---------------------------------------------------------|-------------|------------------------------------------------------------------------------|
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.UserPreferenceSettings](schemas.md#codersdkuserpreferencesettings) |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## Update user profile
### Code samples
+13
View File
@@ -1483,6 +1483,19 @@ class ApiMethods {
return response.data;
};
getUserPreferenceSettings =
async (): Promise<TypesGen.UserPreferenceSettings> => {
const response = await this.axios.get("/api/v2/users/me/preferences");
return response.data;
};
updateUserPreferenceSettings = async (
req: TypesGen.UpdateUserPreferenceSettingsRequest,
): Promise<TypesGen.UserPreferenceSettings> => {
const response = await this.axios.put("/api/v2/users/me/preferences", req);
return response.data;
};
getUserQuietHoursSchedule = async (
userId: TypesGen.User["id"],
): Promise<TypesGen.UserQuietHoursScheduleResponse> => {
+29
View File
@@ -6,9 +6,11 @@ import type {
RequestOneTimePasscodeRequest,
UpdateUserAppearanceSettingsRequest,
UpdateUserPasswordRequest,
UpdateUserPreferenceSettingsRequest,
UpdateUserProfileRequest,
User,
UserAppearanceSettings,
UserPreferenceSettings,
UsersRequest,
} from "api/typesGenerated";
import {
@@ -277,6 +279,33 @@ export const updateAppearanceSettings = (
};
};
const myPreferencesKey = ["me", "preferences"];
export const preferenceSettings =
(): UseQueryOptions<UserPreferenceSettings> => {
return {
queryKey: myPreferencesKey,
queryFn: () => API.getUserPreferenceSettings(),
};
};
export const updatePreferenceSettings = (
queryClient: QueryClient,
): UseMutationOptions<
UserPreferenceSettings,
unknown,
UpdateUserPreferenceSettingsRequest,
unknown
> => {
return {
mutationFn: (req) => API.updateUserPreferenceSettings(req),
onSuccess: async () =>
await queryClient.invalidateQueries({
queryKey: myPreferencesKey,
}),
};
};
export const requestOneTimePassword = () => {
return {
mutationFn: (req: RequestOneTimePasscodeRequest) =>
+10
View File
@@ -5479,6 +5479,11 @@ export interface UpdateUserPasswordRequest {
readonly password: string;
}
// From codersdk/users.go
export interface UpdateUserPreferenceSettingsRequest {
readonly task_notification_alert_dismissed: boolean;
}
// From codersdk/users.go
export interface UpdateUserProfileRequest {
readonly username: string;
@@ -5707,6 +5712,11 @@ export interface UserParameter {
readonly value: string;
}
// From codersdk/users.go
export interface UserPreferenceSettings {
readonly task_notification_alert_dismissed: boolean;
}
// From codersdk/deployment.go
export interface UserQuietHoursScheduleConfig {
readonly default_schedule: string;
+30
View File
@@ -1,3 +1,7 @@
import type {
NotificationPreference,
NotificationTemplate,
} from "api/typesGenerated";
import { MailIcon, WebhookIcon } from "lucide-react";
// TODO: This should be provided by the auto generated types from codersdk
@@ -26,3 +30,29 @@ export const castNotificationMethod = (value: string) => {
)}`,
);
};
export function isTaskNotification(tmpl: NotificationTemplate): boolean {
return tmpl.group === "Task Events";
}
// Determines if a notification is disabled based on user preferences and system defaults
// A notification is considered disabled if:
// 1. It's NOT enabled by default AND the user hasn't set any preference (undefined), OR
// 2. The user has explicitly disabled it in their preferences
// Returns true if disabled, false if enabled
export function notificationIsDisabled(
disabledPreferences: Record<string, boolean>,
tmpl: NotificationTemplate,
): boolean {
return Boolean(
(!tmpl.enabled_by_default && disabledPreferences[tmpl.id] === undefined) ||
disabledPreferences[tmpl.id],
);
}
// Transforms an array of NotificationPreference objects into a map
// where the key is the template ID and the value is whether it's disabled
// Example: [{ id: "abc", disabled: true }, { id: "def", disabled: false }]
export function selectDisabledPreferences(data: NotificationPreference[]) {
return Object.fromEntries(data.map((pref) => [pref.id, pref.disabled]));
}
+137 -1
View File
@@ -1,6 +1,7 @@
import {
MockDisplayNameTasks,
MockInitializingTasks,
MockSystemNotificationTemplates,
MockTasks,
MockTemplate,
MockUserOwner,
@@ -13,9 +14,9 @@ import {
} from "testHelpers/storybook";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { API } from "api/api";
import { getTemplatesQueryKey } from "api/queries/templates";
import { MockUsers } from "pages/UsersPage/storybookData/users";
import { expect, spyOn, userEvent, waitFor, within } from "storybook/test";
import { getTemplatesQueryKey } from "../../api/queries/templates";
import TasksPage from "./TasksPage";
const meta: Meta<typeof TasksPage> = {
@@ -361,3 +362,138 @@ export const BatchActionsDropdownOpen: Story = {
});
},
};
export const AllTaskNotificationsDisabledAlertVisible: Story = {
parameters: {
queries: [
{
key: ["tasks", { owner: MockUserOwner.username }],
data: MockTasks,
},
{
key: getTemplatesQueryKey({ q: "has-ai-task:true" }),
data: [MockTemplate],
},
{
// User notification preferences: empty because user hasn't changed defaults
// Task notifications are disabled by default (enabled_by_default: false)
key: ["users", MockUserOwner.id, "notifications", "preferences"],
data: [],
},
{
// System notification templates: includes task notifications with enabled_by_default: false
key: ["notifications", "templates", "system"],
data: MockSystemNotificationTemplates,
},
{
// User preferences: alert NOT dismissed
key: ["me", "preferences"],
data: { task_notification_alert_dismissed: false },
},
],
},
};
export const AllTaskNotificationsDisabledAlertDismissed: Story = {
parameters: {
queries: [
{
key: ["tasks", { owner: MockUserOwner.username }],
data: MockTasks,
},
{
key: getTemplatesQueryKey({ q: "has-ai-task:true" }),
data: [MockTemplate],
},
{
// User notification preferences: empty because user hasn't changed defaults
// Task notifications are disabled by default (enabled_by_default: false)
key: ["users", MockUserOwner.id, "notifications", "preferences"],
data: [],
},
{
// System notification templates: includes task notifications with enabled_by_default: false
key: ["notifications", "templates", "system"],
data: MockSystemNotificationTemplates,
},
{
// User preferences: alert IS dismissed
key: ["me", "preferences"],
data: { task_notification_alert_dismissed: true },
},
],
},
};
export const OneTaskNotificationEnabledAlertHidden: Story = {
parameters: {
queries: [
{
key: ["tasks", { owner: MockUserOwner.username }],
data: MockTasks,
},
{
key: getTemplatesQueryKey({ q: "has-ai-task:true" }),
data: [MockTemplate],
},
{
// User has explicitly enabled one task notification (Task Working)
// Since at least one task notification is enabled, the warning alert should not appear
key: ["users", MockUserOwner.id, "notifications", "preferences"],
data: [
{
id: "bd4b7168-d05e-4e19-ad0f-3593b77aa90f", // Task Working
disabled: false,
updated_at: new Date().toISOString(),
},
],
},
{
// System notification templates: includes task notifications with enabled_by_default: false
key: ["notifications", "templates", "system"],
data: MockSystemNotificationTemplates,
},
{
// User preferences: doesn't matter since alert shouldn't show anyway
key: ["me", "preferences"],
data: { task_notification_alert_dismissed: false },
},
],
},
};
export const AllTaskNotificationsExplicitlyDisabledAlertVisible: Story = {
parameters: {
queries: [
{
key: ["tasks", { owner: MockUserOwner.username }],
data: MockTasks,
},
{
key: getTemplatesQueryKey({ q: "has-ai-task:true" }),
data: [MockTemplate],
},
{
// User has explicitly disabled a task notification
key: ["users", MockUserOwner.id, "notifications", "preferences"],
data: [
{
id: "d4a6271c-cced-4ed0-84ad-afd02a9c7799", // Task Idle
disabled: true,
updated_at: "2024-08-06T11:58:37.755053Z",
},
],
},
{
// System notification templates: includes task notifications with enabled_by_default: false
key: ["notifications", "templates", "system"],
data: MockSystemNotificationTemplates,
},
{
// User preferences: alert NOT dismissed
key: ["me", "preferences"],
data: { task_notification_alert_dismissed: false },
},
],
},
};
+62 -2
View File
@@ -1,7 +1,15 @@
import { API } from "api/api";
import {
systemNotificationTemplates,
userNotificationPreferences,
} from "api/queries/notifications";
import { templates } from "api/queries/templates";
import {
preferenceSettings,
updatePreferenceSettings,
} from "api/queries/users";
import type { TasksFilter } from "api/typesGenerated";
import { Alert } from "components/Alert/Alert";
import { Badge } from "components/Badge/Badge";
import { Button, type ButtonProps } from "components/Button/Button";
import {
@@ -10,6 +18,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "components/DropdownMenu/DropdownMenu";
import { Link } from "components/Link/Link";
import { Margins } from "components/Margins/Margins";
import {
PageHeader,
@@ -22,9 +31,14 @@ import { useAuthenticated } from "hooks";
import { useSearchParamsKey } from "hooks/useSearchParamsKey";
import { ChevronDownIcon, TrashIcon } from "lucide-react";
import { useDashboard } from "modules/dashboard/useDashboard";
import {
isTaskNotification,
notificationIsDisabled,
selectDisabledPreferences,
} from "modules/notifications/utils";
import { TaskPrompt } from "modules/tasks/TaskPrompt/TaskPrompt";
import { type FC, useState } from "react";
import { useQuery } from "react-query";
import { useMutation, useQueries, useQuery, useQueryClient } from "react-query";
import { cn } from "utils/cn";
import { pageTitle } from "utils/page";
import { BatchDeleteConfirmation } from "./BatchDeleteConfirmation";
@@ -96,10 +110,56 @@ const TasksPage: FC = () => {
(t) => t.workspace_id !== null,
).length;
// Fetch notification preferences and templates
const [disabledPreferencesQuery, systemTemplatesQuery] = useQueries({
queries: [
{
...userNotificationPreferences(user.id),
select: selectDisabledPreferences,
},
systemNotificationTemplates(),
],
});
const disabledPreferences = disabledPreferencesQuery.data ?? {};
// Check if ALL task notifications are disabled
// Returns true only when all task notification templates are disabled.
// If even one is enabled, returns false and the warning won't show.
const allTaskNotificationsDisabled = systemTemplatesQuery.data
?.filter(isTaskNotification)
.every((template) => notificationIsDisabled(disabledPreferences, template));
const queryClient = useQueryClient();
const preferencesQuery = useQuery(preferenceSettings());
const updatePreferencesMutation = useMutation(
updatePreferenceSettings(queryClient),
);
const taskNotificationAlertDismissed =
preferencesQuery.data?.task_notification_alert_dismissed ?? false;
return (
<>
<title>{pageTitle("AI Tasks")}</title>
<Margins>
{allTaskNotificationsDisabled && !taskNotificationAlertDismissed && (
<div className="mt-6">
<Alert
severity="warning"
dismissible
onDismiss={() => {
updatePreferencesMutation.mutate({
task_notification_alert_dismissed: true,
});
}}
>
Your notifications for tasks status changes are disabled. Go to{" "}
<Link href="/settings/notifications">Account Settings</Link> to
change it.
</Alert>
</div>
)}
<PageHeader>
<PageHeaderTitle>Tasks</PageHeaderTitle>
<PageHeaderSubtitle>Automate tasks with AI</PageHeaderSubtitle>
@@ -18,7 +18,7 @@ import {
systemNotificationTemplatesKey,
userNotificationPreferencesKey,
} from "api/queries/notifications";
import { expect, spyOn, userEvent, within } from "storybook/test";
import { expect, spyOn, userEvent, waitFor, within } from "storybook/test";
import { reactRouterParameters } from "storybook-addon-remix-react-router";
import NotificationsPage from "./NotificationsPage";
@@ -185,3 +185,69 @@ export const DisableInvalidTemplate: Story = {
await within(document.body).findByText("Error disabling notification");
},
};
export const EnablingTaskNotificationClearsAlertDismissal: Story = {
parameters: {
queries: [
{
// User notification preferences: empty because user hasn't changed defaults
// Task notifications are disabled by default (enabled_by_default: false)
key: ["users", MockUserOwner.id, "notifications", "preferences"],
data: [],
},
{
// System notification templates: includes task notifications with enabled_by_default: false
key: ["notifications", "templates", "system"],
data: MockSystemNotificationTemplates,
},
{
key: customNotificationTemplatesKey,
data: MockCustomNotificationTemplates,
},
{
key: notificationDispatchMethodsKey,
data: MockNotificationMethodsResponse,
},
{
// User preferences: alert was previously dismissed
key: ["me", "preferences"],
data: { task_notification_alert_dismissed: true },
},
],
},
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);
// Mock the API call to update notification preferences
spyOn(API, "putUserNotificationPreferences").mockResolvedValue([
{
id: "d4a6271c-cced-4ed0-84ad-afd02a9c7799", // Task Idle
disabled: false,
updated_at: new Date().toISOString(),
},
]);
// Mock the user preferences update to verify the alert dismissal is cleared
const updatePreferencesSpy = spyOn(
API,
"updateUserPreferenceSettings",
).mockResolvedValue({
task_notification_alert_dismissed: false,
});
await step("Enable Task Idle notification", async () => {
// Find the Task Idle checkbox by its label text
const taskIdleToggle = canvas.getByLabelText("Task Idle");
// Click to enable it
await userEvent.click(taskIdleToggle);
// Verify the preferences API was called to clear the alert dismissal
await waitFor(() => {
expect(updatePreferencesSpy).toHaveBeenCalledWith({
task_notification_alert_dismissed: false,
});
});
});
},
};
@@ -15,10 +15,11 @@ import {
updateUserNotificationPreferences,
userNotificationPreferences,
} from "api/queries/notifications";
import type {
NotificationPreference,
NotificationTemplate,
} from "api/typesGenerated";
import {
preferenceSettings,
updatePreferenceSettings,
} from "api/queries/users";
import type { NotificationTemplate } from "api/typesGenerated";
import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
import { Loader } from "components/Loader/Loader";
import { Stack } from "components/Stack/Stack";
@@ -30,12 +31,15 @@ import {
import { useAuthenticated } from "hooks";
import {
castNotificationMethod,
isTaskNotification,
methodIcons,
methodLabels,
notificationIsDisabled,
selectDisabledPreferences,
} from "modules/notifications/utils";
import type { Permissions } from "modules/permissions";
import { type FC, Fragment, useEffect } from "react";
import { useMutation, useQueries, useQueryClient } from "react-query";
import { useMutation, useQueries, useQuery, useQueryClient } from "react-query";
import { useSearchParams } from "react-router";
import { pageTitle } from "utils/page";
import { Section } from "../Section";
@@ -103,6 +107,11 @@ const NotificationsPage: FC = () => {
...customTemplatesByGroup.data,
};
const preferencesQuery = useQuery(preferenceSettings());
const updatePreferencesMutation = useMutation(
updatePreferenceSettings(queryClient),
);
return (
<>
<title>{pageTitle("Notifications Settings")}</title>
@@ -185,6 +194,20 @@ const NotificationsPage: FC = () => {
[tmpl.id]: !checked,
},
});
// Clear the Tasks page warning dismissal when enabling a task notification
// This ensures that if the user disables task notifications again later,
// they will see the warning alert again.
if (
isTaskNotification(tmpl) &&
checked &&
preferencesQuery.data
) {
updatePreferencesMutation.mutate({
task_notification_alert_dismissed: false,
});
}
displaySuccess(
"Notification preferences updated",
);
@@ -250,26 +273,6 @@ function canSeeNotificationGroup(
}
}
function notificationIsDisabled(
disabledPreferences: Record<string, boolean>,
tmpl: NotificationTemplate,
): boolean {
return (
(!tmpl.enabled_by_default && disabledPreferences[tmpl.id] === undefined) ||
!!disabledPreferences[tmpl.id]
);
}
function selectDisabledPreferences(data: NotificationPreference[]) {
return data.reduce(
(acc, pref) => {
acc[pref.id] = pref.disabled;
return acc;
},
{} as Record<string, boolean>,
);
}
const styles = {
listHeader: (theme) => ({
background: theme.palette.background.paper,
+27 -2
View File
@@ -4695,6 +4695,31 @@ export const MockSystemNotificationTemplates: TypesGen.NotificationTemplate[] =
kind: "system",
enabled_by_default: true,
},
{
id: "8c5a4d12-9f7e-4b3a-a1c8-6e4f2d9b5a7c",
name: "Task Completed",
title_template: "Task '{{.Labels.workspace}}' completed",
body_template: "The task '{{.Labels.task}}' has completed successfully.",
actions:
'[{"url": "{{base_url}}/tasks/{{.UserUsername}}/{{.Labels.workspace}}", "label": "View task"}, {"url": "{{base_url}}/@{{.UserUsername}}/{{.Labels.workspace}}", "label": "View workspace"}]',
group: "Task Events",
method: "",
kind: "system",
enabled_by_default: false,
},
{
id: "3b7e8f1a-4c2d-49a6-b5e9-7f3a1c8d6b4e",
name: "Task Failed",
title_template: "Task '{{.Labels.workspace}}' failed",
body_template:
"The task '{{.Labels.task}}' has failed. Check the logs for more details.",
actions:
'[{"url": "{{base_url}}/tasks/{{.UserUsername}}/{{.Labels.workspace}}", "label": "View task"}, {"url": "{{base_url}}/@{{.UserUsername}}/{{.Labels.workspace}}", "label": "View workspace"}]',
group: "Task Events",
method: "",
kind: "system",
enabled_by_default: false,
},
{
id: "d4a6271c-cced-4ed0-84ad-afd02a9c7799",
name: "Task Idle",
@@ -4705,7 +4730,7 @@ export const MockSystemNotificationTemplates: TypesGen.NotificationTemplate[] =
group: "Task Events",
method: "",
kind: "system",
enabled_by_default: true,
enabled_by_default: false,
},
{
id: "bd4b7168-d05e-4e19-ad0f-3593b77aa90f",
@@ -4718,7 +4743,7 @@ export const MockSystemNotificationTemplates: TypesGen.NotificationTemplate[] =
group: "Task Events",
method: "",
kind: "system",
enabled_by_default: true,
enabled_by_default: false,
},
];