mirror of
https://github.com/IT4Change/Ocelot-Social.git
synced 2025-12-13 07:45:56 +00:00
feat(webapp): several group and personal invitation links (#8504)
* invite codes refactor typo * lint fixes * remove duplicate initeCodes on User * fix typo * clean permissionMiddleware * dummy permissions * separate validateInviteCode call * permissions group & user * test validateInviteCode + adjustments * more validateInviteCode fixes * missing test * generatePersonalInviteCode * generateGroupInviteCode * old tests * lint fixes * more lint fixes * fix validateInviteCode * fix redeemInviteCode, fix signup * fix all tests * fix lint * uniform types in config * test & fix invalidateInviteCode * cleanup test * fix & test redeemInviteCode * permissions * fix Group->inviteCodes * more cleanup * improve tests * fix code generation * cleanup * order inviteCodes result on User and Group * lint * test max invite codes + fix * better description of collision * tests: properly define group ids * reused old group query * reuse old Groupmembers query * remove duplicate skip * update comment * fix uniqueInviteCode * fix test * fix lint * Get invite codes * Show invitation data in registration * Add invitation list to menu (WIP) * Add mutations, add CreateInvitation, some fixes * Improve style, fix long comments * Lock scrolling when popover is open, but prevent layout change * small fixes * instant updates * Introduce config for link limit; add texts, layout changes * Validate comment length * Improve layout * Add message to copied link * Add invite link section to group settings * Handle hidden groups * Add menu entry for group invite links * Fix locale * hotfix invite codes * Add copy messages * More styling (WIP) * Design update * Don't forget user state * Localize placeholder * Add locale * Instant updates for group invites * fix registration with invite code * Fix text overflow * Fix instant updates * Overhaul styles, add locales, add heading * Add test and snapshot for CreateInvitation * Improve accessability; add invitation test * Add tests for InvitationList * Fix locales * Round plus button * Fix tests * Fix tests * fix locales * fix linting * Don't show name of hidden group in invite message * Add more tests * Update webapp/locales/de.json Co-authored-by: Ulf Gebhardt <ulf.gebhardt@webcraft-media.de> * Update webapp/locales/de.json Co-authored-by: Ulf Gebhardt <ulf.gebhardt@webcraft-media.de> --------- Co-authored-by: Ulf Gebhardt <ulf.gebhardt@webcraft-media.de> Co-authored-by: Wolfgang Huß <wolle.huss@pjannto.com>
This commit is contained in:
parent
fcd1776f21
commit
ce1844e521
@ -6,3 +6,6 @@ MAPBOX_TOKEN="pk.eyJ1IjoiYnVzZmFrdG9yIiwiYSI6ImNraDNiM3JxcDBhaWQydG1uczhpZWtpOW4
|
|||||||
PUBLIC_REGISTRATION=false
|
PUBLIC_REGISTRATION=false
|
||||||
INVITE_REGISTRATION=true
|
INVITE_REGISTRATION=true
|
||||||
CATEGORIES_ACTIVE=false
|
CATEGORIES_ACTIVE=false
|
||||||
|
BADGES_ENABLED=true
|
||||||
|
INVITE_LINK_LIMIT=7
|
||||||
|
NETWORK_NAME="Ocelot.social"
|
||||||
|
|||||||
@ -142,6 +142,12 @@ hr {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body.dropdown-open {
|
||||||
|
max-height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
|
|
||||||
.base-card > .ds-section {
|
.base-card > .ds-section {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: -$space-base;
|
margin: -$space-base;
|
||||||
|
|||||||
@ -89,6 +89,11 @@ export default {
|
|||||||
path: `/groups/edit/${this.group.id}`,
|
path: `/groups/edit/${this.group.id}`,
|
||||||
icon: 'edit',
|
icon: 'edit',
|
||||||
})
|
})
|
||||||
|
routes.push({
|
||||||
|
label: this.$t('group.contentMenu.inviteLinks'),
|
||||||
|
path: `/groups/edit/${this.group.id}/invites`,
|
||||||
|
icon: 'link',
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return routes
|
return routes
|
||||||
|
|||||||
@ -88,6 +88,27 @@ exports[`GroupContentMenu renders as groupProfile when I am the owner 1`] = `
|
|||||||
</a>
|
</a>
|
||||||
<!---->
|
<!---->
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<li
|
||||||
|
class="ds-menu-item ds-menu-item-level-0"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
class="ds-menu-item-link"
|
||||||
|
href="/groups/edit/groupid/invites"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="base-icon"
|
||||||
|
>
|
||||||
|
<!---->
|
||||||
|
</span>
|
||||||
|
|
||||||
|
group.contentMenu.inviteLinks
|
||||||
|
|
||||||
|
</a>
|
||||||
|
<!---->
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,59 +0,0 @@
|
|||||||
import { mount } from '@vue/test-utils'
|
|
||||||
import InviteButton from './InviteButton.vue'
|
|
||||||
|
|
||||||
const localVue = global.localVue
|
|
||||||
|
|
||||||
const stubs = {
|
|
||||||
'v-popover': {
|
|
||||||
template: '<span><slot /></span>',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
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')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -13,24 +13,18 @@
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #popover>
|
<template #popover>
|
||||||
<div class="invite-button-menu-popover">
|
<div class="invite-list">
|
||||||
<div v-if="inviteCode && inviteCode.code">
|
<h2>{{ $t('invite-codes.my-invite-links') }}</h2>
|
||||||
<p class="description">{{ $t('invite-codes.your-code') }}</p>
|
<invitation-list
|
||||||
<base-card class="code-card" wideContent>
|
@generate-invite-code="generatePersonalInviteCode"
|
||||||
<base-button
|
@invalidate-invite-code="invalidateInviteCode"
|
||||||
v-if="canCopy"
|
:inviteCodes="user.inviteCodes"
|
||||||
class="invite-code"
|
:copy-message="
|
||||||
icon="copy"
|
$t('invite-codes.invite-link-message-personal', {
|
||||||
ghost
|
network: $env.NETWORK_NAME,
|
||||||
@click="copyInviteLink"
|
})
|
||||||
>
|
"
|
||||||
<ds-text bold>{{ $t('invite-codes.copy-code') }}</ds-text>
|
/>
|
||||||
</base-button>
|
|
||||||
</base-card>
|
|
||||||
</div>
|
|
||||||
<div v-else>
|
|
||||||
<ds-text>{{ $t('invite-codes.not-available') }}</ds-text>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</dropdown>
|
</dropdown>
|
||||||
@ -38,82 +32,87 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import Dropdown from '~/components/Dropdown'
|
import Dropdown from '~/components/Dropdown'
|
||||||
import gql from 'graphql-tag'
|
import { mapGetters, mapMutations } from 'vuex'
|
||||||
import BaseCard from '../_new/generic/BaseCard/BaseCard.vue'
|
import InvitationList from '~/components/_new/features/Invitations/InvitationList.vue'
|
||||||
|
import { generatePersonalInviteCode, invalidateInviteCode } from '~/graphql/InviteCode'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
Dropdown,
|
Dropdown,
|
||||||
BaseCard,
|
InvitationList,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
placement: { type: String, default: 'top-end' },
|
placement: { type: String, default: 'top-end' },
|
||||||
},
|
},
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
inviteCode: null,
|
|
||||||
canCopy: false,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
created() {
|
|
||||||
this.canCopy = !!navigator.clipboard
|
|
||||||
},
|
|
||||||
computed: {
|
computed: {
|
||||||
inviteLink() {
|
...mapGetters({
|
||||||
return (
|
user: 'auth/user',
|
||||||
'https://' +
|
}),
|
||||||
window.location.hostname +
|
inviteCode() {
|
||||||
'/registration?method=invite-code&inviteCode=' +
|
return this.user.inviteCodes[0] || null
|
||||||
this.inviteCode.code
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
async copyInviteLink() {
|
...mapMutations({
|
||||||
await navigator.clipboard.writeText(this.inviteLink)
|
setCurrentUser: 'auth/SET_USER_PARTIAL',
|
||||||
this.$toast.success(this.$t('invite-codes.copy-success'))
|
}),
|
||||||
|
async generatePersonalInviteCode(comment) {
|
||||||
|
try {
|
||||||
|
await this.$apollo.mutate({
|
||||||
|
mutation: generatePersonalInviteCode(),
|
||||||
|
variables: {
|
||||||
|
comment,
|
||||||
},
|
},
|
||||||
|
update: (_, { data: { generatePersonalInviteCode } }) => {
|
||||||
|
this.setCurrentUser({
|
||||||
|
...this.currentUser,
|
||||||
|
inviteCodes: [...this.user.inviteCodes, generatePersonalInviteCode],
|
||||||
|
})
|
||||||
},
|
},
|
||||||
apollo: {
|
})
|
||||||
inviteCode: {
|
this.$toast.success(this.$t('invite-codes.create-success'))
|
||||||
query() {
|
} catch (error) {
|
||||||
return gql`
|
this.$toast.error(this.$t('invite-codes.create-error', { error: error.message }))
|
||||||
query {
|
|
||||||
getInviteCode {
|
|
||||||
code
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
async invalidateInviteCode(code) {
|
||||||
|
try {
|
||||||
|
await this.$apollo.mutate({
|
||||||
|
mutation: invalidateInviteCode(),
|
||||||
|
variables: {
|
||||||
|
code,
|
||||||
|
},
|
||||||
|
update: (_, { data: { _invalidateInviteCode } }) => {
|
||||||
|
this.setCurrentUser({
|
||||||
|
...this.currentUser,
|
||||||
|
inviteCodes: this.user.inviteCodes.map((inviteCode) => ({
|
||||||
|
...inviteCode,
|
||||||
|
isValid: inviteCode.code === code ? false : inviteCode.isValid,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
this.$toast.success(this.$t('invite-codes.invalidate-success'))
|
||||||
|
} catch (error) {
|
||||||
|
this.$toast.error(this.$t('invite-codes.invalidate-error', { error: error.message }))
|
||||||
}
|
}
|
||||||
`
|
|
||||||
},
|
|
||||||
variables() {},
|
|
||||||
update({ getInviteCode }) {
|
|
||||||
return getInviteCode
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scope>
|
<style lang="scss" scoped>
|
||||||
.invite-button {
|
.invite-button {
|
||||||
color: $color-secondary;
|
color: $color-secondary;
|
||||||
}
|
}
|
||||||
|
|
||||||
.invite-button-menu-popover {
|
.invite-list {
|
||||||
|
max-width: min(400px, 90vw);
|
||||||
|
padding: $space-small;
|
||||||
|
margin-top: $space-base;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
flex-flow: column;
|
||||||
align-items: center;
|
gap: $space-small;
|
||||||
|
|
||||||
.description {
|
|
||||||
margin-top: $space-x-small;
|
|
||||||
margin-bottom: $space-x-small;
|
|
||||||
}
|
|
||||||
.code-card {
|
|
||||||
margin-bottom: $space-x-small;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.invite-code {
|
|
||||||
margin-left: 25%;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -13,28 +13,48 @@
|
|||||||
id="inviteCode"
|
id="inviteCode"
|
||||||
icon="question-circle"
|
icon="question-circle"
|
||||||
/>
|
/>
|
||||||
<ds-text>
|
<ds-text v-if="!validInput">
|
||||||
{{ $t('components.registration.invite-code.form.description') }}
|
{{ $t('components.registration.invite-code.form.description') }}
|
||||||
</ds-text>
|
</ds-text>
|
||||||
|
<div class="invitation-info" v-if="invitedBy">
|
||||||
|
<profile-avatar :profile="invitedBy" size="small" />
|
||||||
|
<span v-if="invitedTo && invitedTo.groupType === 'hidden'">
|
||||||
|
{{
|
||||||
|
$t('components.registration.invite-code.invited-to-hidden-group', {
|
||||||
|
invitedBy: invitedBy.name,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
<span v-else-if="invitedTo">
|
||||||
|
{{
|
||||||
|
$t('components.registration.invite-code.invited-by-and-to', {
|
||||||
|
invitedBy: invitedBy.name,
|
||||||
|
invitedTo: invitedTo.name,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
|
{{ $t('components.registration.invite-code.invited-by', { invitedBy: invitedBy.name }) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
<ds-space margin="xxx-small" />
|
<ds-space margin="xxx-small" />
|
||||||
</ds-form>
|
</ds-form>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import gql from 'graphql-tag'
|
|
||||||
import registrationConstants from '~/constants/registration'
|
import registrationConstants from '~/constants/registration'
|
||||||
|
import { validateInviteCode } from '~/graphql/InviteCode'
|
||||||
|
import ProfileAvatar from '~/components/_new/generic/ProfileAvatar/ProfileAvatar'
|
||||||
|
|
||||||
export const isValidInviteCodeQuery = gql`
|
|
||||||
query ($code: ID!) {
|
|
||||||
isValidInviteCode(code: $code)
|
|
||||||
}
|
|
||||||
`
|
|
||||||
export default {
|
export default {
|
||||||
name: 'RegistrationSlideInvite',
|
name: 'RegistrationSlideInvite',
|
||||||
props: {
|
props: {
|
||||||
sliderData: { type: Object, required: true },
|
sliderData: { type: Object, required: true },
|
||||||
},
|
},
|
||||||
|
components: {
|
||||||
|
ProfileAvatar,
|
||||||
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
formData: {
|
formData: {
|
||||||
@ -75,6 +95,16 @@ export default {
|
|||||||
validInput() {
|
validInput() {
|
||||||
return this.formData.inviteCode.length === 6
|
return this.formData.inviteCode.length === 6
|
||||||
},
|
},
|
||||||
|
invitedBy() {
|
||||||
|
return this.sliderData.sliders[this.sliderIndex].data.response.validateInviteCode
|
||||||
|
? this.sliderData.sliders[this.sliderIndex].data.response.validateInviteCode.generatedBy
|
||||||
|
: null
|
||||||
|
},
|
||||||
|
invitedTo() {
|
||||||
|
return this.sliderData.sliders[this.sliderIndex].data.response.validateInviteCode
|
||||||
|
? this.sliderData.sliders[this.sliderIndex].data.response.validateInviteCode.invitedTo
|
||||||
|
: null
|
||||||
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
async sendValidation() {
|
async sendValidation() {
|
||||||
@ -84,8 +114,7 @@ export default {
|
|||||||
|
|
||||||
let dbValidated = false
|
let dbValidated = false
|
||||||
if (this.validInput) {
|
if (this.validInput) {
|
||||||
await this.handleSubmitVerify()
|
dbValidated = await this.handleSubmitVerify()
|
||||||
dbValidated = this.sliderData.sliders[this.sliderIndex].data.response.isValidInviteCode
|
|
||||||
}
|
}
|
||||||
this.sliderData.setSliderValuesCallback(dbValidated)
|
this.sliderData.setSliderValuesCallback(dbValidated)
|
||||||
},
|
},
|
||||||
@ -110,7 +139,7 @@ export default {
|
|||||||
try {
|
try {
|
||||||
this.dbRequestInProgress = true
|
this.dbRequestInProgress = true
|
||||||
|
|
||||||
const response = await this.$apollo.query({ query: isValidInviteCodeQuery, variables })
|
const response = await this.$apollo.query({ query: validateInviteCode(), variables })
|
||||||
this.sliderData.setSliderValuesCallback(null, {
|
this.sliderData.setSliderValuesCallback(null, {
|
||||||
sliderData: {
|
sliderData: {
|
||||||
request: { variables },
|
request: { variables },
|
||||||
@ -118,20 +147,22 @@ export default {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
if (this.sliderData.sliders[this.sliderIndex].data.response) {
|
const validationResult = response.data.validateInviteCode
|
||||||
if (this.sliderData.sliders[this.sliderIndex].data.response.isValidInviteCode) {
|
|
||||||
|
if (validationResult && validationResult.isValid) {
|
||||||
this.$toast.success(
|
this.$toast.success(
|
||||||
this.$t('components.registration.invite-code.form.validations.success', {
|
this.$t('components.registration.invite-code.form.validations.success', {
|
||||||
inviteCode,
|
inviteCode,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
return true
|
||||||
} else {
|
} else {
|
||||||
this.$toast.error(
|
this.$toast.error(
|
||||||
this.$t('components.registration.invite-code.form.validations.error', {
|
this.$t('components.registration.invite-code.form.validations.error', {
|
||||||
inviteCode,
|
inviteCode,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
return false
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.sliderData.setSliderValuesCallback(false, {
|
this.sliderData.setSliderValuesCallback(false, {
|
||||||
@ -140,6 +171,7 @@ export default {
|
|||||||
|
|
||||||
const { message } = err
|
const { message } = err
|
||||||
this.$toast.error(message)
|
this.$toast.error(message)
|
||||||
|
return false
|
||||||
} finally {
|
} finally {
|
||||||
this.dbRequestInProgress = false
|
this.dbRequestInProgress = false
|
||||||
}
|
}
|
||||||
@ -152,10 +184,25 @@ export default {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss" scoped>
|
||||||
.enter-invite {
|
.enter-invite {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
margin: $space-large 0 $space-xxx-small 0;
|
margin: $space-large 0 $space-xxx-small 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.invitation-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: $space-x-small;
|
||||||
|
gap: $space-small;
|
||||||
|
|
||||||
|
> * {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
> span {
|
||||||
|
flex: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -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']])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,68 @@
|
|||||||
|
<template>
|
||||||
|
<div class="create-invitation">
|
||||||
|
<div>{{ $t('invite-codes.generate-code-explanation') }}</div>
|
||||||
|
<form @submit.prevent="generateInviteCode" class="generate-invite-code-form">
|
||||||
|
<ds-input
|
||||||
|
name="comment"
|
||||||
|
:placeholder="$t('invite-codes.comment-placeholder')"
|
||||||
|
v-model="comment"
|
||||||
|
:schema="{ type: 'string', max: 30 }"
|
||||||
|
/>
|
||||||
|
<base-button
|
||||||
|
circle
|
||||||
|
class="generate-invite-code"
|
||||||
|
:aria-label="$t('invite-codes.generate-code')"
|
||||||
|
icon="plus"
|
||||||
|
type="submit"
|
||||||
|
:disabled="disabled"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'CreateInvitation',
|
||||||
|
props: {
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
comment: '',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async generateInviteCode() {
|
||||||
|
this.$emit('generate-invite-code', this.comment)
|
||||||
|
this.comment = ''
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.create-invitation {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $space-small;
|
||||||
|
}
|
||||||
|
|
||||||
|
.generate-invite-code-form {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $space-x-small;
|
||||||
|
}
|
||||||
|
|
||||||
|
::v-deep .ds-form-item {
|
||||||
|
margin-bottom: 0;
|
||||||
|
flex: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
::v-deep .ds-input-error {
|
||||||
|
margin-top: $space-xx-small;
|
||||||
|
margin-left: $space-x-small;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
115
webapp/components/_new/features/Invitations/Invitation.spec.js
Normal file
115
webapp/components/_new/features/Invitations/Invitation.spec.js
Normal file
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
155
webapp/components/_new/features/Invitations/Invitation.vue
Normal file
155
webapp/components/_new/features/Invitations/Invitation.vue
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
<template>
|
||||||
|
<li class="invitation">
|
||||||
|
<div class="column1">
|
||||||
|
<div class="code">
|
||||||
|
{{ inviteCode.code }}
|
||||||
|
<span v-if="inviteCode.comment" class="mdash">—</span>
|
||||||
|
<span v-if="inviteCode.comment" class="comment">{{ inviteCode.comment }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span v-if="inviteCode.redeemedByCount === 0">
|
||||||
|
{{ $t('invite-codes.redeemed-count-0') }}
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
|
{{ $t('invite-codes.redeemed-count', { count: inviteCode.redeemedByCount }) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<base-button
|
||||||
|
circle
|
||||||
|
class="copy-button"
|
||||||
|
icon="copy"
|
||||||
|
@click="copyInviteCode(inviteCode.copy)"
|
||||||
|
:disabled="!canCopy"
|
||||||
|
:aria-label="$t('invite-codes.copy-code')"
|
||||||
|
/>
|
||||||
|
<base-button
|
||||||
|
circle
|
||||||
|
class="invalidate-button"
|
||||||
|
icon="trash"
|
||||||
|
@click="openDeleteModal"
|
||||||
|
:aria-label="$t('invite-codes.invalidate')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { mapMutations } from 'vuex'
|
||||||
|
import BaseButton from '~/components/_new/generic/BaseButton/BaseButton.vue'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'Invitation',
|
||||||
|
components: {
|
||||||
|
BaseButton,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
inviteCode: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
copyMessage: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
inviteLink() {
|
||||||
|
return `${window.location.origin}/registration?method=invite-code&inviteCode=${this.inviteCode.code}`
|
||||||
|
},
|
||||||
|
inviteMessageAndLink() {
|
||||||
|
return this.copyMessage ? `${this.copyMessage} ${this.inviteLink}` : this.inviteLink
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
canCopy: false,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.canCopy = !!navigator.clipboard
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
...mapMutations({
|
||||||
|
commitModalData: 'modal/SET_OPEN',
|
||||||
|
}),
|
||||||
|
async copyInviteCode() {
|
||||||
|
await navigator.clipboard.writeText(this.inviteMessageAndLink)
|
||||||
|
this.$toast.success(this.$t('invite-codes.copy-success'))
|
||||||
|
},
|
||||||
|
openDeleteModal() {
|
||||||
|
this.commitModalData({
|
||||||
|
name: 'confirm',
|
||||||
|
data: {
|
||||||
|
type: '',
|
||||||
|
resource: { id: '' },
|
||||||
|
modalData: {
|
||||||
|
titleIdent: this.$t('invite-codes.delete-modal.title'),
|
||||||
|
messageIdent: this.$t('invite-codes.delete-modal.message'),
|
||||||
|
buttons: {
|
||||||
|
confirm: {
|
||||||
|
danger: true,
|
||||||
|
icon: 'trash',
|
||||||
|
textIdent: 'actions.delete',
|
||||||
|
callback: () => {
|
||||||
|
this.$emit('invalidate-invite-code', this.inviteCode.code)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cancel: {
|
||||||
|
icon: 'close',
|
||||||
|
textIdent: 'actions.cancel',
|
||||||
|
callback: () => {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.invitation {
|
||||||
|
display: flex;
|
||||||
|
padding: calc($space-base / 2);
|
||||||
|
border-bottom: 1px dotted #e5e3e8;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invitation:nth-child(odd) {
|
||||||
|
background-color: $color-neutral-90;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invitation:nth-child(even) {
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column1 {
|
||||||
|
flex: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-flow: column;
|
||||||
|
gap: $space-xx-small;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code {
|
||||||
|
display: inline-flex;
|
||||||
|
max-width: 73%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: $space-x-small;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mdash {
|
||||||
|
margin-inline: $space-x-small;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,82 @@
|
|||||||
|
<template>
|
||||||
|
<div class="invitation-list">
|
||||||
|
<ul v-if="validInviteCodes.length">
|
||||||
|
<client-only>
|
||||||
|
<invitation
|
||||||
|
v-for="inviteCode in validInviteCodes"
|
||||||
|
:key="inviteCode.code"
|
||||||
|
:invite-code="inviteCode"
|
||||||
|
:copy-message="copyMessage"
|
||||||
|
@invalidate-invite-code="invalidateInviteCode"
|
||||||
|
/>
|
||||||
|
</client-only>
|
||||||
|
</ul>
|
||||||
|
<div v-else class="no-invitation">
|
||||||
|
{{ $t('invite-codes.no-links', { max: maxLinks }) }}
|
||||||
|
</div>
|
||||||
|
<create-invitation
|
||||||
|
@generate-invite-code="generateInviteCode"
|
||||||
|
:disabled="isLimitReached"
|
||||||
|
class="create-invitation"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import Invitation from './Invitation.vue'
|
||||||
|
import CreateInvitation from './CreateInvitation.vue'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'InvitationList',
|
||||||
|
props: {
|
||||||
|
inviteCodes: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
copyMessage: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
Invitation,
|
||||||
|
CreateInvitation,
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
validInviteCodes() {
|
||||||
|
return this.inviteCodes.filter((inviteCode) => inviteCode.isValid)
|
||||||
|
},
|
||||||
|
maxLinks() {
|
||||||
|
return Number(this.$env.INVITE_LINK_LIMIT)
|
||||||
|
},
|
||||||
|
isLimitReached() {
|
||||||
|
return this.validInviteCodes.length >= this.maxLinks
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
generateInviteCode(comment) {
|
||||||
|
this.$emit('generate-invite-code', comment)
|
||||||
|
},
|
||||||
|
invalidateInviteCode(code) {
|
||||||
|
this.$emit('invalidate-invite-code', code)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.invitation-list {
|
||||||
|
display: flex;
|
||||||
|
flex-flow: column;
|
||||||
|
gap: $space-base;
|
||||||
|
padding-bottom: $space-base;
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-invitation {
|
||||||
|
margin-top: $space-base;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,140 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`CreateInvitation.vue renders 1`] = `
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="create-invitation"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
invite-codes.generate-code-explanation
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
class="generate-invite-code-form"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="ds-form-item ds-input-size-base"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
class="ds-input-label"
|
||||||
|
style="display: none;"
|
||||||
|
>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
class="ds-input-wrap"
|
||||||
|
>
|
||||||
|
<!---->
|
||||||
|
<input
|
||||||
|
class="ds-input"
|
||||||
|
name="comment"
|
||||||
|
placeholder="invite-codes.comment-placeholder"
|
||||||
|
tabindex="0"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<!---->
|
||||||
|
</div>
|
||||||
|
<transition-stub
|
||||||
|
name="ds-input-error"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="ds-input-error"
|
||||||
|
style="display: none;"
|
||||||
|
>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</transition-stub>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
aria-label="invite-codes.generate-code"
|
||||||
|
class="generate-invite-code base-button --icon-only --circle"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="base-icon"
|
||||||
|
>
|
||||||
|
<!---->
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!---->
|
||||||
|
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`CreateInvitation.vue renders with disabled button 1`] = `
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="create-invitation"
|
||||||
|
isdisabled="true"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
invite-codes.generate-code-explanation
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
class="generate-invite-code-form"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="ds-form-item ds-input-size-base"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
class="ds-input-label"
|
||||||
|
style="display: none;"
|
||||||
|
>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
class="ds-input-wrap"
|
||||||
|
>
|
||||||
|
<!---->
|
||||||
|
<input
|
||||||
|
class="ds-input"
|
||||||
|
name="comment"
|
||||||
|
placeholder="invite-codes.comment-placeholder"
|
||||||
|
tabindex="0"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<!---->
|
||||||
|
</div>
|
||||||
|
<transition-stub
|
||||||
|
name="ds-input-error"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="ds-input-error"
|
||||||
|
style="display: none;"
|
||||||
|
>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</transition-stub>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
aria-label="invite-codes.generate-code"
|
||||||
|
class="generate-invite-code base-button --icon-only --circle"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="base-icon"
|
||||||
|
>
|
||||||
|
<!---->
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!---->
|
||||||
|
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
@ -0,0 +1,147 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`Invitation.vue when the invite code was not redeemed renders 1`] = `
|
||||||
|
<div>
|
||||||
|
<li
|
||||||
|
class="invitation"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="column1"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="code"
|
||||||
|
>
|
||||||
|
|
||||||
|
test-invite-code
|
||||||
|
|
||||||
|
<span
|
||||||
|
class="mdash"
|
||||||
|
>
|
||||||
|
—
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span
|
||||||
|
class="comment"
|
||||||
|
>
|
||||||
|
test-comment
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span>
|
||||||
|
|
||||||
|
invite-codes.redeemed-count-0
|
||||||
|
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="actions"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
aria-label="invite-codes.copy-code"
|
||||||
|
class="copy-button base-button --icon-only --circle"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="base-icon"
|
||||||
|
>
|
||||||
|
<!---->
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!---->
|
||||||
|
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
aria-label="invite-codes.invalidate"
|
||||||
|
class="invalidate-button base-button --icon-only --circle"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="base-icon"
|
||||||
|
>
|
||||||
|
<!---->
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!---->
|
||||||
|
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Invitation.vue when the invite code was redeemed renders 1`] = `
|
||||||
|
<div>
|
||||||
|
<li
|
||||||
|
class="invitation"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="column1"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="code"
|
||||||
|
>
|
||||||
|
|
||||||
|
test-invite-code
|
||||||
|
|
||||||
|
<span
|
||||||
|
class="mdash"
|
||||||
|
>
|
||||||
|
—
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span
|
||||||
|
class="comment"
|
||||||
|
>
|
||||||
|
test-comment
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span>
|
||||||
|
|
||||||
|
invite-codes.redeemed-count
|
||||||
|
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="actions"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
aria-label="invite-codes.copy-code"
|
||||||
|
class="copy-button base-button --icon-only --circle"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="base-icon"
|
||||||
|
>
|
||||||
|
<!---->
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!---->
|
||||||
|
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
aria-label="invite-codes.invalidate"
|
||||||
|
class="invalidate-button base-button --icon-only --circle"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="base-icon"
|
||||||
|
>
|
||||||
|
<!---->
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!---->
|
||||||
|
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
@ -0,0 +1,296 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`InvitationList.vue renders 1`] = `
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="invitation-list"
|
||||||
|
>
|
||||||
|
<ul>
|
||||||
|
<client-only-stub>
|
||||||
|
<li
|
||||||
|
class="invitation"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="column1"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="code"
|
||||||
|
>
|
||||||
|
|
||||||
|
test-invite-code-1
|
||||||
|
|
||||||
|
<span
|
||||||
|
class="mdash"
|
||||||
|
>
|
||||||
|
—
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span
|
||||||
|
class="comment"
|
||||||
|
>
|
||||||
|
test-comment
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span>
|
||||||
|
|
||||||
|
invite-codes.redeemed-count-0
|
||||||
|
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="actions"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
aria-label="invite-codes.copy-code"
|
||||||
|
class="copy-button base-button --icon-only --circle"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="base-icon"
|
||||||
|
>
|
||||||
|
<!---->
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!---->
|
||||||
|
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
aria-label="invite-codes.invalidate"
|
||||||
|
class="invalidate-button base-button --icon-only --circle"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="base-icon"
|
||||||
|
>
|
||||||
|
<!---->
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!---->
|
||||||
|
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li
|
||||||
|
class="invitation"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="column1"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="code"
|
||||||
|
>
|
||||||
|
|
||||||
|
test-invite-code-2
|
||||||
|
|
||||||
|
<span
|
||||||
|
class="mdash"
|
||||||
|
>
|
||||||
|
—
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span
|
||||||
|
class="comment"
|
||||||
|
>
|
||||||
|
test-comment-2
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span>
|
||||||
|
|
||||||
|
invite-codes.redeemed-count
|
||||||
|
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="actions"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
aria-label="invite-codes.copy-code"
|
||||||
|
class="copy-button base-button --icon-only --circle"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="base-icon"
|
||||||
|
>
|
||||||
|
<!---->
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!---->
|
||||||
|
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
aria-label="invite-codes.invalidate"
|
||||||
|
class="invalidate-button base-button --icon-only --circle"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="base-icon"
|
||||||
|
>
|
||||||
|
<!---->
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!---->
|
||||||
|
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</client-only-stub>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="create-invitation create-invitation"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
invite-codes.generate-code-explanation
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
class="generate-invite-code-form"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="ds-form-item ds-input-size-base"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
class="ds-input-label"
|
||||||
|
style="display: none;"
|
||||||
|
>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
class="ds-input-wrap"
|
||||||
|
>
|
||||||
|
<!---->
|
||||||
|
<input
|
||||||
|
class="ds-input"
|
||||||
|
name="comment"
|
||||||
|
placeholder="invite-codes.comment-placeholder"
|
||||||
|
tabindex="0"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<!---->
|
||||||
|
</div>
|
||||||
|
<transition-stub
|
||||||
|
name="ds-input-error"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="ds-input-error"
|
||||||
|
style="display: none;"
|
||||||
|
>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</transition-stub>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
aria-label="invite-codes.generate-code"
|
||||||
|
class="generate-invite-code base-button --icon-only --circle"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="base-icon"
|
||||||
|
>
|
||||||
|
<!---->
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!---->
|
||||||
|
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`InvitationList.vue renders empty state 1`] = `
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="invitation-list"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="no-invitation"
|
||||||
|
>
|
||||||
|
|
||||||
|
invite-codes.no-links
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="create-invitation create-invitation"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
invite-codes.generate-code-explanation
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
class="generate-invite-code-form"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="ds-form-item ds-input-size-base"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
class="ds-input-label"
|
||||||
|
style="display: none;"
|
||||||
|
>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
class="ds-input-wrap"
|
||||||
|
>
|
||||||
|
<!---->
|
||||||
|
<input
|
||||||
|
class="ds-input"
|
||||||
|
name="comment"
|
||||||
|
placeholder="invite-codes.comment-placeholder"
|
||||||
|
tabindex="0"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<!---->
|
||||||
|
</div>
|
||||||
|
<transition-stub
|
||||||
|
name="ds-input-error"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="ds-input-error"
|
||||||
|
style="display: none;"
|
||||||
|
>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</transition-stub>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
aria-label="invite-codes.generate-code"
|
||||||
|
class="generate-invite-code base-button --icon-only --circle"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="base-icon"
|
||||||
|
>
|
||||||
|
<!---->
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!---->
|
||||||
|
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
@ -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
|
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,
|
CATEGORIES_ACTIVE: process.env.CATEGORIES_ACTIVE === 'true' || false,
|
||||||
BADGES_ENABLED: process.env.BADGES_ENABLED === '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 = {
|
const CONFIG = {
|
||||||
|
|||||||
137
webapp/graphql/InviteCode.js
Normal file
137
webapp/graphql/InviteCode.js
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
`
|
||||||
@ -406,6 +406,15 @@ export const currentUserQuery = gql`
|
|||||||
query {
|
query {
|
||||||
currentUser {
|
currentUser {
|
||||||
...user
|
...user
|
||||||
|
inviteCodes {
|
||||||
|
code
|
||||||
|
isValid
|
||||||
|
redeemedBy {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
comment
|
||||||
|
redeemedByCount
|
||||||
|
}
|
||||||
badgeTrophiesSelected {
|
badgeTrophiesSelected {
|
||||||
id
|
id
|
||||||
icon
|
icon
|
||||||
|
|||||||
@ -195,6 +195,16 @@ export const groupQuery = (i18n) => {
|
|||||||
lat
|
lat
|
||||||
}
|
}
|
||||||
myRole
|
myRole
|
||||||
|
inviteCodes {
|
||||||
|
createdAt
|
||||||
|
code
|
||||||
|
isValid
|
||||||
|
redeemedBy {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
comment
|
||||||
|
redeemedByCount
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|||||||
@ -244,7 +244,10 @@
|
|||||||
"length": "muss genau {inviteCodeLength} Buchstaben lang sein",
|
"length": "muss genau {inviteCodeLength} Buchstaben lang sein",
|
||||||
"success": "Gültiger Einladungs-Code <b>{inviteCode}</b>!"
|
"success": "Gültiger Einladungs-Code <b>{inviteCode}</b>!"
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"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": {
|
"no-public-registrstion": {
|
||||||
"title": "Keine öffentliche Registrierung möglich"
|
"title": "Keine öffentliche Registrierung möglich"
|
||||||
@ -509,6 +512,7 @@
|
|||||||
"categoriesTitle": "Themen der Gruppe",
|
"categoriesTitle": "Themen der Gruppe",
|
||||||
"changeMemberRole": "Die Rolle wurde auf „{role}“ geändert!",
|
"changeMemberRole": "Die Rolle wurde auf „{role}“ geändert!",
|
||||||
"contentMenu": {
|
"contentMenu": {
|
||||||
|
"inviteLinks": "Einladungslinks",
|
||||||
"muteGroup": "Stummschalten",
|
"muteGroup": "Stummschalten",
|
||||||
"unmuteGroup": "Nicht stummschalten",
|
"unmuteGroup": "Nicht stummschalten",
|
||||||
"visitGroupPage": "Gruppe anzeigen"
|
"visitGroupPage": "Gruppe anzeigen"
|
||||||
@ -531,6 +535,7 @@
|
|||||||
"goal": "Ziel der Gruppe",
|
"goal": "Ziel der Gruppe",
|
||||||
"groupCreated": "Die Gruppe wurde angelegt!",
|
"groupCreated": "Die Gruppe wurde angelegt!",
|
||||||
"in": "in",
|
"in": "in",
|
||||||
|
"invite-links": "Einladungslinks",
|
||||||
"joinLeaveButton": {
|
"joinLeaveButton": {
|
||||||
"iAmMember": "Bin Mitglied",
|
"iAmMember": "Bin Mitglied",
|
||||||
"join": "Beitreten",
|
"join": "Beitreten",
|
||||||
@ -628,10 +633,29 @@
|
|||||||
"button": {
|
"button": {
|
||||||
"tooltip": "Freunde einladen"
|
"tooltip": "Freunde einladen"
|
||||||
},
|
},
|
||||||
|
"comment-placeholder": "Kommentar (optional)",
|
||||||
"copy-code": "Einladungslink kopieren",
|
"copy-code": "Einladungslink kopieren",
|
||||||
"copy-success": "Einladungscode erfolgreich in die Zwischenablage kopiert",
|
"copy-success": "Einladungscode erfolgreich in die Zwischenablage kopiert",
|
||||||
"not-available": "Du hast keinen Einladungscode zur Verfügung!",
|
"create-error": "Einladungslink konnte nicht erstellt werden: {error}",
|
||||||
"your-code": "Sende diesen Link per E-Mail oder in sozialen Medien, um deine Freunde einzuladen:"
|
"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": {
|
"localeSwitch": {
|
||||||
"tooltip": "Sprache wählen"
|
"tooltip": "Sprache wählen"
|
||||||
|
|||||||
@ -244,7 +244,10 @@
|
|||||||
"length": "must be {inviteCodeLength} characters long",
|
"length": "must be {inviteCodeLength} characters long",
|
||||||
"success": "Valid invite code <b>{inviteCode}</b>!"
|
"success": "Valid invite code <b>{inviteCode}</b>!"
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"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": {
|
"no-public-registrstion": {
|
||||||
"title": "No Public Registration"
|
"title": "No Public Registration"
|
||||||
@ -509,6 +512,7 @@
|
|||||||
"categoriesTitle": "Topics of the group",
|
"categoriesTitle": "Topics of the group",
|
||||||
"changeMemberRole": "The role has been changed to “{role}”!",
|
"changeMemberRole": "The role has been changed to “{role}”!",
|
||||||
"contentMenu": {
|
"contentMenu": {
|
||||||
|
"inviteLinks": "Invite links",
|
||||||
"muteGroup": "Mute group",
|
"muteGroup": "Mute group",
|
||||||
"unmuteGroup": "Unmute group",
|
"unmuteGroup": "Unmute group",
|
||||||
"visitGroupPage": "Show group"
|
"visitGroupPage": "Show group"
|
||||||
@ -531,6 +535,7 @@
|
|||||||
"goal": "Goal of group",
|
"goal": "Goal of group",
|
||||||
"groupCreated": "The group was created!",
|
"groupCreated": "The group was created!",
|
||||||
"in": "in",
|
"in": "in",
|
||||||
|
"invite-links": "Invite Links",
|
||||||
"joinLeaveButton": {
|
"joinLeaveButton": {
|
||||||
"iAmMember": "I'm a member",
|
"iAmMember": "I'm a member",
|
||||||
"join": "Join",
|
"join": "Join",
|
||||||
@ -628,10 +633,29 @@
|
|||||||
"button": {
|
"button": {
|
||||||
"tooltip": "Invite friends"
|
"tooltip": "Invite friends"
|
||||||
},
|
},
|
||||||
|
"comment-placeholder": "Comment (optional)",
|
||||||
"copy-code": "Copy Invite Link",
|
"copy-code": "Copy Invite Link",
|
||||||
"copy-success": "Invite code copied to clipboard",
|
"copy-success": "Invite code copied to clipboard",
|
||||||
"not-available": "You have no valid invite code available!",
|
"create-error": "Creating a new invite link failed! Error: {error}",
|
||||||
"your-code": "Send this link per e-mail or in social media to invite your friends:"
|
"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": {
|
"localeSwitch": {
|
||||||
"tooltip": "Choose language"
|
"tooltip": "Choose language"
|
||||||
|
|||||||
@ -244,7 +244,10 @@
|
|||||||
"length": null,
|
"length": null,
|
||||||
"success": null
|
"success": null
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"invited-by": null,
|
||||||
|
"invited-by-and-to": null,
|
||||||
|
"invited-to-hidden-group": null
|
||||||
},
|
},
|
||||||
"no-public-registrstion": {
|
"no-public-registrstion": {
|
||||||
"title": null
|
"title": null
|
||||||
@ -509,6 +512,7 @@
|
|||||||
"categoriesTitle": null,
|
"categoriesTitle": null,
|
||||||
"changeMemberRole": null,
|
"changeMemberRole": null,
|
||||||
"contentMenu": {
|
"contentMenu": {
|
||||||
|
"inviteLinks": null,
|
||||||
"muteGroup": "Silenciar grupo",
|
"muteGroup": "Silenciar grupo",
|
||||||
"unmuteGroup": "Desactivar silencio del grupo",
|
"unmuteGroup": "Desactivar silencio del grupo",
|
||||||
"visitGroupPage": null
|
"visitGroupPage": null
|
||||||
@ -531,6 +535,7 @@
|
|||||||
"goal": null,
|
"goal": null,
|
||||||
"groupCreated": null,
|
"groupCreated": null,
|
||||||
"in": null,
|
"in": null,
|
||||||
|
"invite-links": null,
|
||||||
"joinLeaveButton": {
|
"joinLeaveButton": {
|
||||||
"iAmMember": null,
|
"iAmMember": null,
|
||||||
"join": null,
|
"join": null,
|
||||||
@ -628,10 +633,29 @@
|
|||||||
"button": {
|
"button": {
|
||||||
"tooltip": null
|
"tooltip": null
|
||||||
},
|
},
|
||||||
|
"comment-placeholder": null,
|
||||||
"copy-code": null,
|
"copy-code": null,
|
||||||
"copy-success": null,
|
"copy-success": null,
|
||||||
"not-available": null,
|
"create-error": null,
|
||||||
"your-code": 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": {
|
"localeSwitch": {
|
||||||
"tooltip": null
|
"tooltip": null
|
||||||
|
|||||||
@ -244,7 +244,10 @@
|
|||||||
"length": null,
|
"length": null,
|
||||||
"success": null
|
"success": null
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"invited-by": null,
|
||||||
|
"invited-by-and-to": null,
|
||||||
|
"invited-to-hidden-group": null
|
||||||
},
|
},
|
||||||
"no-public-registrstion": {
|
"no-public-registrstion": {
|
||||||
"title": null
|
"title": null
|
||||||
@ -509,6 +512,7 @@
|
|||||||
"categoriesTitle": null,
|
"categoriesTitle": null,
|
||||||
"changeMemberRole": null,
|
"changeMemberRole": null,
|
||||||
"contentMenu": {
|
"contentMenu": {
|
||||||
|
"inviteLinks": null,
|
||||||
"muteGroup": null,
|
"muteGroup": null,
|
||||||
"unmuteGroup": null,
|
"unmuteGroup": null,
|
||||||
"visitGroupPage": null
|
"visitGroupPage": null
|
||||||
@ -531,6 +535,7 @@
|
|||||||
"goal": null,
|
"goal": null,
|
||||||
"groupCreated": null,
|
"groupCreated": null,
|
||||||
"in": null,
|
"in": null,
|
||||||
|
"invite-links": null,
|
||||||
"joinLeaveButton": {
|
"joinLeaveButton": {
|
||||||
"iAmMember": null,
|
"iAmMember": null,
|
||||||
"join": null,
|
"join": null,
|
||||||
@ -628,10 +633,29 @@
|
|||||||
"button": {
|
"button": {
|
||||||
"tooltip": null
|
"tooltip": null
|
||||||
},
|
},
|
||||||
|
"comment-placeholder": null,
|
||||||
"copy-code": null,
|
"copy-code": null,
|
||||||
"copy-success": null,
|
"copy-success": null,
|
||||||
"not-available": null,
|
"create-error": null,
|
||||||
"your-code": 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": {
|
"localeSwitch": {
|
||||||
"tooltip": null
|
"tooltip": null
|
||||||
|
|||||||
@ -244,7 +244,10 @@
|
|||||||
"length": null,
|
"length": null,
|
||||||
"success": null
|
"success": null
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"invited-by": null,
|
||||||
|
"invited-by-and-to": null,
|
||||||
|
"invited-to-hidden-group": null
|
||||||
},
|
},
|
||||||
"no-public-registrstion": {
|
"no-public-registrstion": {
|
||||||
"title": null
|
"title": null
|
||||||
@ -509,6 +512,7 @@
|
|||||||
"categoriesTitle": null,
|
"categoriesTitle": null,
|
||||||
"changeMemberRole": null,
|
"changeMemberRole": null,
|
||||||
"contentMenu": {
|
"contentMenu": {
|
||||||
|
"inviteLinks": null,
|
||||||
"muteGroup": null,
|
"muteGroup": null,
|
||||||
"unmuteGroup": null,
|
"unmuteGroup": null,
|
||||||
"visitGroupPage": null
|
"visitGroupPage": null
|
||||||
@ -531,6 +535,7 @@
|
|||||||
"goal": null,
|
"goal": null,
|
||||||
"groupCreated": null,
|
"groupCreated": null,
|
||||||
"in": null,
|
"in": null,
|
||||||
|
"invite-links": null,
|
||||||
"joinLeaveButton": {
|
"joinLeaveButton": {
|
||||||
"iAmMember": null,
|
"iAmMember": null,
|
||||||
"join": null,
|
"join": null,
|
||||||
@ -628,10 +633,29 @@
|
|||||||
"button": {
|
"button": {
|
||||||
"tooltip": null
|
"tooltip": null
|
||||||
},
|
},
|
||||||
|
"comment-placeholder": null,
|
||||||
"copy-code": null,
|
"copy-code": null,
|
||||||
"copy-success": null,
|
"copy-success": null,
|
||||||
"not-available": null,
|
"create-error": null,
|
||||||
"your-code": 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": {
|
"localeSwitch": {
|
||||||
"tooltip": null
|
"tooltip": null
|
||||||
|
|||||||
@ -244,7 +244,10 @@
|
|||||||
"length": null,
|
"length": null,
|
||||||
"success": null
|
"success": null
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"invited-by": null,
|
||||||
|
"invited-by-and-to": null,
|
||||||
|
"invited-to-hidden-group": null
|
||||||
},
|
},
|
||||||
"no-public-registrstion": {
|
"no-public-registrstion": {
|
||||||
"title": null
|
"title": null
|
||||||
@ -509,6 +512,7 @@
|
|||||||
"categoriesTitle": null,
|
"categoriesTitle": null,
|
||||||
"changeMemberRole": null,
|
"changeMemberRole": null,
|
||||||
"contentMenu": {
|
"contentMenu": {
|
||||||
|
"inviteLinks": null,
|
||||||
"muteGroup": null,
|
"muteGroup": null,
|
||||||
"unmuteGroup": null,
|
"unmuteGroup": null,
|
||||||
"visitGroupPage": null
|
"visitGroupPage": null
|
||||||
@ -531,6 +535,7 @@
|
|||||||
"goal": null,
|
"goal": null,
|
||||||
"groupCreated": null,
|
"groupCreated": null,
|
||||||
"in": null,
|
"in": null,
|
||||||
|
"invite-links": null,
|
||||||
"joinLeaveButton": {
|
"joinLeaveButton": {
|
||||||
"iAmMember": null,
|
"iAmMember": null,
|
||||||
"join": null,
|
"join": null,
|
||||||
@ -628,10 +633,29 @@
|
|||||||
"button": {
|
"button": {
|
||||||
"tooltip": null
|
"tooltip": null
|
||||||
},
|
},
|
||||||
|
"comment-placeholder": null,
|
||||||
"copy-code": null,
|
"copy-code": null,
|
||||||
"copy-success": null,
|
"copy-success": null,
|
||||||
"not-available": null,
|
"create-error": null,
|
||||||
"your-code": 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": {
|
"localeSwitch": {
|
||||||
"tooltip": null
|
"tooltip": null
|
||||||
|
|||||||
@ -244,7 +244,10 @@
|
|||||||
"length": null,
|
"length": null,
|
||||||
"success": null
|
"success": null
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"invited-by": null,
|
||||||
|
"invited-by-and-to": null,
|
||||||
|
"invited-to-hidden-group": null
|
||||||
},
|
},
|
||||||
"no-public-registrstion": {
|
"no-public-registrstion": {
|
||||||
"title": null
|
"title": null
|
||||||
@ -509,6 +512,7 @@
|
|||||||
"categoriesTitle": null,
|
"categoriesTitle": null,
|
||||||
"changeMemberRole": null,
|
"changeMemberRole": null,
|
||||||
"contentMenu": {
|
"contentMenu": {
|
||||||
|
"inviteLinks": null,
|
||||||
"muteGroup": null,
|
"muteGroup": null,
|
||||||
"unmuteGroup": null,
|
"unmuteGroup": null,
|
||||||
"visitGroupPage": null
|
"visitGroupPage": null
|
||||||
@ -531,6 +535,7 @@
|
|||||||
"goal": null,
|
"goal": null,
|
||||||
"groupCreated": null,
|
"groupCreated": null,
|
||||||
"in": null,
|
"in": null,
|
||||||
|
"invite-links": null,
|
||||||
"joinLeaveButton": {
|
"joinLeaveButton": {
|
||||||
"iAmMember": null,
|
"iAmMember": null,
|
||||||
"join": null,
|
"join": null,
|
||||||
@ -628,10 +633,29 @@
|
|||||||
"button": {
|
"button": {
|
||||||
"tooltip": null
|
"tooltip": null
|
||||||
},
|
},
|
||||||
|
"comment-placeholder": null,
|
||||||
"copy-code": null,
|
"copy-code": null,
|
||||||
"copy-success": null,
|
"copy-success": null,
|
||||||
"not-available": null,
|
"create-error": null,
|
||||||
"your-code": 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": {
|
"localeSwitch": {
|
||||||
"tooltip": null
|
"tooltip": null
|
||||||
|
|||||||
@ -244,7 +244,10 @@
|
|||||||
"length": null,
|
"length": null,
|
||||||
"success": null
|
"success": null
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"invited-by": null,
|
||||||
|
"invited-by-and-to": null,
|
||||||
|
"invited-to-hidden-group": null
|
||||||
},
|
},
|
||||||
"no-public-registrstion": {
|
"no-public-registrstion": {
|
||||||
"title": null
|
"title": null
|
||||||
@ -509,6 +512,7 @@
|
|||||||
"categoriesTitle": null,
|
"categoriesTitle": null,
|
||||||
"changeMemberRole": null,
|
"changeMemberRole": null,
|
||||||
"contentMenu": {
|
"contentMenu": {
|
||||||
|
"inviteLinks": null,
|
||||||
"muteGroup": null,
|
"muteGroup": null,
|
||||||
"unmuteGroup": null,
|
"unmuteGroup": null,
|
||||||
"visitGroupPage": null
|
"visitGroupPage": null
|
||||||
@ -531,6 +535,7 @@
|
|||||||
"goal": null,
|
"goal": null,
|
||||||
"groupCreated": null,
|
"groupCreated": null,
|
||||||
"in": null,
|
"in": null,
|
||||||
|
"invite-links": null,
|
||||||
"joinLeaveButton": {
|
"joinLeaveButton": {
|
||||||
"iAmMember": null,
|
"iAmMember": null,
|
||||||
"join": null,
|
"join": null,
|
||||||
@ -628,10 +633,29 @@
|
|||||||
"button": {
|
"button": {
|
||||||
"tooltip": null
|
"tooltip": null
|
||||||
},
|
},
|
||||||
|
"comment-placeholder": null,
|
||||||
"copy-code": null,
|
"copy-code": null,
|
||||||
"copy-success": null,
|
"copy-success": null,
|
||||||
"not-available": null,
|
"create-error": null,
|
||||||
"your-code": 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": {
|
"localeSwitch": {
|
||||||
"tooltip": null
|
"tooltip": null
|
||||||
|
|||||||
@ -244,7 +244,10 @@
|
|||||||
"length": null,
|
"length": null,
|
||||||
"success": null
|
"success": null
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"invited-by": null,
|
||||||
|
"invited-by-and-to": null,
|
||||||
|
"invited-to-hidden-group": null
|
||||||
},
|
},
|
||||||
"no-public-registrstion": {
|
"no-public-registrstion": {
|
||||||
"title": null
|
"title": null
|
||||||
@ -509,6 +512,7 @@
|
|||||||
"categoriesTitle": null,
|
"categoriesTitle": null,
|
||||||
"changeMemberRole": null,
|
"changeMemberRole": null,
|
||||||
"contentMenu": {
|
"contentMenu": {
|
||||||
|
"inviteLinks": null,
|
||||||
"muteGroup": null,
|
"muteGroup": null,
|
||||||
"unmuteGroup": null,
|
"unmuteGroup": null,
|
||||||
"visitGroupPage": null
|
"visitGroupPage": null
|
||||||
@ -531,6 +535,7 @@
|
|||||||
"goal": null,
|
"goal": null,
|
||||||
"groupCreated": null,
|
"groupCreated": null,
|
||||||
"in": null,
|
"in": null,
|
||||||
|
"invite-links": null,
|
||||||
"joinLeaveButton": {
|
"joinLeaveButton": {
|
||||||
"iAmMember": null,
|
"iAmMember": null,
|
||||||
"join": null,
|
"join": null,
|
||||||
@ -628,10 +633,29 @@
|
|||||||
"button": {
|
"button": {
|
||||||
"tooltip": null
|
"tooltip": null
|
||||||
},
|
},
|
||||||
|
"comment-placeholder": null,
|
||||||
"copy-code": null,
|
"copy-code": null,
|
||||||
"copy-success": null,
|
"copy-success": null,
|
||||||
"not-available": null,
|
"create-error": null,
|
||||||
"your-code": 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": {
|
"localeSwitch": {
|
||||||
"tooltip": null
|
"tooltip": null
|
||||||
|
|||||||
@ -151,6 +151,27 @@ exports[`GroupProfileSlug given a puplic group – "yoga-practice" given a close
|
|||||||
</router-link-stub>
|
</router-link-stub>
|
||||||
<!---->
|
<!---->
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<li
|
||||||
|
class="ds-menu-item ds-menu-item-level-0"
|
||||||
|
>
|
||||||
|
<router-link-stub
|
||||||
|
class="ds-menu-item-link"
|
||||||
|
to="/groups/edit/g1/invites"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="base-icon"
|
||||||
|
>
|
||||||
|
<!---->
|
||||||
|
</span>
|
||||||
|
|
||||||
|
group.contentMenu.inviteLinks
|
||||||
|
|
||||||
|
</router-link-stub>
|
||||||
|
<!---->
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
@ -3009,6 +3030,27 @@ exports[`GroupProfileSlug given a puplic group – "yoga-practice" given a curre
|
|||||||
</router-link-stub>
|
</router-link-stub>
|
||||||
<!---->
|
<!---->
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<li
|
||||||
|
class="ds-menu-item ds-menu-item-level-0"
|
||||||
|
>
|
||||||
|
<router-link-stub
|
||||||
|
class="ds-menu-item-link"
|
||||||
|
to="/groups/edit/g2/invites"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="base-icon"
|
||||||
|
>
|
||||||
|
<!---->
|
||||||
|
</span>
|
||||||
|
|
||||||
|
group.contentMenu.inviteLinks
|
||||||
|
|
||||||
|
</router-link-stub>
|
||||||
|
<!---->
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
@ -6489,6 +6531,27 @@ exports[`GroupProfileSlug given a puplic group – "yoga-practice" given a hidde
|
|||||||
</router-link-stub>
|
</router-link-stub>
|
||||||
<!---->
|
<!---->
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<li
|
||||||
|
class="ds-menu-item ds-menu-item-level-0"
|
||||||
|
>
|
||||||
|
<router-link-stub
|
||||||
|
class="ds-menu-item-link"
|
||||||
|
to="/groups/edit/g0/invites"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="base-icon"
|
||||||
|
>
|
||||||
|
<!---->
|
||||||
|
</span>
|
||||||
|
|
||||||
|
group.contentMenu.inviteLinks
|
||||||
|
|
||||||
|
</router-link-stub>
|
||||||
|
<!---->
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -13,7 +13,7 @@
|
|||||||
</ds-flex-item>
|
</ds-flex-item>
|
||||||
<ds-flex-item :width="{ base: '100%', md: 1 }">
|
<ds-flex-item :width="{ base: '100%', md: 1 }">
|
||||||
<transition name="slide-up" appear>
|
<transition name="slide-up" appear>
|
||||||
<nuxt-child :group="group" />
|
<nuxt-child :group="group" @update-invite-codes="updateInviteCodes" />
|
||||||
</transition>
|
</transition>
|
||||||
</ds-flex-item>
|
</ds-flex-item>
|
||||||
</ds-flex>
|
</ds-flex>
|
||||||
@ -39,9 +39,18 @@ export default {
|
|||||||
name: this.$t('group.members'),
|
name: this.$t('group.members'),
|
||||||
path: `/groups/edit/${this.group.id}/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) {
|
async asyncData(context) {
|
||||||
const {
|
const {
|
||||||
app,
|
app,
|
||||||
@ -62,5 +71,10 @@ export default {
|
|||||||
}
|
}
|
||||||
return { group }
|
return { group }
|
||||||
},
|
},
|
||||||
|
methods: {
|
||||||
|
updateInviteCodes(inviteCodes) {
|
||||||
|
this.group.inviteCodes = inviteCodes
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
167
webapp/pages/groups/edit/_id/__snapshots__/invites.spec.js.snap
Normal file
167
webapp/pages/groups/edit/_id/__snapshots__/invites.spec.js.snap
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`invites.vue renders 1`] = `
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<article
|
||||||
|
class="base-card"
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
class="ds-heading ds-heading-h3"
|
||||||
|
>
|
||||||
|
invite-codes.group-invite-links
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="ds-space"
|
||||||
|
style="margin-top: 32px; margin-bottom: 32px;"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="invitation-list"
|
||||||
|
>
|
||||||
|
<ul>
|
||||||
|
<client-only-stub>
|
||||||
|
<li
|
||||||
|
class="invitation"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="column1"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="code"
|
||||||
|
>
|
||||||
|
|
||||||
|
INVITE1
|
||||||
|
|
||||||
|
<span
|
||||||
|
class="mdash"
|
||||||
|
>
|
||||||
|
—
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span
|
||||||
|
class="comment"
|
||||||
|
>
|
||||||
|
Test invite 1
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span>
|
||||||
|
|
||||||
|
invite-codes.redeemed-count-0
|
||||||
|
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="actions"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
aria-label="invite-codes.copy-code"
|
||||||
|
class="copy-button base-button --icon-only --circle"
|
||||||
|
disabled="disabled"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="base-icon"
|
||||||
|
>
|
||||||
|
<!---->
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!---->
|
||||||
|
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
aria-label="invite-codes.invalidate"
|
||||||
|
class="invalidate-button base-button --icon-only --circle"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="base-icon"
|
||||||
|
>
|
||||||
|
<!---->
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!---->
|
||||||
|
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</client-only-stub>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="create-invitation create-invitation"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
invite-codes.generate-code-explanation
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
class="generate-invite-code-form"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="ds-form-item ds-input-size-base"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
class="ds-input-label"
|
||||||
|
style="display: none;"
|
||||||
|
>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
class="ds-input-wrap"
|
||||||
|
>
|
||||||
|
<!---->
|
||||||
|
<input
|
||||||
|
class="ds-input"
|
||||||
|
name="comment"
|
||||||
|
placeholder="invite-codes.comment-placeholder"
|
||||||
|
tabindex="0"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<!---->
|
||||||
|
</div>
|
||||||
|
<transition-stub
|
||||||
|
name="ds-input-error"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="ds-input-error"
|
||||||
|
style="display: none;"
|
||||||
|
>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</transition-stub>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
aria-label="invite-codes.generate-code"
|
||||||
|
class="generate-invite-code base-button --icon-only --circle"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="base-icon"
|
||||||
|
>
|
||||||
|
<!---->
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!---->
|
||||||
|
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!---->
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
86
webapp/pages/groups/edit/_id/invites.spec.js
Normal file
86
webapp/pages/groups/edit/_id/invites.spec.js
Normal file
@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
81
webapp/pages/groups/edit/_id/invites.vue
Normal file
81
webapp/pages/groups/edit/_id/invites.vue
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<base-card>
|
||||||
|
<ds-heading tag="h3">{{ $t('invite-codes.group-invite-links') }}</ds-heading>
|
||||||
|
<ds-space margin="large" />
|
||||||
|
<invitation-list
|
||||||
|
@generate-invite-code="generateGroupInviteCode"
|
||||||
|
@invalidate-invite-code="invalidateInviteCode"
|
||||||
|
:inviteCodes="group.inviteCodes"
|
||||||
|
:copy-message="
|
||||||
|
group.type === 'hidden'
|
||||||
|
? $T('invite-codes.invite-link-message-hidden-group', {
|
||||||
|
network: $env.NETWORK_NAME,
|
||||||
|
})
|
||||||
|
: $t('invite-codes.invite-link-message-group', {
|
||||||
|
groupName: group.name,
|
||||||
|
network: $env.NETWORK_NAME,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</base-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import InvitationList from '~/components/_new/features/Invitations/InvitationList.vue'
|
||||||
|
import { generateGroupInviteCode, invalidateInviteCode } from '~/graphql/InviteCode'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
InvitationList,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
group: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async generateGroupInviteCode(comment) {
|
||||||
|
try {
|
||||||
|
await this.$apollo.mutate({
|
||||||
|
mutation: generateGroupInviteCode(),
|
||||||
|
variables: {
|
||||||
|
comment,
|
||||||
|
groupId: this.group.id,
|
||||||
|
},
|
||||||
|
update: (_, { data: { generateGroupInviteCode } }) => {
|
||||||
|
this.$emit('update-invite-codes', [...this.group.inviteCodes, generateGroupInviteCode])
|
||||||
|
},
|
||||||
|
})
|
||||||
|
this.$toast.success(this.$t('invite-codes.create-success'))
|
||||||
|
} catch (error) {
|
||||||
|
this.$toast.error(this.$t('invite-codes.create-error', { error: error.message }))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async invalidateInviteCode(code) {
|
||||||
|
try {
|
||||||
|
await this.$apollo.mutate({
|
||||||
|
mutation: invalidateInviteCode(),
|
||||||
|
variables: {
|
||||||
|
code,
|
||||||
|
},
|
||||||
|
update: (_, { data: { _invalidateInviteCode } }) => {
|
||||||
|
this.$emit(
|
||||||
|
'update-invite-codes',
|
||||||
|
this.group.inviteCodes.map((inviteCode) => ({
|
||||||
|
...inviteCode,
|
||||||
|
isValid: inviteCode.code === code ? false : inviteCode.isValid,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
this.$toast.success(this.$t('invite-codes.invalidate-success'))
|
||||||
|
} catch (error) {
|
||||||
|
this.$toast.error(this.$t('invite-codes.invalidate-error', { error: error.message }))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@ -18,6 +18,9 @@ export const mutations = {
|
|||||||
SET_USER(state, user) {
|
SET_USER(state, user) {
|
||||||
state.user = user || null
|
state.user = user || null
|
||||||
},
|
},
|
||||||
|
SET_USER_PARTIAL(state, user) {
|
||||||
|
state.user = { ...state.user, ...user }
|
||||||
|
},
|
||||||
SET_TOKEN(state, token) {
|
SET_TOKEN(state, token) {
|
||||||
state.token = token || null
|
state.token = token || null
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user