mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
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:
Generated
+94
@@ -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": {
|
||||
|
||||
Generated
+84
@@ -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": {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, ¶ms) {
|
||||
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
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Generated
+28
@@ -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
|
||||
|
||||
Generated
+84
@@ -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
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
Generated
+10
@@ -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;
|
||||
|
||||
@@ -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]));
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user