diff --git a/webapp/.env.template b/webapp/.env.template index 76bc502f9..ee9fd0578 100644 --- a/webapp/.env.template +++ b/webapp/.env.template @@ -6,3 +6,6 @@ MAPBOX_TOKEN="pk.eyJ1IjoiYnVzZmFrdG9yIiwiYSI6ImNraDNiM3JxcDBhaWQydG1uczhpZWtpOW4 PUBLIC_REGISTRATION=false INVITE_REGISTRATION=true CATEGORIES_ACTIVE=false +BADGES_ENABLED=true +INVITE_LINK_LIMIT=7 +NETWORK_NAME="Ocelot.social" diff --git a/webapp/assets/styles/main.scss b/webapp/assets/styles/main.scss index 4fba0b5e0..036b7b90d 100644 --- a/webapp/assets/styles/main.scss +++ b/webapp/assets/styles/main.scss @@ -142,6 +142,12 @@ hr { } } +body.dropdown-open { + max-height: 100vh; + overflow: hidden; + scrollbar-gutter: stable; +} + .base-card > .ds-section { padding: 0; margin: -$space-base; diff --git a/webapp/components/ContentMenu/GroupContentMenu.vue b/webapp/components/ContentMenu/GroupContentMenu.vue index e28a855ac..2c15ad28d 100644 --- a/webapp/components/ContentMenu/GroupContentMenu.vue +++ b/webapp/components/ContentMenu/GroupContentMenu.vue @@ -89,6 +89,11 @@ export default { path: `/groups/edit/${this.group.id}`, icon: 'edit', }) + routes.push({ + label: this.$t('group.contentMenu.inviteLinks'), + path: `/groups/edit/${this.group.id}/invites`, + icon: 'link', + }) } return routes diff --git a/webapp/components/ContentMenu/__snapshots__/GroupContentMenu.spec.js.snap b/webapp/components/ContentMenu/__snapshots__/GroupContentMenu.spec.js.snap index 0553dfa79..5875d2341 100644 --- a/webapp/components/ContentMenu/__snapshots__/GroupContentMenu.spec.js.snap +++ b/webapp/components/ContentMenu/__snapshots__/GroupContentMenu.spec.js.snap @@ -88,6 +88,27 @@ exports[`GroupContentMenu renders as groupProfile when I am the owner 1`] = ` + + + +
  • + + + + + + group.contentMenu.inviteLinks + + + +
  • diff --git a/webapp/components/InviteButton/InviteButton.spec.js b/webapp/components/InviteButton/InviteButton.spec.js deleted file mode 100644 index 1282c2bad..000000000 --- a/webapp/components/InviteButton/InviteButton.spec.js +++ /dev/null @@ -1,59 +0,0 @@ -import { mount } from '@vue/test-utils' -import InviteButton from './InviteButton.vue' - -const localVue = global.localVue - -const stubs = { - 'v-popover': { - template: '', - }, -} - -describe('InviteButton.vue', () => { - let wrapper - let mocks - let propsData - - beforeEach(() => { - mocks = { - $t: jest.fn(), - navigator: { - clipboard: { - writeText: jest.fn(), - }, - }, - } - propsData = {} - }) - - describe('mount', () => { - const Wrapper = () => { - return mount(InviteButton, { mocks, localVue, propsData, stubs }) - } - - beforeEach(() => { - wrapper = Wrapper() - }) - - it('renders', () => { - expect(wrapper.find('.invite-button').exists()).toBe(true) - }) - - it('open popup', () => { - wrapper.find('.base-button').trigger('click') - expect(wrapper.find('.invite-button').exists()).toBe(true) - }) - - it('invite codes not available', async () => { - wrapper.find('.base-button').trigger('click') // open popup - wrapper.find('.invite-button').trigger('click') // click copy button - expect(mocks.$t).toHaveBeenCalledWith('invite-codes.not-available') - }) - - it.skip('invite codes copied to clipboard', async () => { - wrapper.find('.base-button').trigger('click') // open popup - wrapper.find('.invite-button').trigger('click') // click copy button - expect(mocks.$t).toHaveBeenCalledWith('invite-codes.not-available') - }) - }) -}) diff --git a/webapp/components/InviteButton/InviteButton.vue b/webapp/components/InviteButton/InviteButton.vue index 3042a706a..3eea98f74 100644 --- a/webapp/components/InviteButton/InviteButton.vue +++ b/webapp/components/InviteButton/InviteButton.vue @@ -13,24 +13,18 @@ /> @@ -38,82 +32,87 @@ - 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 },