mirror of
https://github.com/Ocelot-Social-Community/Ocelot-Social.git
synced 2025-12-12 23:35:58 +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
|
||||
INVITE_REGISTRATION=true
|
||||
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 {
|
||||
padding: 0;
|
||||
margin: -$space-base;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -88,6 +88,27 @@ exports[`GroupContentMenu renders as groupProfile when I am the owner 1`] = `
|
||||
</a>
|
||||
<!---->
|
||||
</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>
|
||||
</nav>
|
||||
</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 #popover>
|
||||
<div class="invite-button-menu-popover">
|
||||
<div v-if="inviteCode && inviteCode.code">
|
||||
<p class="description">{{ $t('invite-codes.your-code') }}</p>
|
||||
<base-card class="code-card" wideContent>
|
||||
<base-button
|
||||
v-if="canCopy"
|
||||
class="invite-code"
|
||||
icon="copy"
|
||||
ghost
|
||||
@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 class="invite-list">
|
||||
<h2>{{ $t('invite-codes.my-invite-links') }}</h2>
|
||||
<invitation-list
|
||||
@generate-invite-code="generatePersonalInviteCode"
|
||||
@invalidate-invite-code="invalidateInviteCode"
|
||||
:inviteCodes="user.inviteCodes"
|
||||
:copy-message="
|
||||
$t('invite-codes.invite-link-message-personal', {
|
||||
network: $env.NETWORK_NAME,
|
||||
})
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</dropdown>
|
||||
@ -38,82 +32,87 @@
|
||||
|
||||
<script>
|
||||
import Dropdown from '~/components/Dropdown'
|
||||
import gql from 'graphql-tag'
|
||||
import BaseCard from '../_new/generic/BaseCard/BaseCard.vue'
|
||||
import { mapGetters, mapMutations } from 'vuex'
|
||||
import InvitationList from '~/components/_new/features/Invitations/InvitationList.vue'
|
||||
import { generatePersonalInviteCode, invalidateInviteCode } from '~/graphql/InviteCode'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Dropdown,
|
||||
BaseCard,
|
||||
InvitationList,
|
||||
},
|
||||
props: {
|
||||
placement: { type: String, default: 'top-end' },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
inviteCode: null,
|
||||
canCopy: false,
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.canCopy = !!navigator.clipboard
|
||||
},
|
||||
|
||||
computed: {
|
||||
inviteLink() {
|
||||
return (
|
||||
'https://' +
|
||||
window.location.hostname +
|
||||
'/registration?method=invite-code&inviteCode=' +
|
||||
this.inviteCode.code
|
||||
)
|
||||
...mapGetters({
|
||||
user: 'auth/user',
|
||||
}),
|
||||
inviteCode() {
|
||||
return this.user.inviteCodes[0] || null
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async copyInviteLink() {
|
||||
await navigator.clipboard.writeText(this.inviteLink)
|
||||
this.$toast.success(this.$t('invite-codes.copy-success'))
|
||||
...mapMutations({
|
||||
setCurrentUser: 'auth/SET_USER_PARTIAL',
|
||||
}),
|
||||
async generatePersonalInviteCode(comment) {
|
||||
try {
|
||||
await this.$apollo.mutate({
|
||||
mutation: generatePersonalInviteCode(),
|
||||
variables: {
|
||||
comment,
|
||||
},
|
||||
update: (_, { data: { generatePersonalInviteCode } }) => {
|
||||
this.setCurrentUser({
|
||||
...this.currentUser,
|
||||
inviteCodes: [...this.user.inviteCodes, generatePersonalInviteCode],
|
||||
})
|
||||
},
|
||||
})
|
||||
this.$toast.success(this.$t('invite-codes.create-success'))
|
||||
} catch (error) {
|
||||
this.$toast.error(this.$t('invite-codes.create-error', { error: error.message }))
|
||||
}
|
||||
},
|
||||
},
|
||||
apollo: {
|
||||
inviteCode: {
|
||||
query() {
|
||||
return gql`
|
||||
query {
|
||||
getInviteCode {
|
||||
code
|
||||
}
|
||||
}
|
||||
`
|
||||
},
|
||||
variables() {},
|
||||
update({ getInviteCode }) {
|
||||
return getInviteCode
|
||||
},
|
||||
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 }))
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scope>
|
||||
<style lang="scss" scoped>
|
||||
.invite-button {
|
||||
color: $color-secondary;
|
||||
}
|
||||
|
||||
.invite-button-menu-popover {
|
||||
.invite-list {
|
||||
max-width: min(400px, 90vw);
|
||||
padding: $space-small;
|
||||
margin-top: $space-base;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
.description {
|
||||
margin-top: $space-x-small;
|
||||
margin-bottom: $space-x-small;
|
||||
}
|
||||
.code-card {
|
||||
margin-bottom: $space-x-small;
|
||||
}
|
||||
}
|
||||
|
||||
.invite-code {
|
||||
margin-left: 25%;
|
||||
flex-flow: column;
|
||||
gap: $space-small;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -13,28 +13,48 @@
|
||||
id="inviteCode"
|
||||
icon="question-circle"
|
||||
/>
|
||||
<ds-text>
|
||||
<ds-text v-if="!validInput">
|
||||
{{ $t('components.registration.invite-code.form.description') }}
|
||||
</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>
|
||||
<ds-space margin="xxx-small" />
|
||||
</ds-form>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import gql from 'graphql-tag'
|
||||
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 {
|
||||
name: 'RegistrationSlideInvite',
|
||||
props: {
|
||||
sliderData: { type: Object, required: true },
|
||||
},
|
||||
components: {
|
||||
ProfileAvatar,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
formData: {
|
||||
@ -75,6 +95,16 @@ export default {
|
||||
validInput() {
|
||||
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: {
|
||||
async sendValidation() {
|
||||
@ -84,8 +114,7 @@ export default {
|
||||
|
||||
let dbValidated = false
|
||||
if (this.validInput) {
|
||||
await this.handleSubmitVerify()
|
||||
dbValidated = this.sliderData.sliders[this.sliderIndex].data.response.isValidInviteCode
|
||||
dbValidated = await this.handleSubmitVerify()
|
||||
}
|
||||
this.sliderData.setSliderValuesCallback(dbValidated)
|
||||
},
|
||||
@ -110,7 +139,7 @@ export default {
|
||||
try {
|
||||
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, {
|
||||
sliderData: {
|
||||
request: { variables },
|
||||
@ -118,20 +147,22 @@ export default {
|
||||
},
|
||||
})
|
||||
|
||||
if (this.sliderData.sliders[this.sliderIndex].data.response) {
|
||||
if (this.sliderData.sliders[this.sliderIndex].data.response.isValidInviteCode) {
|
||||
this.$toast.success(
|
||||
this.$t('components.registration.invite-code.form.validations.success', {
|
||||
inviteCode,
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
this.$toast.error(
|
||||
this.$t('components.registration.invite-code.form.validations.error', {
|
||||
inviteCode,
|
||||
}),
|
||||
)
|
||||
}
|
||||
const validationResult = response.data.validateInviteCode
|
||||
|
||||
if (validationResult && validationResult.isValid) {
|
||||
this.$toast.success(
|
||||
this.$t('components.registration.invite-code.form.validations.success', {
|
||||
inviteCode,
|
||||
}),
|
||||
)
|
||||
return true
|
||||
} else {
|
||||
this.$toast.error(
|
||||
this.$t('components.registration.invite-code.form.validations.error', {
|
||||
inviteCode,
|
||||
}),
|
||||
)
|
||||
return false
|
||||
}
|
||||
} catch (err) {
|
||||
this.sliderData.setSliderValuesCallback(false, {
|
||||
@ -140,6 +171,7 @@ export default {
|
||||
|
||||
const { message } = err
|
||||
this.$toast.error(message)
|
||||
return false
|
||||
} finally {
|
||||
this.dbRequestInProgress = false
|
||||
}
|
||||
@ -152,10 +184,25 @@ export default {
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
<style lang="scss" scoped>
|
||||
.enter-invite {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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>
|
||||
|
||||
@ -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
|
||||
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 = {
|
||||
|
||||
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 {
|
||||
currentUser {
|
||||
...user
|
||||
inviteCodes {
|
||||
code
|
||||
isValid
|
||||
redeemedBy {
|
||||
id
|
||||
}
|
||||
comment
|
||||
redeemedByCount
|
||||
}
|
||||
badgeTrophiesSelected {
|
||||
id
|
||||
icon
|
||||
|
||||
@ -195,6 +195,16 @@ export const groupQuery = (i18n) => {
|
||||
lat
|
||||
}
|
||||
myRole
|
||||
inviteCodes {
|
||||
createdAt
|
||||
code
|
||||
isValid
|
||||
redeemedBy {
|
||||
id
|
||||
}
|
||||
comment
|
||||
redeemedByCount
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
@ -244,7 +244,10 @@
|
||||
"length": "muss genau {inviteCodeLength} Buchstaben lang sein",
|
||||
"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": {
|
||||
"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"
|
||||
|
||||
@ -244,7 +244,10 @@
|
||||
"length": "must be {inviteCodeLength} characters long",
|
||||
"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": {
|
||||
"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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -151,6 +151,27 @@ exports[`GroupProfileSlug given a puplic group – "yoga-practice" given a close
|
||||
</router-link-stub>
|
||||
<!---->
|
||||
</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>
|
||||
</nav>
|
||||
</div>
|
||||
@ -3009,6 +3030,27 @@ exports[`GroupProfileSlug given a puplic group – "yoga-practice" given a curre
|
||||
</router-link-stub>
|
||||
<!---->
|
||||
</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>
|
||||
</nav>
|
||||
</div>
|
||||
@ -6489,6 +6531,27 @@ exports[`GroupProfileSlug given a puplic group – "yoga-practice" given a hidde
|
||||
</router-link-stub>
|
||||
<!---->
|
||||
</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>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
</ds-flex-item>
|
||||
<ds-flex-item :width="{ base: '100%', md: 1 }">
|
||||
<transition name="slide-up" appear>
|
||||
<nuxt-child :group="group" />
|
||||
<nuxt-child :group="group" @update-invite-codes="updateInviteCodes" />
|
||||
</transition>
|
||||
</ds-flex-item>
|
||||
</ds-flex>
|
||||
@ -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
|
||||
},
|
||||
},
|
||||
}
|
||||
</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) {
|
||||
state.user = user || null
|
||||
},
|
||||
SET_USER_PARTIAL(state, user) {
|
||||
state.user = { ...state.user, ...user }
|
||||
},
|
||||
SET_TOKEN(state, token) {
|
||||
state.token = token || null
|
||||
},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user