From 9300fbd5fce99dcb27b22a66b241041fd9d68a76 Mon Sep 17 00:00:00 2001 From: Moriz Wahl Date: Fri, 9 May 2025 19:57:27 +0200 Subject: [PATCH 1/9] feat(webapp): redirect on registration for invite links (#8517) * feat(webapp): redirect on registration for invite links --- webapp/graphql/inviteCodes.js | 19 ++++++ webapp/pages/registration.spec.js | 105 ++++++++++++++++++++++++++++++ webapp/pages/registration.vue | 41 +++++++++++- 3 files changed, 163 insertions(+), 2 deletions(-) create mode 100644 webapp/graphql/inviteCodes.js diff --git a/webapp/graphql/inviteCodes.js b/webapp/graphql/inviteCodes.js new file mode 100644 index 000000000..9ff171759 --- /dev/null +++ b/webapp/graphql/inviteCodes.js @@ -0,0 +1,19 @@ +import gql from 'graphql-tag' + +export const validateInviteCodeQuery = gql` + query ($code: String!) { + validateInviteCode(code: $code) { + invitedTo { + id + slug + groupType + } + } + } +` + +export const redeemInviteCodeMutation = gql` + mutation ($code: String!) { + redeemInviteCode(code: $code) + } +` diff --git a/webapp/pages/registration.spec.js b/webapp/pages/registration.spec.js index 8be08fa5e..9bc6f150b 100644 --- a/webapp/pages/registration.spec.js +++ b/webapp/pages/registration.spec.js @@ -2,6 +2,7 @@ import Vuex from 'vuex' import { mount } from '@vue/test-utils' import Registration from './registration.vue' import Vue from 'vue' +import { validateInviteCodeQuery, redeemInviteCodeMutation } from '~/graphql/inviteCodes' const localVue = global.localVue @@ -12,6 +13,18 @@ const stubs = { 'infinite-loading': true, } +const queryMock = jest.fn() +const mutationMock = jest.fn() + +const app = { + apolloProvider: { + defaultClient: { + query: queryMock, + mutation: mutationMock, + }, + }, +} + describe('Registration', () => { let wrapper let Wrapper @@ -20,6 +33,7 @@ describe('Registration', () => { let store let redirect let isLoggedIn + let route beforeEach(() => { mocks = { @@ -39,6 +53,9 @@ describe('Registration', () => { asyncData = false isLoggedIn = false redirect = jest.fn() + route = { + query: {}, + } }) describe('mount', () => { @@ -65,6 +82,8 @@ describe('Registration', () => { const aData = await Registration.asyncData({ store, redirect, + route, + app, }) Registration.data = function () { return { ...data, ...aData } @@ -330,6 +349,92 @@ describe('Registration', () => { expect(redirect).toHaveBeenCalledWith('/') }) + describe('already logged in', () => { + beforeEach(async () => { + asyncData = true + isLoggedIn = true + }) + + describe('route contains personal invite code', () => { + beforeEach(async () => { + jest.clearAllMocks() + queryMock.mockResolvedValue({ + data: { + validateInviteCode: { + invitedTo: null, + }, + }, + }) + route.query.inviteCode = 'ABCDEF' + wrapper = await Wrapper() + }) + + it('calls validate invite code', () => { + expect(queryMock).toHaveBeenCalledWith({ + query: validateInviteCodeQuery, + variables: { + code: 'ABCDEF', + }, + }) + }) + + it('redirects to index', () => { + expect(redirect).toHaveBeenCalledWith('/') + }) + + it('does not redeem the link', () => { + expect(mutationMock).not.toBeCalled() + }) + }) + + // no idea why this is not working + describe.skip('route contains group invite code to public group', () => { + beforeEach(async () => { + jest.clearAllMocks() + queryMock.mockResolvedValue({ + data: { + validateInviteCode: { + invitedTo: { + id: 'public-group', + slug: 'public-group', + groupType: 'public', + }, + }, + }, + }) + mutationMock.mockResolvedValue({ + data: { + redeemInviteCode: true, + }, + }) + route.query.inviteCode = 'ABCDEF' + wrapper = await Wrapper() + }) + + it('calls validate invite code', () => { + expect(queryMock).toHaveBeenCalledWith({ + query: validateInviteCodeQuery, + variables: { + code: 'ABCDEF', + }, + }) + }) + + it('redirects to group', () => { + expect(redirect).toHaveBeenCalledWith('/groups/public-group/public-group') + }) + + it('redeems the code', () => { + expect(mutationMock).toBeCalledWith({ + mutation: redeemInviteCodeMutation, + variables: { + code: 'ABCDEF', + }, + }) + }) + }) + }) + // copied from webapp/components/Registration/Signup.spec.js as testing template // describe('with invitation code', () => { // let action diff --git a/webapp/pages/registration.vue b/webapp/pages/registration.vue index c62d28c06..ec8d94adc 100644 --- a/webapp/pages/registration.vue +++ b/webapp/pages/registration.vue @@ -11,6 +11,7 @@ - diff --git a/webapp/components/Registration/RegistrationSlideInvite.vue b/webapp/components/Registration/RegistrationSlideInvite.vue index 9041fd345..276dec737 100644 --- a/webapp/components/Registration/RegistrationSlideInvite.vue +++ b/webapp/components/Registration/RegistrationSlideInvite.vue @@ -13,28 +13,48 @@ id="inviteCode" icon="question-circle" /> - + {{ $t('components.registration.invite-code.form.description') }} +
+ + + {{ + $t('components.registration.invite-code.invited-to-hidden-group', { + invitedBy: invitedBy.name, + }) + }} + + + {{ + $t('components.registration.invite-code.invited-by-and-to', { + invitedBy: invitedBy.name, + invitedTo: invitedTo.name, + }) + }} + + + {{ $t('components.registration.invite-code.invited-by', { invitedBy: invitedBy.name }) }} + +
- diff --git a/webapp/components/_new/features/Invitations/CreateInvitation.spec.js b/webapp/components/_new/features/Invitations/CreateInvitation.spec.js new file mode 100644 index 000000000..10e13d62c --- /dev/null +++ b/webapp/components/_new/features/Invitations/CreateInvitation.spec.js @@ -0,0 +1,51 @@ +import { render, screen, fireEvent } from '@testing-library/vue' + +import CreateInvitation from './CreateInvitation.vue' + +const localVue = global.localVue + +describe('CreateInvitation.vue', () => { + let wrapper + + const Wrapper = ({ isDisabled = false }) => { + return render(CreateInvitation, { + localVue, + propsData: { + isDisabled, + }, + mocks: { + $t: jest.fn((v) => v), + }, + }) + } + + it('renders', () => { + wrapper = Wrapper({}) + expect(wrapper.container).toMatchSnapshot() + }) + + it('renders with disabled button', () => { + wrapper = Wrapper({ isDisabled: true }) + expect(wrapper.container).toMatchSnapshot() + }) + + describe('when the form is submitted', () => { + beforeEach(() => { + wrapper = Wrapper({}) + }) + + it('emits generate-invite-code with empty comment', async () => { + const button = screen.getByRole('button') + await fireEvent.click(button) + expect(wrapper.emitted()['generate-invite-code']).toEqual([['']]) + }) + + it('emits generate-invite-code with comment', async () => { + const button = screen.getByRole('button') + const input = screen.getByPlaceholderText('invite-codes.comment-placeholder') + await fireEvent.update(input, 'Test comment') + await fireEvent.click(button) + expect(wrapper.emitted()['generate-invite-code']).toEqual([['Test comment']]) + }) + }) +}) diff --git a/webapp/components/_new/features/Invitations/CreateInvitation.vue b/webapp/components/_new/features/Invitations/CreateInvitation.vue new file mode 100644 index 000000000..6147ca682 --- /dev/null +++ b/webapp/components/_new/features/Invitations/CreateInvitation.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/webapp/components/_new/features/Invitations/Invitation.spec.js b/webapp/components/_new/features/Invitations/Invitation.spec.js new file mode 100644 index 000000000..dd62847ef --- /dev/null +++ b/webapp/components/_new/features/Invitations/Invitation.spec.js @@ -0,0 +1,115 @@ +import { render, screen, fireEvent } from '@testing-library/vue' +import '@testing-library/jest-dom' + +import Invitation from './Invitation.vue' + +const localVue = global.localVue + +Object.assign(navigator, { + clipboard: { + writeText: jest.fn(), + }, +}) + +const mutations = { + 'modal/SET_OPEN': jest.fn().mockResolvedValue(), +} + +describe('Invitation.vue', () => { + let wrapper + + const Wrapper = ({ wasRedeemed = false, withCopymessage = false }) => { + const propsData = { + inviteCode: { + code: 'test-invite-code', + comment: 'test-comment', + redeemedByCount: wasRedeemed ? 1 : 0, + }, + copyMessage: withCopymessage ? 'test-copy-message' : undefined, + } + return render(Invitation, { + localVue, + propsData, + mocks: { + $t: jest.fn((v) => v), + $toast: { + success: jest.fn(), + error: jest.fn(), + }, + }, + mutations, + }) + } + + describe('when the invite code was redeemed', () => { + beforeEach(() => { + wrapper = Wrapper({ wasRedeemed: true }) + }) + + it('renders', () => { + expect(wrapper.container).toMatchSnapshot() + }) + + it('says how many times the code was redeemed', () => { + const redeemedCount = screen.getByText('invite-codes.redeemed-count') + expect(redeemedCount).toBeInTheDocument() + }) + }) + + describe('when the invite code was not redeemed', () => { + beforeEach(() => { + wrapper = Wrapper({ wasRedeemed: false }) + }) + + it('renders', () => { + expect(wrapper.container).toMatchSnapshot() + }) + + it('says it was not redeemed', () => { + const redeemedCount = screen.queryByText('invite-codes.redeemed-count-0') + expect(redeemedCount).toBeInTheDocument() + }) + }) + + describe('without copy message', () => { + beforeEach(() => { + wrapper = Wrapper({ withCopymessage: false }) + }) + + it('can copy the link', async () => { + const clipboardMock = jest.spyOn(navigator.clipboard, 'writeText').mockResolvedValue() + const copyButton = screen.getByLabelText('invite-codes.copy-code') + await fireEvent.click(copyButton) + expect(clipboardMock).toHaveBeenCalledWith( + 'http://localhost/registration?method=invite-code&inviteCode=test-invite-code', + ) + }) + }) + + describe('with copy message', () => { + beforeEach(() => { + wrapper = Wrapper({ withCopymessage: true }) + }) + + it('can copy the link with message', async () => { + const clipboardMock = jest.spyOn(navigator.clipboard, 'writeText').mockResolvedValue() + const copyButton = screen.getByLabelText('invite-codes.copy-code') + await fireEvent.click(copyButton) + expect(clipboardMock).toHaveBeenCalledWith( + 'test-copy-message http://localhost/registration?method=invite-code&inviteCode=test-invite-code', + ) + }) + }) + + describe.skip('invalidate button', () => { + beforeEach(() => { + wrapper = Wrapper({ wasRedeemed: false }) + }) + + it('opens the delete modal', async () => { + const deleteButton = screen.getByLabelText('invite-codes.invalidate') + await fireEvent.click(deleteButton) + expect(mutations['modal/SET_OPEN']).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/webapp/components/_new/features/Invitations/Invitation.vue b/webapp/components/_new/features/Invitations/Invitation.vue new file mode 100644 index 000000000..989af2e57 --- /dev/null +++ b/webapp/components/_new/features/Invitations/Invitation.vue @@ -0,0 +1,155 @@ + + + + + diff --git a/webapp/components/_new/features/Invitations/InvitationList.spec.js b/webapp/components/_new/features/Invitations/InvitationList.spec.js new file mode 100644 index 000000000..dc60bb93f --- /dev/null +++ b/webapp/components/_new/features/Invitations/InvitationList.spec.js @@ -0,0 +1,113 @@ +import { render, screen, fireEvent } from '@testing-library/vue' +import '@testing-library/jest-dom' + +import InvitationList from './InvitationList.vue' + +const localVue = global.localVue + +Object.assign(navigator, { + clipboard: { + writeText: jest.fn(), + }, +}) + +const sampleInviteCodes = [ + { + code: 'test-invite-code-1', + comment: 'test-comment', + redeemedByCount: 0, + isValid: true, + }, + { + code: 'test-invite-code-2', + comment: 'test-comment-2', + redeemedByCount: 1, + isValid: true, + }, + { + code: 'test-invite-code-3', + comment: 'test-comment-3', + redeemedByCount: 0, + isValid: false, + }, +] + +describe('InvitationList.vue', () => { + let wrapper + + const Wrapper = ({ withInviteCodes, withCopymessage = false, limit = 3 }) => { + const propsData = { + inviteCodes: withInviteCodes ? sampleInviteCodes : [], + copyMessage: withCopymessage ? 'test-copy-message' : undefined, + } + return render(InvitationList, { + localVue, + propsData, + mocks: { + $t: jest.fn((v) => v), + $toast: { + success: jest.fn(), + error: jest.fn(), + }, + $env: { + INVITE_LINK_LIMIT: limit, + }, + }, + stubs: { + 'client-only': true, + }, + }) + } + + it('renders', () => { + wrapper = Wrapper({ withInviteCodes: true }) + expect(wrapper.container).toMatchSnapshot() + }) + + it('renders empty state', () => { + wrapper = Wrapper({ withInviteCodes: false }) + expect(wrapper.container).toMatchSnapshot() + }) + + it('does not render invalid invite codes', () => { + wrapper = Wrapper({ withInviteCodes: true }) + const invalidInviteCode = screen.queryByText('invite-codes.test-invite-code-3') + expect(invalidInviteCode).not.toBeInTheDocument() + }) + + describe('without copy message', () => { + beforeEach(() => { + wrapper = Wrapper({ withCopymessage: false, withInviteCodes: true }) + }) + + it('can copy a link', async () => { + const clipboardMock = jest.spyOn(navigator.clipboard, 'writeText').mockResolvedValue() + const copyButton = screen.getAllByLabelText('invite-codes.copy-code')[0] + await fireEvent.click(copyButton) + expect(clipboardMock).toHaveBeenCalledWith( + 'http://localhost/registration?method=invite-code&inviteCode=test-invite-code-1', + ) + }) + }) + + describe('with copy message', () => { + beforeEach(() => { + wrapper = Wrapper({ withCopymessage: true, withInviteCodes: true }) + }) + + it('can copy the link with message', async () => { + const clipboardMock = jest.spyOn(navigator.clipboard, 'writeText').mockResolvedValue() + const copyButton = screen.getAllByLabelText('invite-codes.copy-code')[0] + await fireEvent.click(copyButton) + expect(clipboardMock).toHaveBeenCalledWith( + 'test-copy-message http://localhost/registration?method=invite-code&inviteCode=test-invite-code-1', + ) + }) + }) + + it('cannot generate more than the limit of invite codes', () => { + wrapper = Wrapper({ withInviteCodes: true, limit: 2 }) + const generateButton = screen.getByLabelText('invite-codes.generate-code') + expect(generateButton).toBeDisabled() + }) +}) diff --git a/webapp/components/_new/features/Invitations/InvitationList.vue b/webapp/components/_new/features/Invitations/InvitationList.vue new file mode 100644 index 000000000..355139cd2 --- /dev/null +++ b/webapp/components/_new/features/Invitations/InvitationList.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/webapp/components/_new/features/Invitations/__snapshots__/CreateInvitation.spec.js.snap b/webapp/components/_new/features/Invitations/__snapshots__/CreateInvitation.spec.js.snap new file mode 100644 index 000000000..ac2eb0a09 --- /dev/null +++ b/webapp/components/_new/features/Invitations/__snapshots__/CreateInvitation.spec.js.snap @@ -0,0 +1,140 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CreateInvitation.vue renders 1`] = ` +
+
+
+ invite-codes.generate-code-explanation +
+ +
+
+ +
+ + + +
+ + + +
+ + +
+
+
+`; + +exports[`CreateInvitation.vue renders with disabled button 1`] = ` +
+
+
+ invite-codes.generate-code-explanation +
+ +
+
+ +
+ + + +
+ + + +
+ + +
+
+
+`; diff --git a/webapp/components/_new/features/Invitations/__snapshots__/Invitation.spec.js.snap b/webapp/components/_new/features/Invitations/__snapshots__/Invitation.spec.js.snap new file mode 100644 index 000000000..10e9e68a5 --- /dev/null +++ b/webapp/components/_new/features/Invitations/__snapshots__/Invitation.spec.js.snap @@ -0,0 +1,147 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Invitation.vue when the invite code was not redeemed renders 1`] = ` +
+
  • +
    +
    + + test-invite-code + + + — + + + + test-comment + +
    + +
    + + + invite-codes.redeemed-count-0 + + +
    +
    + +
    + + + +
    +
  • +
    +`; + +exports[`Invitation.vue when the invite code was redeemed renders 1`] = ` +
    +
  • +
    +
    + + test-invite-code + + + — + + + + test-comment + +
    + +
    + + + invite-codes.redeemed-count + + +
    +
    + +
    + + + +
    +
  • +
    +`; diff --git a/webapp/components/_new/features/Invitations/__snapshots__/InvitationList.spec.js.snap b/webapp/components/_new/features/Invitations/__snapshots__/InvitationList.spec.js.snap new file mode 100644 index 000000000..6bad7db58 --- /dev/null +++ b/webapp/components/_new/features/Invitations/__snapshots__/InvitationList.spec.js.snap @@ -0,0 +1,296 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`InvitationList.vue renders 1`] = ` +
    +
    +
      + +
    • +
      +
      + + test-invite-code-1 + + + — + + + + test-comment + +
      + +
      + + + invite-codes.redeemed-count-0 + + +
      +
      + +
      + + + +
      +
    • +
    • +
      +
      + + test-invite-code-2 + + + — + + + + test-comment-2 + +
      + +
      + + + invite-codes.redeemed-count + + +
      +
      + +
      + + + +
      +
    • +
      +
    + +
    +
    + invite-codes.generate-code-explanation +
    + +
    +
    + +
    + + + +
    + + + +
    + + +
    +
    +
    +
    +`; + +exports[`InvitationList.vue renders empty state 1`] = ` +
    +
    +
    + + invite-codes.no-links + +
    + +
    +
    + invite-codes.generate-code-explanation +
    + +
    +
    + +
    + + + +
    + + + +
    + + +
    +
    +
    +
    +`; diff --git a/webapp/config/index.js b/webapp/config/index.js index fb275a8ec..074cb0736 100644 --- a/webapp/config/index.js +++ b/webapp/config/index.js @@ -36,6 +36,8 @@ const options = { COOKIE_HTTPS_ONLY: process.env.COOKIE_HTTPS_ONLY || process.env.NODE_ENV === 'production', // ensure true in production if not set explicitly CATEGORIES_ACTIVE: process.env.CATEGORIES_ACTIVE === 'true' || false, BADGES_ENABLED: process.env.BADGES_ENABLED === 'true' || false, + INVITE_LINK_LIMIT: process.env.INVITE_LINK_LIMIT || 7, + NETWORK_NAME: process.env.NETWORK_NAME || 'Ocelot.social', } const CONFIG = { diff --git a/webapp/graphql/InviteCode.js b/webapp/graphql/InviteCode.js new file mode 100644 index 000000000..99a8ee9f2 --- /dev/null +++ b/webapp/graphql/InviteCode.js @@ -0,0 +1,137 @@ +import gql from 'graphql-tag' + +export const validateInviteCode = () => gql` + query validateInviteCode($code: String!) { + validateInviteCode(code: $code) { + code + invitedTo { + groupType + name + about + avatar { + url + } + } + generatedBy { + name + avatar { + url + } + } + isValid + } + } +` + +export const generatePersonalInviteCode = () => gql` + mutation generatePersonalInviteCode($expiresAt: String, $comment: String) { + generatePersonalInviteCode(expiresAt: $expiresAt, comment: $comment) { + code + createdAt + generatedBy { + id + name + avatar { + url + } + } + redeemedBy { + id + name + avatar { + url + } + } + redeemedByCount + expiresAt + comment + invitedTo { + groupType + name + about + avatar { + url + } + } + isValid + } + } +` + +export const generateGroupInviteCode = () => gql` + mutation generateGroupInviteCode($groupId: ID!, $expiresAt: String, $comment: String) { + generateGroupInviteCode(groupId: $groupId, expiresAt: $expiresAt, comment: $comment) { + code + createdAt + generatedBy { + id + name + avatar { + url + } + } + redeemedBy { + id + name + avatar { + url + } + } + redeemedByCount + expiresAt + comment + invitedTo { + id + groupType + name + about + avatar { + url + } + } + isValid + } + } +` + +export const invalidateInviteCode = () => gql` + mutation invalidateInviteCode($code: String!) { + invalidateInviteCode(code: $code) { + code + createdAt + generatedBy { + id + name + avatar { + url + } + } + redeemedBy { + id + name + avatar { + url + } + } + redeemedByCount + expiresAt + comment + invitedTo { + id + groupType + name + about + avatar { + url + } + } + isValid + } + } +` + +export const redeemInviteCode = () => gql` + mutation redeemInviteCode($code: String!) { + redeemInviteCode(code: $code) + } +` diff --git a/webapp/graphql/User.js b/webapp/graphql/User.js index 7440b5051..2f2a0ce65 100644 --- a/webapp/graphql/User.js +++ b/webapp/graphql/User.js @@ -406,6 +406,15 @@ export const currentUserQuery = gql` query { currentUser { ...user + inviteCodes { + code + isValid + redeemedBy { + id + } + comment + redeemedByCount + } badgeTrophiesSelected { id icon diff --git a/webapp/graphql/groups.js b/webapp/graphql/groups.js index bd17b0484..5ce33407b 100644 --- a/webapp/graphql/groups.js +++ b/webapp/graphql/groups.js @@ -195,6 +195,16 @@ export const groupQuery = (i18n) => { lat } myRole + inviteCodes { + createdAt + code + isValid + redeemedBy { + id + } + comment + redeemedByCount + } } } ` diff --git a/webapp/locales/de.json b/webapp/locales/de.json index fbfaf87c0..ac58891c4 100644 --- a/webapp/locales/de.json +++ b/webapp/locales/de.json @@ -244,7 +244,10 @@ "length": "muss genau {inviteCodeLength} Buchstaben lang sein", "success": "Gültiger Einladungs-Code {inviteCode}!" } - } + }, + "invited-by": "Eingeladen von {invitedBy}", + "invited-by-and-to": "Einladung von {invitedBy} zur Grupppe {invitedTo}", + "invited-to-hidden-group": "Eingeladen von {invitedBy} zu einer versteckten Gruppe" }, "no-public-registrstion": { "title": "Keine öffentliche Registrierung möglich" @@ -509,6 +512,7 @@ "categoriesTitle": "Themen der Gruppe", "changeMemberRole": "Die Rolle wurde auf „{role}“ geändert!", "contentMenu": { + "inviteLinks": "Einladungslinks", "muteGroup": "Stummschalten", "unmuteGroup": "Nicht stummschalten", "visitGroupPage": "Gruppe anzeigen" @@ -531,6 +535,7 @@ "goal": "Ziel der Gruppe", "groupCreated": "Die Gruppe wurde angelegt!", "in": "in", + "invite-links": "Einladungslinks", "joinLeaveButton": { "iAmMember": "Bin Mitglied", "join": "Beitreten", @@ -628,10 +633,29 @@ "button": { "tooltip": "Freunde einladen" }, + "comment-placeholder": "Kommentar (optional)", "copy-code": "Einladungslink kopieren", "copy-success": "Einladungscode erfolgreich in die Zwischenablage kopiert", - "not-available": "Du hast keinen Einladungscode zur Verfügung!", - "your-code": "Sende diesen Link per E-Mail oder in sozialen Medien, um deine Freunde einzuladen:" + "create-error": "Einladungslink konnte nicht erstellt werden: {error}", + "create-success": "Einladungslink erfolgreich erstellt!", + "delete-modal": { + "message": "Möchtest du diesen Einladungslink wirklich ungültig machen?", + "title": "Einladungslink widerrufen" + }, + "generate-code": "Neuen Einladungslink erstellen", + "generate-code-explanation": "Erstelle einen neuen Link. Wenn du möchtest, füge einen Kommentar hinzu (nur für dich sichtbar). ", + "group-invite-links": "Gruppen-Einladungslinks", + "invalidate": "Widerrufen", + "invalidate-error": "Einladungslink konnte nicht ungültig gemacht werden: {error}", + "invalidate-success": "Einladungslink erfolgreich widerrufen", + "invite-link-message-group": "Du wurdest eingeladen, der Gruppe {groupName} auf {network} beizutreten.", + "invite-link-message-hidden-group": "Du wurdest eingeladen, einer versteckten Gruppe auf {network} beizutreten.", + "invite-link-message-personal": "Du wurdest eingeladen, dem Netzwerk {network} beizutreten", + "limit-reached": "Du hast die maximale Anzahl an Einladungslinks erreicht.", + "my-invite-links": "Meine Einladungslinks", + "no-links": "Keine Links vorhanden", + "redeemed-count": "{count} mal eingelöst", + "redeemed-count-0": "Noch von niemandem eingelöst" }, "localeSwitch": { "tooltip": "Sprache wählen" diff --git a/webapp/locales/en.json b/webapp/locales/en.json index ab34ba66a..33c3abfd9 100644 --- a/webapp/locales/en.json +++ b/webapp/locales/en.json @@ -244,7 +244,10 @@ "length": "must be {inviteCodeLength} characters long", "success": "Valid invite code {inviteCode}!" } - } + }, + "invited-by": "Invited by {invitedBy}.", + "invited-by-and-to": "Invited by {invitedBy} to group {invitedTo}.", + "invited-to-hidden-group": "Invited by {invitedBy} to a hidden group." }, "no-public-registrstion": { "title": "No Public Registration" @@ -509,6 +512,7 @@ "categoriesTitle": "Topics of the group", "changeMemberRole": "The role has been changed to “{role}”!", "contentMenu": { + "inviteLinks": "Invite links", "muteGroup": "Mute group", "unmuteGroup": "Unmute group", "visitGroupPage": "Show group" @@ -531,6 +535,7 @@ "goal": "Goal of group", "groupCreated": "The group was created!", "in": "in", + "invite-links": "Invite Links", "joinLeaveButton": { "iAmMember": "I'm a member", "join": "Join", @@ -628,10 +633,29 @@ "button": { "tooltip": "Invite friends" }, + "comment-placeholder": "Comment (optional)", "copy-code": "Copy Invite Link", "copy-success": "Invite code copied to clipboard", - "not-available": "You have no valid invite code available!", - "your-code": "Send this link per e-mail or in social media to invite your friends:" + "create-error": "Creating a new invite link failed! Error: {error}", + "create-success": "Invite link created successfully!", + "delete-modal": { + "message": "Do you really want to invalidate this invite link?", + "title": "Invalidate link?" + }, + "generate-code": "Create new link", + "generate-code-explanation": "Create a new link. You can add a comment if you like (only visible to you).", + "group-invite-links": "Group invite links", + "invalidate": "Invalidate link", + "invalidate-error": "Invalidating the invite link failed! Error: {error}", + "invalidate-success": "Invite link invalidated successfully!", + "invite-link-message-group": "You have been invited to join the group “{groupName}” on {network}.", + "invite-link-message-hidden-group": "You have been invited to join a hidden group on {network}.", + "invite-link-message-personal": "You have been invited to join {network}.", + "limit-reached": "You have reached the maximum number of invite links.", + "my-invite-links": "My invite links", + "no-links": "No invite links created yet.", + "redeemed-count": "This code has been used {count} times.", + "redeemed-count-0": "No one has used this code yet." }, "localeSwitch": { "tooltip": "Choose language" diff --git a/webapp/locales/es.json b/webapp/locales/es.json index b7d95d11c..3c4d06403 100644 --- a/webapp/locales/es.json +++ b/webapp/locales/es.json @@ -244,7 +244,10 @@ "length": null, "success": null } - } + }, + "invited-by": null, + "invited-by-and-to": null, + "invited-to-hidden-group": null }, "no-public-registrstion": { "title": null @@ -509,6 +512,7 @@ "categoriesTitle": null, "changeMemberRole": null, "contentMenu": { + "inviteLinks": null, "muteGroup": "Silenciar grupo", "unmuteGroup": "Desactivar silencio del grupo", "visitGroupPage": null @@ -531,6 +535,7 @@ "goal": null, "groupCreated": null, "in": null, + "invite-links": null, "joinLeaveButton": { "iAmMember": null, "join": null, @@ -628,10 +633,29 @@ "button": { "tooltip": null }, + "comment-placeholder": null, "copy-code": null, "copy-success": null, - "not-available": null, - "your-code": null + "create-error": null, + "create-success": null, + "delete-modal": { + "message": null, + "title": null + }, + "generate-code": null, + "generate-code-explanation": null, + "group-invite-links": null, + "invalidate": null, + "invalidate-error": null, + "invalidate-success": null, + "invite-link-message-group": null, + "invite-link-message-hidden-group": null, + "invite-link-message-personal": null, + "limit-reached": null, + "my-invite-links": null, + "no-links": null, + "redeemed-count": null, + "redeemed-count-0": null }, "localeSwitch": { "tooltip": null diff --git a/webapp/locales/fr.json b/webapp/locales/fr.json index 37a182c28..583726c52 100644 --- a/webapp/locales/fr.json +++ b/webapp/locales/fr.json @@ -244,7 +244,10 @@ "length": null, "success": null } - } + }, + "invited-by": null, + "invited-by-and-to": null, + "invited-to-hidden-group": null }, "no-public-registrstion": { "title": null @@ -509,6 +512,7 @@ "categoriesTitle": null, "changeMemberRole": null, "contentMenu": { + "inviteLinks": null, "muteGroup": null, "unmuteGroup": null, "visitGroupPage": null @@ -531,6 +535,7 @@ "goal": null, "groupCreated": null, "in": null, + "invite-links": null, "joinLeaveButton": { "iAmMember": null, "join": null, @@ -628,10 +633,29 @@ "button": { "tooltip": null }, + "comment-placeholder": null, "copy-code": null, "copy-success": null, - "not-available": null, - "your-code": null + "create-error": null, + "create-success": null, + "delete-modal": { + "message": null, + "title": null + }, + "generate-code": null, + "generate-code-explanation": null, + "group-invite-links": null, + "invalidate": null, + "invalidate-error": null, + "invalidate-success": null, + "invite-link-message-group": null, + "invite-link-message-hidden-group": null, + "invite-link-message-personal": null, + "limit-reached": null, + "my-invite-links": null, + "no-links": null, + "redeemed-count": null, + "redeemed-count-0": null }, "localeSwitch": { "tooltip": null diff --git a/webapp/locales/it.json b/webapp/locales/it.json index 6b686502c..2896c2c1f 100644 --- a/webapp/locales/it.json +++ b/webapp/locales/it.json @@ -244,7 +244,10 @@ "length": null, "success": null } - } + }, + "invited-by": null, + "invited-by-and-to": null, + "invited-to-hidden-group": null }, "no-public-registrstion": { "title": null @@ -509,6 +512,7 @@ "categoriesTitle": null, "changeMemberRole": null, "contentMenu": { + "inviteLinks": null, "muteGroup": null, "unmuteGroup": null, "visitGroupPage": null @@ -531,6 +535,7 @@ "goal": null, "groupCreated": null, "in": null, + "invite-links": null, "joinLeaveButton": { "iAmMember": null, "join": null, @@ -628,10 +633,29 @@ "button": { "tooltip": null }, + "comment-placeholder": null, "copy-code": null, "copy-success": null, - "not-available": null, - "your-code": null + "create-error": null, + "create-success": null, + "delete-modal": { + "message": null, + "title": null + }, + "generate-code": null, + "generate-code-explanation": null, + "group-invite-links": null, + "invalidate": null, + "invalidate-error": null, + "invalidate-success": null, + "invite-link-message-group": null, + "invite-link-message-hidden-group": null, + "invite-link-message-personal": null, + "limit-reached": null, + "my-invite-links": null, + "no-links": null, + "redeemed-count": null, + "redeemed-count-0": null }, "localeSwitch": { "tooltip": null diff --git a/webapp/locales/nl.json b/webapp/locales/nl.json index 714ed2b01..e4ae47dc4 100644 --- a/webapp/locales/nl.json +++ b/webapp/locales/nl.json @@ -244,7 +244,10 @@ "length": null, "success": null } - } + }, + "invited-by": null, + "invited-by-and-to": null, + "invited-to-hidden-group": null }, "no-public-registrstion": { "title": null @@ -509,6 +512,7 @@ "categoriesTitle": null, "changeMemberRole": null, "contentMenu": { + "inviteLinks": null, "muteGroup": null, "unmuteGroup": null, "visitGroupPage": null @@ -531,6 +535,7 @@ "goal": null, "groupCreated": null, "in": null, + "invite-links": null, "joinLeaveButton": { "iAmMember": null, "join": null, @@ -628,10 +633,29 @@ "button": { "tooltip": null }, + "comment-placeholder": null, "copy-code": null, "copy-success": null, - "not-available": null, - "your-code": null + "create-error": null, + "create-success": null, + "delete-modal": { + "message": null, + "title": null + }, + "generate-code": null, + "generate-code-explanation": null, + "group-invite-links": null, + "invalidate": null, + "invalidate-error": null, + "invalidate-success": null, + "invite-link-message-group": null, + "invite-link-message-hidden-group": null, + "invite-link-message-personal": null, + "limit-reached": null, + "my-invite-links": null, + "no-links": null, + "redeemed-count": null, + "redeemed-count-0": null }, "localeSwitch": { "tooltip": null diff --git a/webapp/locales/pl.json b/webapp/locales/pl.json index 61a6acf24..7783ef93d 100644 --- a/webapp/locales/pl.json +++ b/webapp/locales/pl.json @@ -244,7 +244,10 @@ "length": null, "success": null } - } + }, + "invited-by": null, + "invited-by-and-to": null, + "invited-to-hidden-group": null }, "no-public-registrstion": { "title": null @@ -509,6 +512,7 @@ "categoriesTitle": null, "changeMemberRole": null, "contentMenu": { + "inviteLinks": null, "muteGroup": null, "unmuteGroup": null, "visitGroupPage": null @@ -531,6 +535,7 @@ "goal": null, "groupCreated": null, "in": null, + "invite-links": null, "joinLeaveButton": { "iAmMember": null, "join": null, @@ -628,10 +633,29 @@ "button": { "tooltip": null }, + "comment-placeholder": null, "copy-code": null, "copy-success": null, - "not-available": null, - "your-code": null + "create-error": null, + "create-success": null, + "delete-modal": { + "message": null, + "title": null + }, + "generate-code": null, + "generate-code-explanation": null, + "group-invite-links": null, + "invalidate": null, + "invalidate-error": null, + "invalidate-success": null, + "invite-link-message-group": null, + "invite-link-message-hidden-group": null, + "invite-link-message-personal": null, + "limit-reached": null, + "my-invite-links": null, + "no-links": null, + "redeemed-count": null, + "redeemed-count-0": null }, "localeSwitch": { "tooltip": null diff --git a/webapp/locales/pt.json b/webapp/locales/pt.json index 80172daa3..7cd7b2a4b 100644 --- a/webapp/locales/pt.json +++ b/webapp/locales/pt.json @@ -244,7 +244,10 @@ "length": null, "success": null } - } + }, + "invited-by": null, + "invited-by-and-to": null, + "invited-to-hidden-group": null }, "no-public-registrstion": { "title": null @@ -509,6 +512,7 @@ "categoriesTitle": null, "changeMemberRole": null, "contentMenu": { + "inviteLinks": null, "muteGroup": null, "unmuteGroup": null, "visitGroupPage": null @@ -531,6 +535,7 @@ "goal": null, "groupCreated": null, "in": null, + "invite-links": null, "joinLeaveButton": { "iAmMember": null, "join": null, @@ -628,10 +633,29 @@ "button": { "tooltip": null }, + "comment-placeholder": null, "copy-code": null, "copy-success": null, - "not-available": null, - "your-code": null + "create-error": null, + "create-success": null, + "delete-modal": { + "message": null, + "title": null + }, + "generate-code": null, + "generate-code-explanation": null, + "group-invite-links": null, + "invalidate": null, + "invalidate-error": null, + "invalidate-success": null, + "invite-link-message-group": null, + "invite-link-message-hidden-group": null, + "invite-link-message-personal": null, + "limit-reached": null, + "my-invite-links": null, + "no-links": null, + "redeemed-count": null, + "redeemed-count-0": null }, "localeSwitch": { "tooltip": null diff --git a/webapp/locales/ru.json b/webapp/locales/ru.json index f7956755c..eb365dcdb 100644 --- a/webapp/locales/ru.json +++ b/webapp/locales/ru.json @@ -244,7 +244,10 @@ "length": null, "success": null } - } + }, + "invited-by": null, + "invited-by-and-to": null, + "invited-to-hidden-group": null }, "no-public-registrstion": { "title": null @@ -509,6 +512,7 @@ "categoriesTitle": null, "changeMemberRole": null, "contentMenu": { + "inviteLinks": null, "muteGroup": null, "unmuteGroup": null, "visitGroupPage": null @@ -531,6 +535,7 @@ "goal": null, "groupCreated": null, "in": null, + "invite-links": null, "joinLeaveButton": { "iAmMember": null, "join": null, @@ -628,10 +633,29 @@ "button": { "tooltip": null }, + "comment-placeholder": null, "copy-code": null, "copy-success": null, - "not-available": null, - "your-code": null + "create-error": null, + "create-success": null, + "delete-modal": { + "message": null, + "title": null + }, + "generate-code": null, + "generate-code-explanation": null, + "group-invite-links": null, + "invalidate": null, + "invalidate-error": null, + "invalidate-success": null, + "invite-link-message-group": null, + "invite-link-message-hidden-group": null, + "invite-link-message-personal": null, + "limit-reached": null, + "my-invite-links": null, + "no-links": null, + "redeemed-count": null, + "redeemed-count-0": null }, "localeSwitch": { "tooltip": null diff --git a/webapp/pages/groups/_id/__snapshots__/_slug.spec.js.snap b/webapp/pages/groups/_id/__snapshots__/_slug.spec.js.snap index 68c2b50ba..9c87ae4fd 100644 --- a/webapp/pages/groups/_id/__snapshots__/_slug.spec.js.snap +++ b/webapp/pages/groups/_id/__snapshots__/_slug.spec.js.snap @@ -151,6 +151,27 @@ exports[`GroupProfileSlug given a puplic group – "yoga-practice" given a close + + + +
  • + + + + + + group.contentMenu.inviteLinks + + + +
  • @@ -3009,6 +3030,27 @@ exports[`GroupProfileSlug given a puplic group – "yoga-practice" given a curre + + + +
  • + + + + + + group.contentMenu.inviteLinks + + + +
  • @@ -6489,6 +6531,27 @@ exports[`GroupProfileSlug given a puplic group – "yoga-practice" given a hidde + + + +
  • + + + + + + group.contentMenu.inviteLinks + + + +
  • diff --git a/webapp/pages/groups/edit/_id.vue b/webapp/pages/groups/edit/_id.vue index 57c7d9f6a..66eecd364 100644 --- a/webapp/pages/groups/edit/_id.vue +++ b/webapp/pages/groups/edit/_id.vue @@ -13,7 +13,7 @@ - + @@ -39,9 +39,18 @@ export default { name: this.$t('group.members'), path: `/groups/edit/${this.group.id}/members`, }, + { + name: this.$t('group.invite-links'), + path: `/groups/edit/${this.group.id}/invites`, + }, ] }, }, + data() { + return { + group: {}, + } + }, async asyncData(context) { const { app, @@ -62,5 +71,10 @@ export default { } return { group } }, + methods: { + updateInviteCodes(inviteCodes) { + this.group.inviteCodes = inviteCodes + }, + }, } diff --git a/webapp/pages/groups/edit/_id/__snapshots__/invites.spec.js.snap b/webapp/pages/groups/edit/_id/__snapshots__/invites.spec.js.snap new file mode 100644 index 000000000..2d1155691 --- /dev/null +++ b/webapp/pages/groups/edit/_id/__snapshots__/invites.spec.js.snap @@ -0,0 +1,167 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`invites.vue renders 1`] = ` +
    +
    +
    +

    + invite-codes.group-invite-links +

    + +
    + +
    +
      + +
    • +
      +
      + + INVITE1 + + + — + + + + Test invite 1 + +
      + +
      + + + invite-codes.redeemed-count-0 + + +
      +
      + +
      + + + +
      +
    • +
      +
    + +
    +
    + invite-codes.generate-code-explanation +
    + +
    +
    + +
    + + + +
    + + + +
    + + +
    +
    +
    + + +
    +
    +
    +`; diff --git a/webapp/pages/groups/edit/_id/invites.spec.js b/webapp/pages/groups/edit/_id/invites.spec.js new file mode 100644 index 000000000..8c163a4e9 --- /dev/null +++ b/webapp/pages/groups/edit/_id/invites.spec.js @@ -0,0 +1,86 @@ +import { render, screen, fireEvent } from '@testing-library/vue' + +import invites from './invites.vue' + +const localVue = global.localVue + +describe('invites.vue', () => { + let wrapper + let mocks + + beforeEach(() => { + mocks = { + $t: jest.fn((v) => v), + $apollo: { + mutate: jest.fn(), + }, + $env: { + NETWORK_NAME: 'test-network', + INVITE_LINK_LIMIT: 5, + }, + $toast: { + success: jest.fn(), + error: jest.fn(), + }, + localVue, + } + }) + + const Wrapper = () => { + return render(invites, { + localVue, + propsData: { + group: { + id: 'group1', + name: 'Group 1', + inviteCodes: [ + { + code: 'INVITE1', + comment: 'Test invite 1', + redeemedByCount: 0, + isValid: true, + }, + { + code: 'INVITE2', + comment: 'Test invite 2', + redeemedByCount: 1, + isValid: false, + }, + ], + }, + }, + mocks, + stubs: { + 'client-only': true, + }, + }) + } + + it('renders', () => { + wrapper = Wrapper() + expect(wrapper.container).toMatchSnapshot() + }) + + describe('when a new invite code is generated', () => { + beforeEach(async () => { + wrapper = Wrapper() + const createButton = screen.getByLabelText('invite-codes.generate-code') + await fireEvent.click(createButton) + }) + + it('calls the mutation to generate a new invite code', () => { + expect(mocks.$apollo.mutate).toHaveBeenCalledWith({ + mutation: expect.anything(), + update: expect.anything(), + variables: { + groupId: 'group1', + comment: '', + }, + }) + }) + + it('shows a success message', () => { + expect(mocks.$toast.success).toHaveBeenCalledWith('invite-codes.create-success') + }) + }) +}) diff --git a/webapp/pages/groups/edit/_id/invites.vue b/webapp/pages/groups/edit/_id/invites.vue new file mode 100644 index 000000000..a19cdbf40 --- /dev/null +++ b/webapp/pages/groups/edit/_id/invites.vue @@ -0,0 +1,81 @@ + + + diff --git a/webapp/store/auth.js b/webapp/store/auth.js index 4ef63e3ea..0633c6e1e 100644 --- a/webapp/store/auth.js +++ b/webapp/store/auth.js @@ -18,6 +18,9 @@ export const mutations = { SET_USER(state, user) { state.user = user || null }, + SET_USER_PARTIAL(state, user) { + state.user = { ...state.user, ...user } + }, SET_TOKEN(state, token) { state.token = token || null }, From 4e4eff8dc9705a692fe18f19c0f5652eb1a71d03 Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 10 May 2025 11:22:51 +0200 Subject: [PATCH 6/9] fix(webapp): fix layout break and hidden group name appearance (#8538) Fixes long comment overflow. There is some underlying problem with flex box and overflows. A better solution could be to use a grid, but this was the fastest I would come up with. Fixes hidden group name appearance --- webapp/components/InviteButton/InviteButton.vue | 1 + webapp/components/_new/features/Invitations/Invitation.vue | 1 + webapp/pages/groups/edit/_id/invites.vue | 4 ++-- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/webapp/components/InviteButton/InviteButton.vue b/webapp/components/InviteButton/InviteButton.vue index 3eea98f74..473116c1b 100644 --- a/webapp/components/InviteButton/InviteButton.vue +++ b/webapp/components/InviteButton/InviteButton.vue @@ -114,5 +114,6 @@ export default { display: flex; flex-flow: column; gap: $space-small; + --invitation-column-max-width: 75%; } diff --git a/webapp/components/_new/features/Invitations/Invitation.vue b/webapp/components/_new/features/Invitations/Invitation.vue index 989af2e57..f34beeb58 100644 --- a/webapp/components/_new/features/Invitations/Invitation.vue +++ b/webapp/components/_new/features/Invitations/Invitation.vue @@ -131,6 +131,7 @@ export default { display: flex; flex-flow: column; gap: $space-xx-small; + max-width: var(--invitation-column-max-width, 100%); } .code { diff --git a/webapp/pages/groups/edit/_id/invites.vue b/webapp/pages/groups/edit/_id/invites.vue index a19cdbf40..5181c21f1 100644 --- a/webapp/pages/groups/edit/_id/invites.vue +++ b/webapp/pages/groups/edit/_id/invites.vue @@ -8,8 +8,8 @@ @invalidate-invite-code="invalidateInviteCode" :inviteCodes="group.inviteCodes" :copy-message=" - group.type === 'hidden' - ? $T('invite-codes.invite-link-message-hidden-group', { + group.groupType === 'hidden' + ? $t('invite-codes.invite-link-message-hidden-group', { network: $env.NETWORK_NAME, }) : $t('invite-codes.invite-link-message-group', { From d4a96946574534a420f771fccc0023e1da4039d2 Mon Sep 17 00:00:00 2001 From: Moriz Wahl Date: Sat, 10 May 2025 12:10:55 +0200 Subject: [PATCH 7/9] feat(webapp): redirect to group after registration with invite to group (#8540) --- backend/src/middleware/permissionsMiddleware.ts | 1 + .../Registration/RegistrationSlideCreate.vue | 12 +++++++++++- webapp/graphql/InviteCode.js | 1 + 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/backend/src/middleware/permissionsMiddleware.ts b/backend/src/middleware/permissionsMiddleware.ts index 1a598b972..a775e2fe3 100644 --- a/backend/src/middleware/permissionsMiddleware.ts +++ b/backend/src/middleware/permissionsMiddleware.ts @@ -514,6 +514,7 @@ export default shield( }, Group: { '*': isAuthenticated, // TODO - only those who are allowed to see the group + slug: allow, avatar: allow, name: allow, about: allow, diff --git a/webapp/components/Registration/RegistrationSlideCreate.vue b/webapp/components/Registration/RegistrationSlideCreate.vue index 141db1c4a..9aab5d77a 100644 --- a/webapp/components/Registration/RegistrationSlideCreate.vue +++ b/webapp/components/Registration/RegistrationSlideCreate.vue @@ -269,7 +269,17 @@ export default { setTimeout(async () => { await this.$store.dispatch('auth/login', { email, password }) this.$toast.success(this.$t('login.success')) - this.$router.push('/') + const { validateInviteCode } = this.sliderData.sliders[0].data.response + if ( + validateInviteCode && + validateInviteCode.invitedTo && + validateInviteCode.invitedTo.groupType === 'public' + ) { + const { invitedTo } = validateInviteCode + this.$router.push(`/groups/${invitedTo.slug}`) + } else { + this.$router.push('/') + } this.sliderData.setSliderValuesCallback(null, { sliderSettings: { buttonLoading: false }, }) diff --git a/webapp/graphql/InviteCode.js b/webapp/graphql/InviteCode.js index 99a8ee9f2..10981327d 100644 --- a/webapp/graphql/InviteCode.js +++ b/webapp/graphql/InviteCode.js @@ -5,6 +5,7 @@ export const validateInviteCode = () => gql` validateInviteCode(code: $code) { code invitedTo { + slug groupType name about From be0a5c555ef9d4ca6abb326cff9463239f6b805c Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 10 May 2025 12:38:33 +0200 Subject: [PATCH 8/9] Show invititation dropdown until user clicks somewhere else (#8539) --- webapp/components/Dropdown.vue | 5 ++--- webapp/components/InviteButton/InviteButton.vue | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/webapp/components/Dropdown.vue b/webapp/components/Dropdown.vue index dd2b4a822..7b1aa4f80 100644 --- a/webapp/components/Dropdown.vue +++ b/webapp/components/Dropdown.vue @@ -29,11 +29,11 @@ export default { placement: { type: String, default: 'bottom-end' }, disabled: { type: Boolean, default: false }, offset: { type: [String, Number], default: '16' }, + noMouseLeaveClosing: { type: Boolean, default: false }, }, data() { return { isPopoverOpen: false, - developerNoAutoClosing: false, // stops automatic closing of menu for developer purposes: default is 'false' } }, computed: { @@ -94,8 +94,7 @@ export default { } }, popoverMouseLeave() { - if (this.developerNoAutoClosing) return - if (this.disabled) { + if (this.noMouseLeaveClosing || this.disabled) { return } this.clearTimeouts() diff --git a/webapp/components/InviteButton/InviteButton.vue b/webapp/components/InviteButton/InviteButton.vue index 473116c1b..51d8c3ff6 100644 --- a/webapp/components/InviteButton/InviteButton.vue +++ b/webapp/components/InviteButton/InviteButton.vue @@ -1,5 +1,5 @@