feat(webapp): notification settings frontend (#8320)

Implements detailed settings for email notifications.

Issues
relates 🚀 [Feature] Drei neue Interaktions-Benachrichtigungen #8280
This commit is contained in:
Max 2025-04-09 15:21:38 +02:00 committed by GitHub
parent fe7bab4675
commit 1b07b06ca7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 1687 additions and 217 deletions

View File

@ -72,7 +72,6 @@ Factory.define('basicUser')
termsAndConditionsAgreedAt: '2019-08-01T10:47:19.212Z',
allowEmbedIframes: false,
showShoutsPublicly: false,
sendNotificationEmails: true,
locale: 'en',
})
.attr('slug', ['slug', 'name'], (slug, name) => {

View File

@ -56,19 +56,18 @@ const createDefaultAdminUser = async (session) => {
`MERGE (e:EmailAddress {
email: "${defaultAdmin.email}",
createdAt: toString(datetime())
})-[:BELONGS_TO]->(u:User {
name: "${defaultAdmin.name}",
encryptedPassword: "${defaultAdmin.password}",
role: "admin",
id: "${defaultAdmin.id}",
slug: "${defaultAdmin.slug}",
createdAt: toString(datetime()),
allowEmbedIframes: false,
showShoutsPublicly: false,
sendNotificationEmails: true,
deleted: false,
disabled: false
})-[:PRIMARY_EMAIL]->(e)`,
})-[:BELONGS_TO]->(u:User {
name: "${defaultAdmin.name}",
encryptedPassword: "${defaultAdmin.password}",
role: "admin",
id: "${defaultAdmin.id}",
slug: "${defaultAdmin.slug}",
createdAt: toString(datetime()),
allowEmbedIframes: false,
showShoutsPublicly: false,
deleted: false,
disabled: false
})-[:PRIMARY_EMAIL]->(e)`,
)
})
try {

View File

@ -0,0 +1,68 @@
import { getDriver } from '@db/neo4j'
export const description =
'Transforms the `sendNotificationEmails` property on User to a multi value system'
export async function up(next) {
const driver = getDriver()
const session = driver.session()
const transaction = session.beginTransaction()
try {
// Implement your migration here.
await transaction.run(`
MATCH (user:User)
SET user.emailNotificationsCommentOnObservedPost = user.sendNotificationEmails
SET user.emailNotificationsMention = user.sendNotificationEmails
SET user.emailNotificationsChatMessage = user.sendNotificationEmails
SET user.emailNotificationsGroupMemberJoined = user.sendNotificationEmails
SET user.emailNotificationsGroupMemberLeft = user.sendNotificationEmails
SET user.emailNotificationsGroupMemberRemoved = user.sendNotificationEmails
SET user.emailNotificationsGroupMemberRoleChanged = user.sendNotificationEmails
REMOVE user.sendNotificationEmails
`)
await transaction.commit()
next()
} catch (error) {
// eslint-disable-next-line no-console
console.log(error)
await transaction.rollback()
// eslint-disable-next-line no-console
console.log('rolled back')
throw new Error(error)
} finally {
session.close()
}
}
export async function down(next) {
const driver = getDriver()
const session = driver.session()
const transaction = session.beginTransaction()
try {
// Implement your migration here.
await transaction.run(`
MATCH (user:User)
SET user.sendNotificationEmails = true
REMOVE user.emailNotificationsCommentOnObservedPost
REMOVE user.emailNotificationsMention
REMOVE user.emailNotificationsChatMessage
REMOVE user.emailNotificationsGroupMemberJoined
REMOVE user.emailNotificationsGroupMemberLeft
REMOVE user.emailNotificationsGroupMemberRemoved
REMOVE user.emailNotificationsGroupMemberRoleChanged
`)
await transaction.commit()
next()
} catch (error) {
// eslint-disable-next-line no-console
console.log(error)
await transaction.rollback()
// eslint-disable-next-line no-console
console.log('rolled back')
throw new Error(error)
} finally {
session.close()
}
}

View File

@ -20,8 +20,10 @@ jest.mock('../helpers/email/sendMail', () => ({
}))
const chatMessageTemplateMock = jest.fn()
const notificationTemplateMock = jest.fn()
jest.mock('../helpers/email/templateBuilder', () => ({
chatMessageTemplate: () => chatMessageTemplateMock(),
notificationTemplate: () => notificationTemplateMock(),
}))
let isUserOnlineMock = jest.fn()
@ -86,8 +88,8 @@ afterAll(async () => {
beforeEach(async () => {
publishSpy.mockClear()
notifiedUser = await neode.create(
'User',
notifiedUser = await Factory.build(
'user',
{
id: 'you',
name: 'Al Capone',
@ -187,6 +189,7 @@ describe('notifications', () => {
describe('commenter is not me', () => {
beforeEach(async () => {
jest.clearAllMocks()
commentContent = 'Commenters comment.'
commentAuthor = await neode.create(
'User',
@ -202,25 +205,8 @@ describe('notifications', () => {
)
})
it('sends me a notification', async () => {
it('sends me a notification and email', async () => {
await createCommentOnPostAction()
const expected = expect.objectContaining({
data: {
notifications: [
{
read: false,
createdAt: expect.any(String),
reason: 'commented_on_post',
from: {
__typename: 'Comment',
id: 'c47',
content: commentContent,
},
relatedUser: null,
},
],
},
})
await expect(
query({
query: notificationQuery,
@ -228,24 +214,85 @@ describe('notifications', () => {
read: false,
},
}),
).resolves.toEqual(expected)
).resolves.toMatchObject(
expect.objectContaining({
data: {
notifications: [
{
read: false,
createdAt: expect.any(String),
reason: 'commented_on_post',
from: {
__typename: 'Comment',
id: 'c47',
content: commentContent,
},
relatedUser: null,
},
],
},
}),
)
// Mail
expect(sendMailMock).toHaveBeenCalledTimes(1)
expect(notificationTemplateMock).toHaveBeenCalledTimes(1)
})
it('sends me no notification if I have blocked the comment author', async () => {
await notifiedUser.relateTo(commentAuthor, 'blocked')
await createCommentOnPostAction()
const expected = expect.objectContaining({
data: { notifications: [] },
})
describe('if I have disabled `emailNotificationsCommentOnObservedPost`', () => {
it('sends me a notification but no email', async () => {
await notifiedUser.update({ emailNotificationsCommentOnObservedPost: false })
await createCommentOnPostAction()
await expect(
query({
query: notificationQuery,
variables: {
read: false,
},
}),
).resolves.toMatchObject(
expect.objectContaining({
data: {
notifications: [
{
read: false,
createdAt: expect.any(String),
reason: 'commented_on_post',
from: {
__typename: 'Comment',
id: 'c47',
content: commentContent,
},
relatedUser: null,
},
],
},
}),
)
await expect(
query({
query: notificationQuery,
variables: {
read: false,
},
}),
).resolves.toEqual(expected)
// No Mail
expect(sendMailMock).not.toHaveBeenCalled()
expect(notificationTemplateMock).not.toHaveBeenCalled()
})
})
describe('if I have blocked the comment author', () => {
it('sends me no notification', async () => {
await notifiedUser.relateTo(commentAuthor, 'blocked')
await createCommentOnPostAction()
const expected = expect.objectContaining({
data: { notifications: [] },
})
await expect(
query({
query: notificationQuery,
variables: {
read: false,
},
}),
).resolves.toEqual(expected)
})
})
})
@ -274,6 +321,7 @@ describe('notifications', () => {
})
beforeEach(async () => {
jest.clearAllMocks()
postAuthor = await neode.create(
'User',
{
@ -296,7 +344,7 @@ describe('notifications', () => {
'Hey <a class="mention" data-mention-id="you" href="/profile/you/al-capone">@al-capone</a> how do you do?'
})
it('sends me a notification', async () => {
it('sends me a notification and email', async () => {
await createPostAction()
const expectedContent =
'Hey <a class="mention" data-mention-id="you" href="/profile/you/al-capone" target="_blank">@al-capone</a> how do you do?'
@ -324,6 +372,47 @@ describe('notifications', () => {
],
},
})
// Mail
expect(sendMailMock).toHaveBeenCalledTimes(1)
expect(notificationTemplateMock).toHaveBeenCalledTimes(1)
})
describe('if I have disabled `emailNotificationsMention`', () => {
it('sends me a notification but no email', async () => {
await notifiedUser.update({ emailNotificationsMention: false })
await createPostAction()
const expectedContent =
'Hey <a class="mention" data-mention-id="you" href="/profile/you/al-capone" target="_blank">@al-capone</a> how do you do?'
await expect(
query({
query: notificationQuery,
variables: {
read: false,
},
}),
).resolves.toMatchObject({
errors: undefined,
data: {
notifications: [
{
read: false,
createdAt: expect.any(String),
reason: 'mentioned_in_post',
from: {
__typename: 'Post',
id: 'p47',
content: expectedContent,
},
},
],
},
})
// Mail
expect(sendMailMock).not.toHaveBeenCalled()
expect(notificationTemplateMock).not.toHaveBeenCalled()
})
})
it('publishes `NOTIFICATION_ADDED` to me', async () => {
@ -689,7 +778,7 @@ describe('notifications', () => {
roomId = room.data.CreateRoom.id
})
describe('chatReceiver is online', () => {
describe('if the chatReceiver is online', () => {
it('sends no email', async () => {
isUserOnlineMock = jest.fn().mockReturnValue(true)
@ -706,7 +795,7 @@ describe('notifications', () => {
})
})
describe('chatReceiver is offline', () => {
describe('if the chatReceiver is offline', () => {
it('sends an email', async () => {
isUserOnlineMock = jest.fn().mockReturnValue(false)
@ -723,7 +812,7 @@ describe('notifications', () => {
})
})
describe('chatReceiver has blocked chatSender', () => {
describe('if the chatReceiver has blocked chatSender', () => {
it('sends no email', async () => {
isUserOnlineMock = jest.fn().mockReturnValue(false)
await chatReceiver.relateTo(chatSender, 'blocked')
@ -741,10 +830,10 @@ describe('notifications', () => {
})
})
describe('chatReceiver has disabled email notifications', () => {
describe('if the chatReceiver has disabled `emailNotificationsChatMessage`', () => {
it('sends no email', async () => {
isUserOnlineMock = jest.fn().mockReturnValue(false)
await chatReceiver.update({ sendNotificationEmails: false })
await chatReceiver.update({ emailNotificationsChatMessage: false })
await mutate({
mutation: createMessageMutation(),
@ -764,8 +853,8 @@ describe('notifications', () => {
let groupOwner
beforeEach(async () => {
groupOwner = await neode.create(
'User',
groupOwner = await Factory.build(
'user',
{
id: 'group-owner',
name: 'Group Owner',
@ -792,7 +881,7 @@ describe('notifications', () => {
})
describe('user joins group', () => {
beforeEach(async () => {
const joinGroupAction = async () => {
authenticatedUser = await notifiedUser.toJson()
await mutate({
mutation: joinGroupMutation(),
@ -802,9 +891,14 @@ describe('notifications', () => {
},
})
authenticatedUser = await groupOwner.toJson()
}
beforeEach(async () => {
jest.clearAllMocks()
})
it('has the notification in database', async () => {
it('sends the group owner a notification and email', async () => {
await joinGroupAction()
await expect(
query({
query: notificationQuery,
@ -828,19 +922,50 @@ describe('notifications', () => {
},
errors: undefined,
})
// Mail
expect(sendMailMock).toHaveBeenCalledTimes(1)
expect(notificationTemplateMock).toHaveBeenCalledTimes(1)
})
describe('if the group owner has disabled `emailNotificationsGroupMemberJoined`', () => {
it('sends the group owner a notification but no email', async () => {
await groupOwner.update({ emailNotificationsGroupMemberJoined: false })
await joinGroupAction()
await expect(
query({
query: notificationQuery,
}),
).resolves.toMatchObject({
data: {
notifications: [
{
read: false,
reason: 'user_joined_group',
createdAt: expect.any(String),
from: {
__typename: 'Group',
id: 'closed-group',
},
relatedUser: {
id: 'you',
},
},
],
},
errors: undefined,
})
// Mail
expect(sendMailMock).not.toHaveBeenCalled()
expect(notificationTemplateMock).not.toHaveBeenCalled()
})
})
})
describe('user leaves group', () => {
beforeEach(async () => {
describe('user joins and leaves group', () => {
const leaveGroupAction = async () => {
authenticatedUser = await notifiedUser.toJson()
await mutate({
mutation: joinGroupMutation(),
variables: {
groupId: 'closed-group',
userId: authenticatedUser.id,
},
})
await mutate({
mutation: leaveGroupMutation(),
variables: {
@ -849,9 +974,22 @@ describe('notifications', () => {
},
})
authenticatedUser = await groupOwner.toJson()
}
beforeEach(async () => {
jest.clearAllMocks()
authenticatedUser = await notifiedUser.toJson()
await mutate({
mutation: joinGroupMutation(),
variables: {
groupId: 'closed-group',
userId: authenticatedUser.id,
},
})
})
it('has two the notification in database', async () => {
it('sends the group owner two notifications and emails', async () => {
await leaveGroupAction()
await expect(
query({
query: notificationQuery,
@ -887,19 +1025,61 @@ describe('notifications', () => {
},
errors: undefined,
})
// Mail
expect(sendMailMock).toHaveBeenCalledTimes(2)
expect(notificationTemplateMock).toHaveBeenCalledTimes(2)
})
describe('if the group owner has disabled `emailNotificationsGroupMemberLeft`', () => {
it('sends the group owner two notification but only only one email', async () => {
await groupOwner.update({ emailNotificationsGroupMemberLeft: false })
await leaveGroupAction()
await expect(
query({
query: notificationQuery,
}),
).resolves.toMatchObject({
data: {
notifications: [
{
read: false,
reason: 'user_left_group',
createdAt: expect.any(String),
from: {
__typename: 'Group',
id: 'closed-group',
},
relatedUser: {
id: 'you',
},
},
{
read: false,
reason: 'user_joined_group',
createdAt: expect.any(String),
from: {
__typename: 'Group',
id: 'closed-group',
},
relatedUser: {
id: 'you',
},
},
],
},
errors: undefined,
})
// Mail
expect(sendMailMock).toHaveBeenCalledTimes(1)
expect(notificationTemplateMock).toHaveBeenCalledTimes(1)
})
})
})
describe('user role in group changes', () => {
beforeEach(async () => {
authenticatedUser = await notifiedUser.toJson()
await mutate({
mutation: joinGroupMutation(),
variables: {
groupId: 'closed-group',
userId: authenticatedUser.id,
},
})
const changeGroupMemberRoleAction = async () => {
authenticatedUser = await groupOwner.toJson()
await mutate({
mutation: changeGroupMemberRoleMutation(),
@ -910,9 +1090,23 @@ describe('notifications', () => {
},
})
authenticatedUser = await notifiedUser.toJson()
}
beforeEach(async () => {
authenticatedUser = await notifiedUser.toJson()
await mutate({
mutation: joinGroupMutation(),
variables: {
groupId: 'closed-group',
userId: authenticatedUser.id,
},
})
// Clear after because the above generates a notification not related
jest.clearAllMocks()
})
it('has notification in database', async () => {
it('sends the group member a notification and email', async () => {
await changeGroupMemberRoleAction()
await expect(
query({
query: notificationQuery,
@ -936,19 +1130,49 @@ describe('notifications', () => {
},
errors: undefined,
})
// Mail
expect(sendMailMock).toHaveBeenCalledTimes(1)
expect(notificationTemplateMock).toHaveBeenCalledTimes(1)
})
describe('if the group member has disabled `emailNotificationsGroupMemberRoleChanged`', () => {
it('sends the group member a notification but no email', async () => {
notifiedUser.update({ emailNotificationsGroupMemberRoleChanged: false })
await changeGroupMemberRoleAction()
await expect(
query({
query: notificationQuery,
}),
).resolves.toMatchObject({
data: {
notifications: [
{
read: false,
reason: 'changed_group_member_role',
createdAt: expect.any(String),
from: {
__typename: 'Group',
id: 'closed-group',
},
relatedUser: {
id: 'group-owner',
},
},
],
},
errors: undefined,
})
// Mail
expect(sendMailMock).not.toHaveBeenCalled()
expect(notificationTemplateMock).not.toHaveBeenCalled()
})
})
})
describe('user is removed from group', () => {
beforeEach(async () => {
authenticatedUser = await notifiedUser.toJson()
await mutate({
mutation: joinGroupMutation(),
variables: {
groupId: 'closed-group',
userId: authenticatedUser.id,
},
})
const removeUserFromGroupAction = async () => {
authenticatedUser = await groupOwner.toJson()
await mutate({
mutation: removeUserFromGroupMutation(),
@ -958,9 +1182,23 @@ describe('notifications', () => {
},
})
authenticatedUser = await notifiedUser.toJson()
}
beforeEach(async () => {
authenticatedUser = await notifiedUser.toJson()
await mutate({
mutation: joinGroupMutation(),
variables: {
groupId: 'closed-group',
userId: authenticatedUser.id,
},
})
// Clear after because the above generates a notification not related
jest.clearAllMocks()
})
it('has notification in database', async () => {
it('sends the previous group member a notification and email', async () => {
await removeUserFromGroupAction()
await expect(
query({
query: notificationQuery,
@ -984,6 +1222,44 @@ describe('notifications', () => {
},
errors: undefined,
})
// Mail
expect(sendMailMock).toHaveBeenCalledTimes(1)
expect(notificationTemplateMock).toHaveBeenCalledTimes(1)
})
describe('if the previous group member has disabled `emailNotificationsGroupMemberRemoved`', () => {
it('sends the previous group member a notification but no email', async () => {
notifiedUser.update({ emailNotificationsGroupMemberRemoved: false })
await removeUserFromGroupAction()
await expect(
query({
query: notificationQuery,
}),
).resolves.toMatchObject({
data: {
notifications: [
{
read: false,
reason: 'removed_user_from_group',
createdAt: expect.any(String),
from: {
__typename: 'Group',
id: 'closed-group',
},
relatedUser: {
id: 'group-owner',
},
},
],
},
errors: undefined,
})
// Mail
expect(sendMailMock).not.toHaveBeenCalled()
expect(notificationTemplateMock).not.toHaveBeenCalled()
})
})
})
})

View File

@ -38,7 +38,7 @@ const queryNotificationEmails = async (context, notificationUserIds) => {
}
}
const publishNotifications = async (context, promises) => {
const publishNotifications = async (context, promises, emailNotificationSetting: string) => {
let notifications = await Promise.all(promises)
notifications = notifications.flat()
const notificationsEmailAddresses = await queryNotificationEmails(
@ -47,7 +47,7 @@ const publishNotifications = async (context, promises) => {
)
notifications.forEach((notificationAdded, index) => {
pubsub.publish(NOTIFICATION_ADDED, { notificationAdded })
if (notificationAdded.to.sendNotificationEmails) {
if (notificationAdded.to[emailNotificationSetting] ?? true) {
sendMail(
notificationTemplate({
email: notificationsEmailAddresses[index].email,
@ -62,9 +62,11 @@ const handleJoinGroup = async (resolve, root, args, context, resolveInfo) => {
const { groupId, userId } = args
const user = await resolve(root, args, context, resolveInfo)
if (user) {
await publishNotifications(context, [
notifyOwnersOfGroup(groupId, userId, 'user_joined_group', context),
])
await publishNotifications(
context,
[notifyOwnersOfGroup(groupId, userId, 'user_joined_group', context)],
'emailNotificationsGroupMemberJoined',
)
}
return user
}
@ -73,9 +75,11 @@ const handleLeaveGroup = async (resolve, root, args, context, resolveInfo) => {
const { groupId, userId } = args
const user = await resolve(root, args, context, resolveInfo)
if (user) {
await publishNotifications(context, [
notifyOwnersOfGroup(groupId, userId, 'user_left_group', context),
])
await publishNotifications(
context,
[notifyOwnersOfGroup(groupId, userId, 'user_left_group', context)],
'emailNotificationsGroupMemberLeft',
)
}
return user
}
@ -84,9 +88,11 @@ const handleChangeGroupMemberRole = async (resolve, root, args, context, resolve
const { groupId, userId } = args
const user = await resolve(root, args, context, resolveInfo)
if (user) {
await publishNotifications(context, [
notifyMemberOfGroup(groupId, userId, 'changed_group_member_role', context),
])
await publishNotifications(
context,
[notifyMemberOfGroup(groupId, userId, 'changed_group_member_role', context)],
'emailNotificationsGroupMemberRoleChanged',
)
}
return user
}
@ -95,9 +101,11 @@ const handleRemoveUserFromGroup = async (resolve, root, args, context, resolveIn
const { groupId, userId } = args
const user = await resolve(root, args, context, resolveInfo)
if (user) {
await publishNotifications(context, [
notifyMemberOfGroup(groupId, userId, 'removed_user_from_group', context),
])
await publishNotifications(
context,
[notifyMemberOfGroup(groupId, userId, 'removed_user_from_group', context)],
'emailNotificationsGroupMemberRemoved',
)
}
return user
}
@ -106,9 +114,11 @@ const handleContentDataOfPost = async (resolve, root, args, context, resolveInfo
const idsOfUsers = extractMentionedUsers(args.content)
const post = await resolve(root, args, context, resolveInfo)
if (post) {
await publishNotifications(context, [
notifyUsersOfMention('Post', post.id, idsOfUsers, 'mentioned_in_post', context),
])
await publishNotifications(
context,
[notifyUsersOfMention('Post', post.id, idsOfUsers, 'mentioned_in_post', context)],
'emailNotificationsMention',
)
}
return post
}
@ -119,16 +129,26 @@ const handleContentDataOfComment = async (resolve, root, args, context, resolveI
const comment = await resolve(root, args, context, resolveInfo)
const [postAuthor] = await postAuthorOfComment(comment.id, { context })
idsOfMentionedUsers = idsOfMentionedUsers.filter((id) => id !== postAuthor.id)
await publishNotifications(context, [
notifyUsersOfMention(
'Comment',
comment.id,
idsOfMentionedUsers,
'mentioned_in_comment',
context,
),
notifyUsersOfComment('Comment', comment.id, 'commented_on_post', context),
])
await publishNotifications(
context,
[
notifyUsersOfMention(
'Comment',
comment.id,
idsOfMentionedUsers,
'mentioned_in_comment',
context,
),
],
'emailNotificationsMention',
)
await publishNotifications(
context,
[notifyUsersOfComment('Comment', comment.id, 'commented_on_post', context)],
'emailNotificationsCommentOnObservedPost',
)
return comment
}
@ -339,7 +359,7 @@ const handleCreateMessage = async (resolve, root, args, context, resolveInfo) =>
MATCH (room)<-[:CHATS_IN]-(recipientUser:User)-[:PRIMARY_EMAIL]->(emailAddress:EmailAddress)
WHERE NOT recipientUser.id = $currentUserId
AND NOT (recipientUser)-[:BLOCKED]-(currentUser)
AND recipientUser.sendNotificationEmails = true
AND NOT recipientUser.emailNotificationsChatMessage = false
RETURN recipientUser, emailAddress {.email}
`
const txResponse = await transaction.run(messageRecipientCypher, {

View File

@ -471,6 +471,7 @@ export default shield(
},
User: {
email: or(isMyOwn, isAdmin),
emailNotificationSettings: isMyOwn,
},
Report: isModerator,
},

View File

@ -155,10 +155,37 @@ export default {
type: 'boolean',
default: false,
},
sendNotificationEmails: {
// emailNotifications
emailNotificationsCommentOnObservedPost: {
type: 'boolean',
default: true,
},
emailNotificationsMention: {
type: 'boolean',
default: true,
},
emailNotificationsChatMessage: {
type: 'boolean',
default: true,
},
emailNotificationsGroupMemberJoined: {
type: 'boolean',
default: true,
},
emailNotificationsGroupMemberLeft: {
type: 'boolean',
default: true,
},
emailNotificationsGroupMemberRemoved: {
type: 'boolean',
default: true,
},
emailNotificationsGroupMemberRoleChanged: {
type: 'boolean',
default: true,
},
locale: {
type: 'string',
allow: [null],

View File

@ -11,6 +11,8 @@ export default makeAugmentedSchema({
exclude: [
'Badge',
'Embed',
'EmailNotificationSettings',
'EmailNotificationSettingsOption',
'EmailAddress',
'Notification',
'Statistics',

View File

@ -100,7 +100,6 @@ const signupCypher = (inviteCode) => {
SET user.updatedAt = toString(datetime())
SET user.allowEmbedIframes = false
SET user.showShoutsPublicly = false
SET user.sendNotificationEmails = true
SET email.verifiedAt = toString(datetime())
WITH user
OPTIONAL MATCH (post:Post)-[:IN]->(group:Group)

View File

@ -593,6 +593,220 @@ describe('switch user role', () => {
})
})
let anotherUser
const emailNotificationSettingsQuery = gql`
query ($id: ID!) {
User(id: $id) {
emailNotificationSettings {
type
settings {
name
value
}
}
}
}
`
const emailNotificationSettingsMutation = gql`
mutation ($id: ID!, $emailNotificationSettings: [EmailNotificationSettingsInput]!) {
UpdateUser(id: $id, emailNotificationSettings: $emailNotificationSettings) {
emailNotificationSettings {
type
settings {
name
value
}
}
}
}
`
describe('emailNotificationSettings', () => {
beforeEach(async () => {
user = await Factory.build('user', {
id: 'user',
role: 'user',
})
anotherUser = await Factory.build('user', {
id: 'anotherUser',
role: 'anotherUser',
})
})
describe('query the field', () => {
describe('as another user', () => {
it('throws an error', async () => {
authenticatedUser = await anotherUser.toJson()
const targetUser = await user.toJson()
await expect(
query({ query: emailNotificationSettingsQuery, variables: { id: targetUser.id } }),
).resolves.toEqual(
expect.objectContaining({
errors: [
expect.objectContaining({
message: 'Not Authorized!',
}),
],
}),
)
})
})
describe('as self', () => {
it('returns the emailNotificationSettings', async () => {
authenticatedUser = await user.toJson()
await expect(
query({ query: emailNotificationSettingsQuery, variables: { id: authenticatedUser.id } }),
).resolves.toEqual(
expect.objectContaining({
data: {
User: [
{
emailNotificationSettings: [
{
type: 'post',
settings: [
{
name: 'commentOnObservedPost',
value: true,
},
{
name: 'mention',
value: true,
},
],
},
{
type: 'chat',
settings: [
{
name: 'chatMessage',
value: true,
},
],
},
{
type: 'group',
settings: [
{
name: 'groupMemberJoined',
value: true,
},
{
name: 'groupMemberLeft',
value: true,
},
{
name: 'groupMemberRemoved',
value: true,
},
{
name: 'groupMemberRoleChanged',
value: true,
},
],
},
],
},
],
},
}),
)
})
})
})
describe('mutate the field', () => {
const emailNotificationSettings = [{ name: 'mention', value: false }]
describe('as another user', () => {
it('throws an error', async () => {
authenticatedUser = await anotherUser.toJson()
const targetUser = await user.toJson()
await expect(
mutate({
mutation: emailNotificationSettingsMutation,
variables: { id: targetUser.id, emailNotificationSettings },
}),
).resolves.toEqual(
expect.objectContaining({
errors: [
expect.objectContaining({
message: 'Not Authorized!',
}),
],
}),
)
})
})
describe('as self', () => {
it('updates the emailNotificationSettings', async () => {
authenticatedUser = await user.toJson()
await expect(
mutate({
mutation: emailNotificationSettingsMutation,
variables: { id: authenticatedUser.id, emailNotificationSettings },
}),
).resolves.toEqual(
expect.objectContaining({
data: {
UpdateUser: {
emailNotificationSettings: [
{
type: 'post',
settings: [
{
name: 'commentOnObservedPost',
value: true,
},
{
name: 'mention',
value: false,
},
],
},
{
type: 'chat',
settings: [
{
name: 'chatMessage',
value: true,
},
],
},
{
type: 'group',
settings: [
{
name: 'groupMemberJoined',
value: true,
},
{
name: 'groupMemberLeft',
value: true,
},
{
name: 'groupMemberRemoved',
value: true,
},
{
name: 'groupMemberRoleChanged',
value: true,
},
],
},
],
},
},
}),
)
})
})
})
})
describe('save category settings', () => {
beforeEach(async () => {
await Promise.all(

View File

@ -152,6 +152,19 @@ export default {
}
params.termsAndConditionsAgreedAt = new Date().toISOString()
}
const {
emailNotificationSettings,
}: { emailNotificationSettings: { name: string; value: boolean }[] | undefined } = params
delete params.emailNotificationSettings
if (emailNotificationSettings) {
emailNotificationSettings.forEach((setting) => {
params[
'emailNotifications' + setting.name.charAt(0).toUpperCase() + setting.name.slice(1)
] = setting.value
})
}
const session = context.driver.session()
const writeTxResultPromise = session.writeTransaction(async (transaction) => {
@ -357,6 +370,53 @@ export default {
const [{ email }] = result.records.map((r) => r.get('e').properties)
return email
},
emailNotificationSettings: async (parent, params, context, resolveInfo) => {
return [
{
type: 'post',
settings: [
{
name: 'commentOnObservedPost',
value: parent.emailNotificationsCommentOnObservedPost ?? true,
},
{
name: 'mention',
value: parent.emailNotificationsMention ?? true,
},
],
},
{
type: 'chat',
settings: [
{
name: 'chatMessage',
value: parent.emailNotificationsChatMessage ?? true,
},
],
},
{
type: 'group',
settings: [
{
name: 'groupMemberJoined',
value: parent.emailNotificationsGroupMemberJoined ?? true,
},
{
name: 'groupMemberLeft',
value: parent.emailNotificationsGroupMemberLeft ?? true,
},
{
name: 'groupMemberRemoved',
value: parent.emailNotificationsGroupMemberRemoved ?? true,
},
{
name: 'groupMemberRoleChanged',
value: parent.emailNotificationsGroupMemberRoleChanged ?? true,
},
],
},
]
},
...Resolver('User', {
undefinedToNull: [
'actorId',
@ -368,7 +428,6 @@ export default {
'termsAndConditionsAgreedAt',
'allowEmbedIframes',
'showShoutsPublicly',
'sendNotificationEmails',
'locale',
],
boolean: {

View File

@ -0,0 +1,9 @@
enum EmailNotificationSettingsName {
commentOnObservedPost
mention
chatMessage
groupMemberJoined
groupMemberLeft
groupMemberRemoved
groupMemberRoleChanged
}

View File

@ -0,0 +1,5 @@
enum EmailNotificationSettingsType {
post
chat
group
}

View File

@ -19,6 +19,21 @@ enum _UserOrdering {
locale_desc
}
input EmailNotificationSettingsInput {
name: EmailNotificationSettingsName
value: Boolean
}
type EmailNotificationSettings {
type: EmailNotificationSettingsType
settings: [EmailNotificationSettingsOption] @neo4j_ignore
}
type EmailNotificationSettingsOption {
name: EmailNotificationSettingsName
value: Boolean
}
type User {
id: ID!
actorId: String
@ -46,7 +61,7 @@ type User {
allowEmbedIframes: Boolean
showShoutsPublicly: Boolean
sendNotificationEmails: Boolean
emailNotificationSettings: [EmailNotificationSettings]! @neo4j_ignore
locale: String
friends: [User]! @relation(name: "FRIENDS", direction: "BOTH")
friendsCount: Int! @cypher(statement: "MATCH (this)<-[:FRIENDS]->(r:User) RETURN COUNT(DISTINCT r)")
@ -206,7 +221,7 @@ type Mutation {
termsAndConditionsAgreedAt: String
allowEmbedIframes: Boolean
showShoutsPublicly: Boolean
sendNotificationEmails: Boolean
emailNotificationSettings: [EmailNotificationSettingsInput]
locale: String
): User

View File

@ -46,7 +46,6 @@ export const profileUserQuery = (i18n) => {
url
}
showShoutsPublicly
sendNotificationEmails
}
}
`
@ -335,7 +334,7 @@ export const updateUserMutation = () => {
$about: String
$allowEmbedIframes: Boolean
$showShoutsPublicly: Boolean
$sendNotificationEmails: Boolean
$emailNotificationSettings: [EmailNotificationSettingsInput]
$termsAndConditionsAgreedVersion: String
$avatar: ImageInput
$locationName: String # empty string '' sets it to null
@ -347,7 +346,7 @@ export const updateUserMutation = () => {
about: $about
allowEmbedIframes: $allowEmbedIframes
showShoutsPublicly: $showShoutsPublicly
sendNotificationEmails: $sendNotificationEmails
emailNotificationSettings: $emailNotificationSettings
termsAndConditionsAgreedVersion: $termsAndConditionsAgreedVersion
avatar: $avatar
locationName: $locationName
@ -359,7 +358,13 @@ export const updateUserMutation = () => {
about
allowEmbedIframes
showShoutsPublicly
sendNotificationEmails
emailNotificationSettings {
type
settings {
name
value
}
}
locale
termsAndConditionsAgreedVersion
avatar {
@ -390,7 +395,13 @@ export const currentUserQuery = gql`
locale
allowEmbedIframes
showShoutsPublicly
sendNotificationEmails
emailNotificationSettings {
type
settings {
name
value
}
}
termsAndConditionsAgreedVersion
socialMedia {
id

View File

@ -1039,9 +1039,23 @@
},
"name": "Einstellungen",
"notifications": {
"name": "Benachrichtigungen",
"chat": "Chat",
"chatMessage": "Nachricht erhalten während Abwesenheit",
"checkAll": "Alle auswählen",
"commentOnObservedPost": "Kommentare zu beobachteten Beiträgen",
"group": "Gruppen",
"groupMemberJoined": "Ein Mitglied ist deiner Gruppe beigetreten",
"groupMemberLeft": "Ein Mitglied hat deine Gruppe verlassen",
"groupMemberRemoved": "Du wurdest aus einer Gruppe entfernt",
"groupMemberRoleChanged": "Deine Rolle in einer Gruppe wurde geändert",
"mention": "Ich wurde erwähnt",
"name": "Benachrichtigungen per Email",
"post": "Beiträge und Kommentare",
"postByFollowedUser": "Beitrag von einem Nutzer, dem ich folge",
"postInGroup": "Beitrag in einer Gruppe, die ich beobachte",
"send-email-notifications": "Sende E-Mail-Benachrichtigungen",
"success-update": "Benachrichtigungs-Einstellungen gespeichert!"
"success-update": "Benachrichtigungs-Einstellungen gespeichert!",
"uncheckAll": "Alle abwählen"
},
"organizations": {
"name": "Meine Organisationen"

View File

@ -1039,9 +1039,23 @@
},
"name": "Settings",
"notifications": {
"name": "Notifications",
"chat": "Chat",
"chatMessage": "Message received while absent",
"checkAll": "Check all",
"commentOnObservedPost": "Comments on observed posts",
"group": "Groups",
"groupMemberJoined": "Member joined a group I own",
"groupMemberLeft": "Member left a group I own",
"groupMemberRemoved": "I was removed from a group",
"groupMemberRoleChanged": "My role in a group was changed",
"mention": "I was mentioned",
"name": "Email Notifications",
"post": "Posts and comments",
"postByFollowedUser": "Posts by users I follow",
"postInGroup": "Post in a group I am a member of",
"send-email-notifications": "Send e-mail notifications",
"success-update": "Notifications settings saved!"
"success-update": "Notifications settings saved!",
"uncheckAll": "Uncheck all"
},
"organizations": {
"name": "My Organizations"

View File

@ -1039,9 +1039,23 @@
},
"name": "Configuración",
"notifications": {
"name": null,
"send-email-notifications": null,
"success-update": null
"chat": "Chat",
"chatMessage": "Mensaje recibido mientras estaba ausente",
"checkAll": "Seleccionar todo",
"commentOnObservedPost": "Comentario en una contribución que estoy observando",
"group": "Grupos",
"groupMemberJoined": "Un nuevo miembro se unió a un grupo mio",
"groupMemberLeft": "Un miembro dejó un grupo mio",
"groupMemberRemoved": "Fui eliminado de un grupo",
"groupMemberRoleChanged": "Mi rol en un grupo ha cambiado",
"mention": "Mencionado en una contribución",
"name": "Notificaciones por correo electrónico",
"post": "Entradas y comentarios",
"postByFollowedUser": "Posts by users I follow",
"postInGroup": "Post en un grupo del que soy miembro",
"send-email-notifications": "Enviar notificaciones por correo electrónico",
"success-update": "¡Configuración de notificaciones guardada!",
"uncheckAll": "Deseleccionar todo"
},
"organizations": {
"name": "Mis organizaciones"

View File

@ -1039,9 +1039,23 @@
},
"name": "Paramètres",
"notifications": {
"name": null,
"send-email-notifications": null,
"success-update": null
"chat": "Chat",
"chatMessage": "Message reçu pendant l'absence",
"checkAll": "Tout cocher",
"commentOnObservedPost": "Commentez une contribution que je suis",
"group": "Groups",
"groupMemberJoined": "Un nouveau membre a rejoint un de mes groupes",
"groupMemberLeft": "Un membre a quitté un de mes groupes",
"groupMemberRemoved": "J'ai été retiré d'un groupe",
"groupMemberRoleChanged": "Mon rôle au sein d'un groupe a changé",
"mention": "Mentionné dans une contribution",
"name": "Notifications par mail",
"post": "Messages et commentaires",
"postByFollowedUser": "Messages des utilisateurs que je suis",
"postInGroup": "Message dans un groupe dont je suis membre",
"send-email-notifications": "Envoyer des notifications par courrier électronique",
"success-update": "Paramètres de notification sauvegardés ! ",
"uncheckAll": "Tout décocher"
},
"organizations": {
"name": "Mes organisations"

View File

@ -1039,9 +1039,23 @@
},
"name": "Impostazioni",
"notifications": {
"name": null,
"send-email-notifications": null,
"success-update": null
"chat": "Chat",
"chatMessage": "Messaggio ricevuto durante l'assenza",
"checkAll": "Seleziona tutto",
"commentOnObservedPost": "Commenta un contributo che sto guardando",
"group": "Gruppi",
"groupMemberJoined": "Un nuovo membro si è unito a un mio gruppo",
"groupMemberLeft": "Un membro ha lasciato un mio gruppo",
"groupMemberRemoved": "Sono stato rimosso da un gruppo",
"groupMemberRoleChanged": "Il mio ruolo in un gruppo è cambiato",
"mention": "Menzionato in un contributo",
"name": "Notifiche via e-mail",
"post": "Messaggi e commenti",
"postByFollowedUser": "Messaggi di utenti che seguo",
"postInGroup": "Post in un gruppo di cui sono membro",
"send-email-notifications": "Invia notifiche via e-mail",
"success-update": "Impostazioni di notifica salvate! ",
"uncheckAll": "Deseleziona tutto"
},
"organizations": {
"name": "Mie organizzazioni"

View File

@ -1039,9 +1039,23 @@
},
"name": "Instellingen",
"notifications": {
"name": null,
"send-email-notifications": null,
"success-update": null
"chat": "Chat",
"chatMessage": "Bericht ontvangen tijdens afwezigheid",
"checkAll": "Vink alles aan",
"commentOnObservedPost": "Geef commentaar op een bijdrage die ik volg",
"group": "Groepen",
"groupMemberJoined": "Een nieuw lid is lid geworden van een groep van mij",
"groupMemberLeft": "Een lid heeft een groep van mij verlaten",
"groupMemberRemoved": "Ik ben verwijderd uit een groep",
"groupMemberRoleChanged": "Mijn rol in een groep is veranderd",
"mention": "Genoemd in een bijdrage",
"name": "Email Meldingen",
"post": "Berichten en reacties",
"postByFollowedUser": "Berichten van gebruikers die ik volg",
"postInGroup": "Bericht in een groep waar ik lid van ben",
"send-email-notifications": "E-mailmeldingen verzenden",
"success-update": "Meldingsinstellingen opgeslagen! ",
"uncheckAll": "Vink alles uit"
},
"organizations": {
"name": "Mijn Organisaties"

View File

@ -1039,9 +1039,23 @@
},
"name": "Ustawienia",
"notifications": {
"name": null,
"send-email-notifications": null,
"success-update": null
"chat": "Chat",
"chatMessage": "Wiadomość otrzymana podczas nieobecności",
"checkAll": "Wybierz wszystko",
"commentOnObservedPost": "Skomentuj wpis, który obserwuję",
"group": "Grupy",
"groupMemberJoined": "Nowy członek dołączył do mojej grupy",
"groupMemberLeft": "Członek opuścił moją grupę",
"groupMemberRemoved": "Zostałem usunięty z grupy",
"groupMemberRoleChanged": "Moja rola w grupie uległa zmianie",
"mention": "Mentioned in a contribution",
"name": "Powiadomienia e-mail",
"post": "Posty",
"postByFollowedUser": "Posty użytkowników, których obserwuję",
"postInGroup": "Posty w grupie, której jestem członkiem",
"send-email-notifications": "Wyślij powiadomienia e-mail",
"success-update": "Ustawienia powiadomień zapisane! ",
"uncheckAll": "Odznacz wszystko"
},
"organizations": {
"name": "My Organizations"

View File

@ -1039,9 +1039,23 @@
},
"name": "Configurações",
"notifications": {
"name": null,
"send-email-notifications": null,
"success-update": null
"chat": "Chat",
"chatMessage": "Mensagem recebida durante a ausência",
"checkAll": "Marcar tudo",
"commentOnObservedPost": "Comentários sobre as mensagens observadas",
"group": "Grupos",
"groupMemberJoined": "Member joined a group I own",
"groupMemberLeft": "Membro saiu de um grupo de que sou proprietário",
"groupMemberRemoved": "Fui removido de um grupo",
"groupMemberRoleChanged": "O meu papel num grupo foi alterado",
"mention": "Fui mencionado",
"name": "Notificações por correio eletrónico",
"post": "Posts e comentários",
"postByFollowedUser": "Publicações de utilizadores que sigo",
"postInGroup": "Postar num grupo de que sou membro",
"send-email-notifications": "Enviar notificações por correio eletrónico",
"success-update": "Definições de notificações guardadas!",
"uncheckAll": "Desmarcar tudo"
},
"organizations": {
"name": "Minhas Organizações"

View File

@ -1039,9 +1039,23 @@
},
"name": "Настройки",
"notifications": {
"name": null,
"send-email-notifications": null,
"success-update": null
"chat": "Чат",
"chatMessage": "Сообщение, полученное во время отсутствия",
"checkAll": "Отметить все",
"commentOnObservedPost": "Комментарии по поводу замеченных сообщений",
"group": "Группы",
"groupMemberJoined": "Участник присоединился к группе, которой я владею",
"groupMemberLeft": "Участник вышел из группы, которой владею",
"groupMemberRemoved": "Был удален из группы",
"groupMemberRoleChanged": "Моя роль в группе была изменена",
"mention": "Упоминание в вкладе",
"name": "Уведомления",
"post": "Сообщения и комментарии",
"postByFollowedUser": "Сообщения пользователей, за которыми я слежу",
"postInGroup": "Сообщение в группе, членом которой я являюсь",
"send-email-notifications": "Отправлять уведомления по электронной почте",
"success-update": "Настройки уведомлений сохранены! ",
"uncheckAll": "Снимите все флажки"
},
"organizations": {
"name": "Мои организации"

View File

@ -18,6 +18,7 @@
"locales:normalize": "../scripts/translations/normalize.sh",
"precommit": "yarn lint",
"test": "cross-env NODE_ENV=test jest --coverage --forceExit --detectOpenHandles",
"test:unit:update": "yarn test -- --updateSnapshot",
"test:unit:debug": "node --inspect-brk ./node_modules/jest/bin/jest.js --no-cache --runInBand"
},
"dependencies": {
@ -78,6 +79,7 @@
"@storybook/addon-actions": "^5.3.21",
"@storybook/addon-notes": "^5.3.18",
"@storybook/vue": "~7.4.0",
"@testing-library/vue": "5",
"@vue/cli-shared-utils": "~4.3.1",
"@vue/eslint-config-prettier": "~6.0.0",
"@vue/server-test-utils": "~1.0.0-beta.31",

View File

@ -0,0 +1,197 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`notifications.vue mount renders 1`] = `
<article
class="base-card"
>
<h2
class="title"
>
settings.notifications.name
</h2>
<div
class="ds-space"
style="margin-top: 24px; margin-bottom: 32px;"
>
<div
class="ds-space"
style="margin-bottom: 16px;"
>
<h4>
settings.notifications.post
</h4>
</div>
<div
class="notifcation-settings-section"
>
<div
class="ds-space"
style="margin-bottom: 8px;"
>
<input
id="commentOnObservedPost"
type="checkbox"
/>
<label
class="label"
for="commentOnObservedPost"
>
settings.notifications.commentOnObservedPost
</label>
</div>
<div
class="ds-space"
style="margin-bottom: 8px;"
>
<input
id="mention"
type="checkbox"
/>
<label
class="label"
for="mention"
>
settings.notifications.mention
</label>
</div>
</div>
</div>
<div
class="ds-space"
style="margin-top: 24px; margin-bottom: 32px;"
>
<div
class="ds-space"
style="margin-bottom: 16px;"
>
<h4>
settings.notifications.group
</h4>
</div>
<div
class="notifcation-settings-section"
>
<div
class="ds-space"
style="margin-bottom: 8px;"
>
<input
id="groupMemberJoined"
type="checkbox"
/>
<label
class="label"
for="groupMemberJoined"
>
settings.notifications.groupMemberJoined
</label>
</div>
<div
class="ds-space"
style="margin-bottom: 8px;"
>
<input
id="groupMemberLeft"
type="checkbox"
/>
<label
class="label"
for="groupMemberLeft"
>
settings.notifications.groupMemberLeft
</label>
</div>
<div
class="ds-space"
style="margin-bottom: 8px;"
>
<input
id="groupMemberRemoved"
type="checkbox"
/>
<label
class="label"
for="groupMemberRemoved"
>
settings.notifications.groupMemberRemoved
</label>
</div>
<div
class="ds-space"
style="margin-bottom: 8px;"
>
<input
id="groupMemberRoleChanged"
type="checkbox"
/>
<label
class="label"
for="groupMemberRoleChanged"
>
settings.notifications.groupMemberRoleChanged
</label>
</div>
</div>
</div>
<button
class="base-button"
type="button"
>
<!---->
<!---->
settings.notifications.checkAll
</button>
<button
class="base-button"
type="button"
>
<!---->
<!---->
settings.notifications.uncheckAll
</button>
<button
class="save-button base-button --filled"
disabled="disabled"
type="button"
>
<!---->
<!---->
actions.save
</button>
<!---->
</article>
`;

View File

@ -1,5 +1,6 @@
import Vuex from 'vuex'
import { mount } from '@vue/test-utils'
import { render, fireEvent, screen } from '@testing-library/vue'
import Notifications from './notifications.vue'
const localVue = global.localVue
@ -11,7 +12,7 @@ describe('notifications.vue', () => {
beforeEach(() => {
mocks = {
$t: jest.fn(),
$t: jest.fn((v) => v),
$apollo: {
mutate: jest.fn(),
},
@ -26,7 +27,42 @@ describe('notifications.vue', () => {
return {
id: 'u343',
name: 'MyAccount',
sendNotificationEmails: true,
emailNotificationSettings: [
{
type: 'post',
settings: [
{
name: 'commentOnObservedPost',
value: true,
},
{
name: 'mention',
value: false,
},
],
},
{
type: 'group',
settings: [
{
name: 'groupMemberJoined',
value: true,
},
{
name: 'groupMemberLeft',
value: true,
},
{
name: 'groupMemberRemoved',
value: false,
},
{
name: 'groupMemberRoleChanged',
value: true,
},
],
},
],
}
},
},
@ -47,21 +83,116 @@ describe('notifications.vue', () => {
})
it('renders', () => {
expect(wrapper.classes('base-card')).toBe(true)
expect(wrapper.element).toMatchSnapshot()
})
})
describe('Notifications', () => {
beforeEach(() => {
render(Notifications, {
store,
mocks,
localVue,
})
})
it('clicking on submit changes notifyByEmail to false', async () => {
await wrapper.find('#send-email').setChecked(false)
await wrapper.find('.base-button').trigger('click')
expect(wrapper.vm.notifyByEmail).toBe(false)
it('check all button works', async () => {
const button = screen.getByText('settings.notifications.checkAll')
await fireEvent.click(button)
const checkboxes = screen.getAllByRole('checkbox')
for (const checkbox of checkboxes) {
expect(checkbox.checked).toEqual(true)
}
// Check that the button is disabled
expect(button.disabled).toBe(true)
})
it('clicking on submit with a server error shows a toast and notifyByEmail is still true', async () => {
it('uncheck all button works', async () => {
const button = screen.getByText('settings.notifications.uncheckAll')
await fireEvent.click(button)
const checkboxes = screen.getAllByRole('checkbox')
for (const checkbox of checkboxes) {
expect(checkbox.checked).toEqual(false)
}
// Check that the button is disabled
expect(button.disabled).toBe(true)
})
it('clicking on submit keeps set values and shows success message', async () => {
mocks.$apollo.mutate = jest.fn().mockResolvedValue({
data: {
UpdateUser: {
emailNotificationSettings: [
{
type: 'post',
settings: [
{
name: 'commentOnObservedPost',
value: false,
},
{
name: 'mention',
value: false,
},
],
},
{
type: 'group',
settings: [
{
name: 'groupMemberJoined',
value: true,
},
{
name: 'groupMemberLeft',
value: true,
},
{
name: 'groupMemberRemoved',
value: false,
},
{
name: 'groupMemberRoleChanged',
value: true,
},
],
},
],
},
},
})
// Change some value to enable save button
const checkbox = screen.getAllByRole('checkbox')[0]
await fireEvent.click(checkbox)
const newValue = checkbox.checked
// Click save button
const button = screen.getByText('actions.save')
await fireEvent.click(button)
expect(checkbox.checked).toEqual(newValue)
expect(mocks.$toast.success).toHaveBeenCalledWith('settings.notifications.success-update')
})
it('clicking on submit with a server error shows a toast', async () => {
mocks.$apollo.mutate = jest.fn().mockRejectedValue({ message: 'Ouch!' })
await wrapper.find('#send-email').setChecked(false)
await wrapper.find('.base-button').trigger('click')
// Change some value to enable save button
const checkbox = screen.getAllByRole('checkbox')[0]
await fireEvent.click(checkbox)
// Click save button
const button = screen.getByText('actions.save')
await fireEvent.click(button)
expect(mocks.$toast.error).toHaveBeenCalledWith('Ouch!')
expect(wrapper.vm.notifyByEmail).toBe(true)
})
})
})

View File

@ -1,11 +1,26 @@
<template>
<base-card>
<h2 class="title">{{ $t('settings.notifications.name') }}</h2>
<ds-space margin-bottom="small">
<input id="send-email" type="checkbox" v-model="notifyByEmail" />
<label for="send-email">{{ $t('settings.notifications.send-email-notifications') }}</label>
<ds-space margin-top="base" v-for="topic in emailNotificationSettings" :key="topic.type">
<ds-space margin-bottom="small">
<h4>{{ $t(`settings.notifications.${topic.type}`) }}</h4>
</ds-space>
<div class="notifcation-settings-section">
<ds-space margin-bottom="x-small" v-for="setting in topic.settings" :key="setting.name">
<input :id="setting.name" type="checkbox" v-model="setting.value" />
<label :for="setting.name" class="label">
{{ $t(`settings.notifications.${setting.name}`) }}
</label>
</ds-space>
</div>
</ds-space>
<base-button class="save-button" filled @click="submit" :disabled="disabled">
<base-button @click="checkAll" :disabled="isCheckAllDisabled">
{{ $t('settings.notifications.checkAll') }}
</base-button>
<base-button @click="uncheckAll" :disabled="isUncheckAllDisabled">
{{ $t('settings.notifications.uncheckAll') }}
</base-button>
<base-button class="save-button" filled @click="submit" :disabled="isSubmitDisabled">
{{ $t('actions.save') }}
</base-button>
</base-card>
@ -18,46 +33,107 @@ import { updateUserMutation } from '~/graphql/User'
export default {
data() {
return {
notifyByEmail: false,
emailNotificationSettings: [],
}
},
computed: {
...mapGetters({
currentUser: 'auth/user',
}),
disabled() {
return this.notifyByEmail === this.currentUser.sendNotificationEmails
isSubmitDisabled() {
return this.emailNotificationSettings.every((topic) =>
topic.settings.every(
(setting) =>
setting.value ===
this.currentUser.emailNotificationSettings
.find((t) => t.type === topic.type)
.settings.find((s) => s.name === setting.name).value,
),
)
},
isCheckAllDisabled() {
return this.emailNotificationSettings.every((topic) =>
topic.settings.every((setting) => setting.value),
)
},
isUncheckAllDisabled() {
return this.emailNotificationSettings.every((topic) =>
topic.settings.every((setting) => !setting.value),
)
},
},
created() {
this.notifyByEmail = this.currentUser.sendNotificationEmails || false
this.emailNotificationSettings = [
...this.currentUser.emailNotificationSettings.map((topic) => ({
type: topic.type,
settings: topic.settings.map((setting) => ({
name: setting.name,
value: setting.value,
})),
})),
]
},
methods: {
...mapMutations({
setCurrentUser: 'auth/SET_USER',
}),
setAll(value) {
for (const topic of this.emailNotificationSettings) {
for (const setting of topic.settings) {
setting.value = value
}
}
},
checkAll() {
this.setAll(true)
},
uncheckAll() {
this.setAll(false)
},
transformToEmailSettingsInput(emailSettings) {
const emailSettingsInput = []
for (const topic of emailSettings) {
for (const setting of topic.settings) {
emailSettingsInput.push({
name: setting.name,
value: setting.value,
})
}
}
return emailSettingsInput
},
async submit() {
try {
await this.$apollo.mutate({
mutation: updateUserMutation(),
variables: {
id: this.currentUser.id,
sendNotificationEmails: this.notifyByEmail,
emailNotificationSettings: this.transformToEmailSettingsInput(
this.emailNotificationSettings,
),
},
update: (_, { data: { UpdateUser } }) => {
const { sendNotificationEmails } = UpdateUser
const { emailNotificationSettings } = UpdateUser
this.setCurrentUser({
...this.currentUser,
sendNotificationEmails,
emailNotificationSettings,
})
this.$toast.success(this.$t('settings.notifications.success-update'))
},
})
this.$toast.success(this.$t('settings.notifications.success-update'))
} catch (error) {
this.notifyByEmail = !this.notifyByEmail
this.$toast.error(error.message)
}
},
},
}
</script>
<style lang="scss" scoped>
.notifcation-settings-section {
margin-left: $space-x-small;
}
.label {
margin-left: $space-xx-small;
}
</style>

View File

@ -115,15 +115,7 @@
"@babel/highlight" "^7.24.2"
picocolors "^1.0.0"
"@babel/code-frame@^7.25.7":
version "7.25.7"
resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.25.7.tgz#438f2c524071531d643c6f0188e1e28f130cebc7"
integrity sha512-0xZJFNE5XMpENsgfHYTw8FbX4kv53mFLn2i3XPoq69LyhYSCBJtitaHx9QnsVTrsogI4Z3+HtEfZ2/GFPOtf5g==
dependencies:
"@babel/highlight" "^7.25.7"
picocolors "^1.0.0"
"@babel/code-frame@^7.26.2":
"@babel/code-frame@^7.10.4", "@babel/code-frame@^7.26.2":
version "7.26.2"
resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.26.2.tgz#4b5fab97d33338eff916235055f0ebc21e573a85"
integrity sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==
@ -132,6 +124,14 @@
js-tokens "^4.0.0"
picocolors "^1.0.0"
"@babel/code-frame@^7.25.7":
version "7.25.7"
resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.25.7.tgz#438f2c524071531d643c6f0188e1e28f130cebc7"
integrity sha512-0xZJFNE5XMpENsgfHYTw8FbX4kv53mFLn2i3XPoq69LyhYSCBJtitaHx9QnsVTrsogI4Z3+HtEfZ2/GFPOtf5g==
dependencies:
"@babel/highlight" "^7.25.7"
picocolors "^1.0.0"
"@babel/compat-data@^7.22.6", "@babel/compat-data@^7.25.7", "@babel/compat-data@^7.25.8":
version "7.25.8"
resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.25.8.tgz#0376e83df5ab0eb0da18885c0140041f0747a402"
@ -1966,6 +1966,13 @@
dependencies:
regenerator-runtime "^0.13.4"
"@babel/runtime@^7.12.5", "@babel/runtime@^7.21.0":
version "7.27.0"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.27.0.tgz#fbee7cf97c709518ecc1f590984481d5460d4762"
integrity sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==
dependencies:
regenerator-runtime "^0.14.0"
"@babel/template@^7.22.15", "@babel/template@^7.24.0", "@babel/template@^7.3.3", "@babel/template@^7.8.3":
version "7.24.0"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.24.0.tgz#c6a524aa93a4a05d66aaf31654258fae69d87d50"
@ -4321,6 +4328,29 @@
dependencies:
defer-to-connect "^2.0.0"
"@testing-library/dom@^9.0.0":
version "9.3.4"
resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-9.3.4.tgz#50696ec28376926fec0a1bf87d9dbac5e27f60ce"
integrity sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==
dependencies:
"@babel/code-frame" "^7.10.4"
"@babel/runtime" "^7.12.5"
"@types/aria-query" "^5.0.1"
aria-query "5.1.3"
chalk "^4.1.0"
dom-accessibility-api "^0.5.9"
lz-string "^1.5.0"
pretty-format "^27.0.2"
"@testing-library/vue@5":
version "5.9.0"
resolved "https://registry.yarnpkg.com/@testing-library/vue/-/vue-5.9.0.tgz#d33c52ae89e076808abe622f70dcbccb1b5d080c"
integrity sha512-HWvI4s6FayFLmiqGcEMAMfTSO1SV12NukdoyllYMBobFqfO0TalQmfofMtiO+eRz+Amej8Z26dx4/WYIROzfVw==
dependencies:
"@babel/runtime" "^7.21.0"
"@testing-library/dom" "^9.0.0"
"@vue/test-utils" "^1.3.0"
"@tootallnate/once@2":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf"
@ -4333,6 +4363,11 @@
dependencies:
"@types/node" "*"
"@types/aria-query@^5.0.1":
version "5.0.4"
resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-5.0.4.tgz#1a31c3d378850d2778dabb6374d036dcba4ba708"
integrity sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==
"@types/babel__core@^7.0.0", "@types/babel__core@^7.1.14":
version "7.20.1"
resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.20.1.tgz#916ecea274b0c776fec721e333e55762d3a9614b"
@ -5071,6 +5106,15 @@
lodash "^4.17.15"
pretty "^2.0.0"
"@vue/test-utils@^1.3.0":
version "1.3.6"
resolved "https://registry.yarnpkg.com/@vue/test-utils/-/test-utils-1.3.6.tgz#6656bd8fa44dd088b4ad80ff1ee28abe7e5ddf87"
integrity sha512-udMmmF1ts3zwxUJEIAj5ziioR900reDrt6C9H3XpWPsLBx2lpHKoA4BTdd9HNIYbkGltWw+JjWJ+5O6QBwiyEw==
dependencies:
dom-event-types "^1.0.0"
lodash "^4.17.15"
pretty "^2.0.0"
"@vue/vue2-jest@29":
version "29.2.6"
resolved "https://registry.yarnpkg.com/@vue/vue2-jest/-/vue2-jest-29.2.6.tgz#b827c14fbdfca6e20aa807b00f309866fcf99f47"
@ -6096,6 +6140,13 @@ argparse@^1.0.7:
dependencies:
sprintf-js "~1.0.2"
aria-query@5.1.3:
version "5.1.3"
resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.1.3.tgz#19db27cd101152773631396f7a95a3b58c22c35e"
integrity sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==
dependencies:
deep-equal "^2.0.5"
arr-diff@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-4.0.0.tgz#d6461074febfec71e7e15235761a329a5dc7c520"
@ -6111,6 +6162,14 @@ arr-union@^3.1.0:
resolved "https://registry.yarnpkg.com/arr-union/-/arr-union-3.1.0.tgz#e39b09aea9def866a8f206e288af63919bae39c4"
integrity sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q==
array-buffer-byte-length@^1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz#384d12a37295aec3769ab022ad323a18a51ccf8b"
integrity sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==
dependencies:
call-bound "^1.0.3"
is-array-buffer "^3.0.5"
array-buffer-byte-length@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz#1e5583ec16763540a27ae52eed99ff899223568f"
@ -7091,7 +7150,7 @@ cacheable-request@^7.0.2:
normalize-url "^6.0.1"
responselike "^2.0.0"
call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2:
call-bind-apply-helpers@^1.0.0, call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6"
integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==
@ -7118,7 +7177,17 @@ call-bind@^1.0.2, call-bind@^1.0.5, call-bind@^1.0.6, call-bind@^1.0.7:
get-intrinsic "^1.2.4"
set-function-length "^1.2.1"
call-bound@^1.0.2:
call-bind@^1.0.8:
version "1.0.8"
resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.8.tgz#0736a9660f537e3388826f440d5ec45f744eaa4c"
integrity sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==
dependencies:
call-bind-apply-helpers "^1.0.0"
es-define-property "^1.0.0"
get-intrinsic "^1.2.4"
set-function-length "^1.2.2"
call-bound@^1.0.2, call-bound@^1.0.3, call-bound@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/call-bound/-/call-bound-1.0.4.tgz#238de935d2a2a692928c538c7ccfa91067fd062a"
integrity sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==
@ -8567,6 +8636,30 @@ dedent@^1.0.0:
resolved "https://registry.yarnpkg.com/dedent/-/dedent-1.5.3.tgz#99aee19eb9bae55a67327717b6e848d0bf777e5a"
integrity sha512-NHQtfOOW68WD8lgypbLA5oT+Bt0xXJhiYvoR6SmmNXZfpzOGXwdKWmcwG8N7PwVVWV3eF/68nmD9BaJSsTBhyQ==
deep-equal@^2.0.5:
version "2.2.3"
resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-2.2.3.tgz#af89dafb23a396c7da3e862abc0be27cf51d56e1"
integrity sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==
dependencies:
array-buffer-byte-length "^1.0.0"
call-bind "^1.0.5"
es-get-iterator "^1.1.3"
get-intrinsic "^1.2.2"
is-arguments "^1.1.1"
is-array-buffer "^3.0.2"
is-date-object "^1.0.5"
is-regex "^1.1.4"
is-shared-array-buffer "^1.0.2"
isarray "^2.0.5"
object-is "^1.1.5"
object-keys "^1.1.1"
object.assign "^4.1.4"
regexp.prototype.flags "^1.5.1"
side-channel "^1.0.4"
which-boxed-primitive "^1.0.2"
which-collection "^1.0.1"
which-typed-array "^1.1.13"
deep-extend@^0.6.0:
version "0.6.0"
resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac"
@ -8798,6 +8891,11 @@ doctrine@^3.0.0:
dependencies:
esutils "^2.0.2"
dom-accessibility-api@^0.5.9:
version "0.5.16"
resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz#5a7429e6066eb3664d911e33fb0e45de8eb08453"
integrity sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==
dom-converter@^0.2:
version "0.2.0"
resolved "https://registry.yarnpkg.com/dom-converter/-/dom-converter-0.2.0.tgz#6721a9daee2e293682955b6afe416771627bb768"
@ -9341,6 +9439,21 @@ es-errors@^1.2.1, es-errors@^1.3.0:
resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f"
integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==
es-get-iterator@^1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/es-get-iterator/-/es-get-iterator-1.1.3.tgz#3ef87523c5d464d41084b2c3c9c214f1199763d6"
integrity sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==
dependencies:
call-bind "^1.0.2"
get-intrinsic "^1.1.3"
has-symbols "^1.0.3"
is-arguments "^1.1.1"
is-map "^2.0.2"
is-set "^2.0.2"
is-string "^1.0.7"
isarray "^2.0.5"
stop-iteration-iterator "^1.0.0"
es-object-atoms@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.0.0.tgz#ddb55cd47ac2e240701260bc2a8e31ecb643d941"
@ -10414,6 +10527,13 @@ for-each@^0.3.3:
dependencies:
is-callable "^1.1.3"
for-each@^0.3.5:
version "0.3.5"
resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.5.tgz#d650688027826920feeb0af747ee7b9421a41d47"
integrity sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==
dependencies:
is-callable "^1.2.7"
for-in@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80"
@ -10698,7 +10818,7 @@ get-intrinsic@^1.1.3, get-intrinsic@^1.2.1, get-intrinsic@^1.2.3, get-intrinsic@
has-symbols "^1.0.3"
hasown "^2.0.0"
get-intrinsic@^1.2.5, get-intrinsic@^1.3.0:
get-intrinsic@^1.2.2, get-intrinsic@^1.2.5, get-intrinsic@^1.2.6, get-intrinsic@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01"
integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==
@ -11823,6 +11943,15 @@ internal-slot@^1.0.7:
hasown "^2.0.0"
side-channel "^1.0.4"
internal-slot@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.1.0.tgz#1eac91762947d2f7056bc838d93e13b2e9604961"
integrity sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==
dependencies:
es-errors "^1.3.0"
hasown "^2.0.2"
side-channel "^1.1.0"
interpret@^1.0.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.2.0.tgz#d5061a6224be58e8083985f5014d844359576296"
@ -11892,6 +12021,23 @@ is-arguments@^1.0.4:
resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.0.4.tgz#3faf966c7cba0ff437fb31f6250082fcf0448cf3"
integrity sha512-xPh0Rmt8NE65sNzvyUmWgI1tz3mKq74lGA0mL8LYZcoIzKOzDh6HmrYm3d18k60nHerC8A9Km8kYu87zfSFnLA==
is-arguments@^1.1.1:
version "1.2.0"
resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.2.0.tgz#ad58c6aecf563b78ef2bf04df540da8f5d7d8e1b"
integrity sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==
dependencies:
call-bound "^1.0.2"
has-tostringtag "^1.0.2"
is-array-buffer@^3.0.2, is-array-buffer@^3.0.5:
version "3.0.5"
resolved "https://registry.yarnpkg.com/is-array-buffer/-/is-array-buffer-3.0.5.tgz#65742e1e687bd2cc666253068fd8707fe4d44280"
integrity sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==
dependencies:
call-bind "^1.0.8"
call-bound "^1.0.3"
get-intrinsic "^1.2.6"
is-array-buffer@^3.0.4:
version "3.0.4"
resolved "https://registry.yarnpkg.com/is-array-buffer/-/is-array-buffer-3.0.4.tgz#7a1f92b3d61edd2bc65d24f130530ea93d7fae98"
@ -12011,6 +12157,14 @@ is-date-object@^1.0.1:
resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.2.tgz#bda736f2cd8fd06d32844e7743bfa7494c3bfd7e"
integrity sha512-USlDT524woQ08aoZFzh3/Z6ch9Y/EWXEHQ/AaRN0SkKq4t2Jw2R2339tSXmwuVoY7LLlBCbOIlx2myP/L5zk0g==
is-date-object@^1.0.5:
version "1.1.0"
resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.1.0.tgz#ad85541996fc7aa8b2729701d27b7319f95d82f7"
integrity sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==
dependencies:
call-bound "^1.0.2"
has-tostringtag "^1.0.2"
is-decimal@^1.0.0:
version "1.0.3"
resolved "https://registry.yarnpkg.com/is-decimal/-/is-decimal-1.0.3.tgz#381068759b9dc807d8c0dc0bfbae2b68e1da48b7"
@ -12130,6 +12284,11 @@ is-lower-case@^1.1.0:
dependencies:
lower-case "^1.1.0"
is-map@^2.0.2, is-map@^2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/is-map/-/is-map-2.0.3.tgz#ede96b7fe1e270b3c4465e3a465658764926d62e"
integrity sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==
is-nan@^1.2.1:
version "1.3.0"
resolved "https://registry.yarnpkg.com/is-nan/-/is-nan-1.3.0.tgz#85d1f5482f7051c2019f5673ccebdb06f3b0db03"
@ -12266,6 +12425,11 @@ is-retry-allowed@^1.0.0, is-retry-allowed@^1.1.0:
resolved "https://registry.yarnpkg.com/is-retry-allowed/-/is-retry-allowed-1.1.0.tgz#11a060568b67339444033d0125a61a20d564fb34"
integrity sha1-EaBgVotnM5REAz0BJaYaINVk+zQ=
is-set@^2.0.2, is-set@^2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/is-set/-/is-set-2.0.3.tgz#8ab209ea424608141372ded6e0cb200ef1d9d01d"
integrity sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==
is-shared-array-buffer@^1.0.2, is-shared-array-buffer@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.3.tgz#1237f1cba059cdb62431d378dcc37d9680181688"
@ -12328,6 +12492,11 @@ is-utf8@^0.2.0:
resolved "https://registry.yarnpkg.com/is-utf8/-/is-utf8-0.2.1.tgz#4b0da1442104d1b336340e80797e865cf39f7d72"
integrity sha1-Sw2hRCEE0bM2NA6AeX6GXPOffXI=
is-weakmap@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/is-weakmap/-/is-weakmap-2.0.2.tgz#bf72615d649dfe5f699079c54b83e47d1ae19cfd"
integrity sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==
is-weakref@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/is-weakref/-/is-weakref-1.0.2.tgz#9529f383a9338205e89765e0392efc2f100f06f2"
@ -12335,6 +12504,14 @@ is-weakref@^1.0.2:
dependencies:
call-bind "^1.0.2"
is-weakset@^2.0.3:
version "2.0.4"
resolved "https://registry.yarnpkg.com/is-weakset/-/is-weakset-2.0.4.tgz#c9f5deb0bc1906c6d6f1027f284ddf459249daca"
integrity sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==
dependencies:
call-bound "^1.0.3"
get-intrinsic "^1.2.6"
is-whitespace@^0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/is-whitespace/-/is-whitespace-0.3.0.tgz#1639ecb1be036aec69a54cbb401cfbed7114ab7f"
@ -13648,6 +13825,11 @@ lru_map@^0.3.3:
resolved "https://registry.yarnpkg.com/lru_map/-/lru_map-0.3.3.tgz#b5c8351b9464cbd750335a79650a0ec0e56118dd"
integrity sha1-tcg1G5Rky9dQM1p5ZQoOwOVhGN0=
lz-string@^1.5.0:
version "1.5.0"
resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.5.0.tgz#c1ab50f77887b712621201ba9fd4e3a6ed099941"
integrity sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==
magic-string@^0.25.7:
version "0.25.9"
resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.9.tgz#de7f9faf91ef8a1c91d02c2e5314c8277dbcdd1c"
@ -14911,6 +15093,14 @@ object-is@^1.0.1:
resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.0.2.tgz#6b80eb84fe451498f65007982f035a5b445edec4"
integrity sha512-Epah+btZd5wrrfjkJZq1AOB9O6OxUQto45hzFd7lXGrpHPGE0W1k+426yrZV+k6NJOzLNNW/nVsmZdIWsAqoOQ==
object-is@^1.1.5:
version "1.1.6"
resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.1.6.tgz#1a6a53aed2dd8f7e6775ff870bea58545956ab07"
integrity sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==
dependencies:
call-bind "^1.0.7"
define-properties "^1.2.1"
object-keys@^1.0.11, object-keys@^1.0.12, object-keys@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e"
@ -14948,6 +15138,18 @@ object.assign@^4.1.1:
has-symbols "^1.0.1"
object-keys "^1.1.1"
object.assign@^4.1.4:
version "4.1.7"
resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.7.tgz#8c14ca1a424c6a561b0bb2a22f66f5049a945d3d"
integrity sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==
dependencies:
call-bind "^1.0.8"
call-bound "^1.0.3"
define-properties "^1.2.1"
es-object-atoms "^1.0.0"
has-symbols "^1.1.0"
object-keys "^1.1.1"
object.assign@^4.1.5:
version "4.1.5"
resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.5.tgz#3a833f9ab7fdb80fc9e8d2300c803d216d8fdbb0"
@ -16327,6 +16529,15 @@ pretty-error@^2.0.2:
renderkid "^2.0.1"
utila "~0.4"
pretty-format@^27.0.2:
version "27.5.1"
resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-27.5.1.tgz#2181879fdea51a7a5851fb39d920faa63f01d88e"
integrity sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==
dependencies:
ansi-regex "^5.0.1"
ansi-styles "^5.0.0"
react-is "^17.0.1"
pretty-format@^29.7.0:
version "29.7.0"
resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.7.0.tgz#ca42c758310f365bfa71a0bda0a807160b776812"
@ -16875,6 +17086,11 @@ react-is@^16.8.1:
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
react-is@^17.0.1:
version "17.0.2"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0"
integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==
react-is@^18.0.0:
version "18.2.0"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b"
@ -17169,6 +17385,18 @@ regex-not@^1.0.0, regex-not@^1.0.2:
extend-shallow "^3.0.2"
safe-regex "^1.1.0"
regexp.prototype.flags@^1.5.1:
version "1.5.4"
resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz#1ad6c62d44a259007e55b3970e00f746efbcaa19"
integrity sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==
dependencies:
call-bind "^1.0.8"
define-properties "^1.2.1"
es-errors "^1.3.0"
get-proto "^1.0.1"
gopd "^1.2.0"
set-function-name "^2.0.2"
regexp.prototype.flags@^1.5.2:
version "1.5.3"
resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.3.tgz#b3ae40b1d2499b8350ab2c3fe6ef3845d3a96f42"
@ -17818,7 +18046,7 @@ set-blocking@^2.0.0, set-blocking@~2.0.0:
resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7"
integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc=
set-function-length@^1.2.1:
set-function-length@^1.2.1, set-function-length@^1.2.2:
version "1.2.2"
resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449"
integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==
@ -18296,6 +18524,14 @@ stealthy-require@^1.1.1:
resolved "https://registry.yarnpkg.com/stealthy-require/-/stealthy-require-1.1.1.tgz#35b09875b4ff49f26a777e509b3090a3226bf24b"
integrity sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks=
stop-iteration-iterator@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz#f481ff70a548f6124d0312c3aa14cbfa7aa542ad"
integrity sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==
dependencies:
es-errors "^1.3.0"
internal-slot "^1.1.0"
store2@^2.7.1:
version "2.8.0"
resolved "https://registry.yarnpkg.com/store2/-/store2-2.8.0.tgz#032d5dcbd185a5d74049d67a1765ff1e75faa04b"
@ -18401,7 +18637,7 @@ string-length@^4.0.1:
char-regex "^1.0.2"
strip-ansi "^6.0.0"
"string-width-cjs@npm:string-width@^4.2.0":
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@ -18454,15 +18690,6 @@ string-width@^4.2.0:
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.0"
string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
dependencies:
emoji-regex "^8.0.0"
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"
string-width@^5.0.1, string-width@^5.1.2:
version "5.1.2"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794"
@ -18522,7 +18749,7 @@ string_decoder@~1.1.1:
dependencies:
safe-buffer "~5.1.0"
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@ -18557,13 +18784,6 @@ strip-ansi@^6.0.0:
dependencies:
ansi-regex "^5.0.0"
strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
dependencies:
ansi-regex "^5.0.1"
strip-ansi@^7.0.1:
version "7.1.0"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45"
@ -20536,11 +20756,34 @@ which-boxed-primitive@^1.0.2:
is-string "^1.0.5"
is-symbol "^1.0.3"
which-collection@^1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/which-collection/-/which-collection-1.0.2.tgz#627ef76243920a107e7ce8e96191debe4b16c2a0"
integrity sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==
dependencies:
is-map "^2.0.3"
is-set "^2.0.3"
is-weakmap "^2.0.2"
is-weakset "^2.0.3"
which-module@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/which-module/-/which-module-1.0.0.tgz#bba63ca861948994ff307736089e3b96026c2a4f"
integrity sha1-u6Y8qGGUiZT/MHc2CJ47lgJsKk8=
which-typed-array@^1.1.13:
version "1.1.19"
resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.19.tgz#df03842e870b6b88e117524a4b364b6fc689f956"
integrity sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==
dependencies:
available-typed-arrays "^1.0.7"
call-bind "^1.0.8"
call-bound "^1.0.4"
for-each "^0.3.5"
get-proto "^1.0.1"
gopd "^1.2.0"
has-tostringtag "^1.0.2"
which-typed-array@^1.1.14, which-typed-array@^1.1.15:
version "1.1.15"
resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.15.tgz#264859e9b11a649b388bfaaf4f767df1f779b38d"
@ -20609,7 +20852,7 @@ worker-farm@^1.7.0:
dependencies:
errno "~0.1.7"
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
@ -20661,15 +20904,6 @@ wrap-ansi@^6.2.0:
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
dependencies:
ansi-styles "^4.0.0"
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@^8.1.0:
version "8.1.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"