diff --git a/CHANGELOG.md b/CHANGELOG.md index 332b24ceb..2f16f921c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,31 @@ All notable changes to this project will be documented in this file. Dates are d Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). +#### [3.6.0](https://github.com/Ocelot-Social-Community/Ocelot-Social/compare/3.5.3...3.6.0) + +- Show invititation dropdown until user clicks somewhere else [`#8539`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/8539) +- feat(webapp): redirect to group after registration with invite to group [`#8540`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/8540) +- fix(webapp): fix layout break and hidden group name appearance [`#8538`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/8538) +- feat(webapp): several group and personal invitation links [`#8504`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/8504) +- fix(backend): category seed [`#8536`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/8536) +- correct copy path in dockerfile [`#8519`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/8519) +- feat(webapp): group invite after login [`#8518`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/8518) +- feat(webapp): redirect on registration for invite links [`#8517`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/8517) +- fix(webapp): mobile optimization [`#8516`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/8516) +- feat(docu): update email snapshots [`#8514`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/8514) +- fix(backend): fix user profile and group links in e-mails [`#8512`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/8512) +- fix(backend): fix registration with invite code [`#8513`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/8513) +- fix locales errors (german) [`#8510`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/8510) +- fix(backend): invite codes - hotfix 1 [`#8508`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/8508) +- refactor(backend): category seed [`#8505`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/8505) +- feat(backend): group invite codes [`#8499`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/8499) +- feat(webapp): change german to `du` and `dich` [`#8507`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/8507) + #### [3.5.3](https://github.com/Ocelot-Social-Community/Ocelot-Social/compare/3.5.2...3.5.3) +> 7 May 2025 + +- chore(release): v3.5.3 [`#8503`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/8503) - fix(backend): correct email from [`#8501`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/8501) - refactor(backend): types for global config [`#8485`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/8485) - fix warning in workflow for lower case as [`#8494`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/8494) @@ -1438,31 +1461,15 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - updated CHANGELOG.md [`9d9075f`](https://github.com/Ocelot-Social-Community/Ocelot-Social/commit/9d9075f2117b2eb4b607e7d59ab18c7e655c6ea7) -#### [0.6.4](https://github.com/Ocelot-Social-Community/Ocelot-Social/compare/v0.6.4...0.6.4) +#### [0.6.4](https://github.com/Ocelot-Social-Community/Ocelot-Social/compare/0.6.3...0.6.4) > 8 February 2021 -- - adjusted changelog to ocelot-social repo [`9603882`](https://github.com/Ocelot-Social-Community/Ocelot-Social/commit/9603882edebf8967e05abfa94e4e1ebf452d4e24) -- - first steps towards docker image deployment & github autotagging [`5503216`](https://github.com/Ocelot-Social-Community/Ocelot-Social/commit/5503216ad4a0230ac533042e4a69806590fc2a5a) -- - deploy structure image [`a60400b`](https://github.com/Ocelot-Social-Community/Ocelot-Social/commit/a60400b4fe6f59bbb80e1073db4def3ba205e1a7) +- regenerated `CHANGELOG.md` [`ee688ec`](https://github.com/Ocelot-Social-Community/Ocelot-Social/commit/ee688ece24cf592b3989e83340701ca8772e876e) +- fetch full history [`5ecee4d`](https://github.com/Ocelot-Social-Community/Ocelot-Social/commit/5ecee4d73a92d2e5c5ae971d79848ed27f65a72c) +- don't fail if tag exists (release) [`39c82fc`](https://github.com/Ocelot-Social-Community/Ocelot-Social/commit/39c82fcb37d5c8e7e78a79288e1ef6280f8d0892) -#### [v0.6.4](https://github.com/Ocelot-Social-Community/Ocelot-Social/compare/0.6.3...v0.6.4) - -> 9 February 2021 - -- chore(release): 0.6.4 [`8b7570d`](https://github.com/Ocelot-Social-Community/Ocelot-Social/commit/8b7570dc35d0ea431f673a711ac051f1e1320acb) -- change user roles is working, test fails [`8c3310a`](https://github.com/Ocelot-Social-Community/Ocelot-Social/commit/8c3310abaf87c0e5597fec4f93fb37d27122c9e7) -- change user role: tests are working [`f10da4b`](https://github.com/Ocelot-Social-Community/Ocelot-Social/commit/f10da4b09388fe1e2b85abd53f6ffc67c785d4c1) - -#### [0.6.3](https://github.com/Ocelot-Social-Community/Ocelot-Social/compare/v0.6.3...0.6.3) - -> 8 February 2021 - -- - adjusted changelog to ocelot-social repo [`9603882`](https://github.com/Ocelot-Social-Community/Ocelot-Social/commit/9603882edebf8967e05abfa94e4e1ebf452d4e24) -- - fixed changelog [`cf70b12`](https://github.com/Ocelot-Social-Community/Ocelot-Social/commit/cf70b12ed74011924ea788ab932fc9d7ac0e6bd9) -- - yarn install to allow yarn auto-changelog [`fc496aa`](https://github.com/Ocelot-Social-Community/Ocelot-Social/commit/fc496aa04cb7e804da4335da0cb5cda26f874ea2) - -#### [v0.6.3](https://github.com/Ocelot-Social-Community/Ocelot-Social/compare/0.6.0...v0.6.3) +#### [0.6.3](https://github.com/Ocelot-Social-Community/Ocelot-Social/compare/0.6.0...0.6.3) > 8 February 2021 diff --git a/backend/Dockerfile b/backend/Dockerfile index e481da5a3..697277a00 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -21,9 +21,9 @@ CMD ["/bin/sh", "-c", "yarn install && yarn run dev"] FROM base AS build COPY . . ONBUILD COPY ./branding/constants/ src/config/tmp -ONBUILD RUN tools/replace-constants.sh # copy categories to brand them (use yarn prod:db:data:categories) -ONBUILD COPY branding/constants/ src/constants/ +ONBUILD COPY ./branding/constants/ src/constants/ +ONBUILD RUN tools/replace-constants.sh ONBUILD COPY ./branding/email/ src/middleware/helpers/email/ ONBUILD COPY ./branding/middlewares/ src/middleware/branding/ ONBUILD COPY ./branding/data/ src/db/data diff --git a/backend/package.json b/backend/package.json index 0cfa5a080..a91a8a77f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "ocelot-social-backend", - "version": "3.5.3", + "version": "3.6.0", "description": "GraphQL Backend for ocelot.social", "repository": "https://github.com/Ocelot-Social-Community/Ocelot-Social", "author": "ocelot.social Community", diff --git a/backend/src/db/categories.ts b/backend/src/db/categories.ts index 24421a400..8c8943bd4 100644 --- a/backend/src/db/categories.ts +++ b/backend/src/db/categories.ts @@ -12,10 +12,21 @@ const createCategories = async () => { query: 'MATCH (category:Category) RETURN category { .* }', }) - const existingCategories = result.records.map((r) => r.get('category')) - const existingCategoryIds = existingCategories.map((c) => c.id) + const categoryIds = categories.map((c) => c.id) + const categorySlugs = categories.map((c) => c.slug) + await write({ + query: `MATCH (category:Category) + WHERE NOT category.id IN $categoryIds + DETACH DELETE category`, + variables: { + categoryIds, + categorySlugs, + }, + }) - const newCategories = categories.filter((c) => !existingCategoryIds.includes(c.id)) + const existingCategories = result.records.map((r) => r.get('category')) + + const newCategories = categories.filter((c) => !existingCategories.some((cat) => c.id === cat.id)) await write({ query: `UNWIND $newCategories AS map @@ -27,15 +38,6 @@ const createCategories = async () => { }, }) - const categoryIds = categories.map((c) => c.id) - await write({ - query: `MATCH (category:Category) - WHERE NOT category.id IN $categoryIds - DETACH DELETE category`, - variables: { - categoryIds, - }, - }) // eslint-disable-next-line no-console console.log('Successfully created categories!') await driver.close() diff --git a/backend/src/db/seed.ts b/backend/src/db/seed.ts index ed1fff1a4..e7f5b23c5 100644 --- a/backend/src/db/seed.ts +++ b/backend/src/db/seed.ts @@ -308,12 +308,11 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] await dagobert.relateTo(louie, 'blocked') // categories - let i = 0 for (const category of categories) { await Factory.build('category', { - id: `cat${i++}`, - slug: category.name, - naem: category.name, + id: category.id, + slug: category.slug, + name: category.name, icon: category.icon, }) } diff --git a/backend/src/middleware/permissionsMiddleware.ts b/backend/src/middleware/permissionsMiddleware.ts index 1a598b972..a775e2fe3 100644 --- a/backend/src/middleware/permissionsMiddleware.ts +++ b/backend/src/middleware/permissionsMiddleware.ts @@ -514,6 +514,7 @@ export default shield( }, Group: { '*': isAuthenticated, // TODO - only those who are allowed to see the group + slug: allow, avatar: allow, name: allow, about: allow, diff --git a/backend/tools/replace-constants.sh b/backend/tools/replace-constants.sh index e7cee6ee3..5454d3ae8 100755 --- a/backend/tools/replace-constants.sh +++ b/backend/tools/replace-constants.sh @@ -4,4 +4,5 @@ [ -f src/config/tmp/emails.js ] && mv src/config/tmp/emails.js src/config/emails.ts [ -f src/config/tmp/logos.js ] && mv src/config/tmp/logos.js src/config/logos.ts [ -f src/config/tmp/metadata.js ] && mv src/config/tmp/metadata.js src/config/metadata.ts +[ -f src/constants/categories.js ] && mv src/constants/categories.js src/constants/categories.ts exit 0 diff --git a/deployment/helm/charts/ocelot-neo4j/Chart.yaml b/deployment/helm/charts/ocelot-neo4j/Chart.yaml index 461ed90a3..125a84c31 100644 --- a/deployment/helm/charts/ocelot-neo4j/Chart.yaml +++ b/deployment/helm/charts/ocelot-neo4j/Chart.yaml @@ -21,4 +21,4 @@ version: 0.1.0 # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -appVersion: "3.5.3" +appVersion: "3.6.0" diff --git a/deployment/helm/charts/ocelot-social/Chart.yaml b/deployment/helm/charts/ocelot-social/Chart.yaml index 81febe4a7..eda686495 100644 --- a/deployment/helm/charts/ocelot-social/Chart.yaml +++ b/deployment/helm/charts/ocelot-social/Chart.yaml @@ -21,4 +21,4 @@ version: 0.1.0 # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -appVersion: "3.5.3" +appVersion: "3.6.0" diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 250de7cce..492bb4ae2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ocelot-social-frontend", - "version": "3.5.3", + "version": "3.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ocelot-social-frontend", - "version": "3.5.3", + "version": "3.6.0", "license": "Apache-2.0", "dependencies": { "@intlify/unplugin-vue-i18n": "^2.0.0", diff --git a/frontend/package.json b/frontend/package.json index 62532f76f..e151e436e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "ocelot-social-frontend", - "version": "3.5.3", + "version": "3.6.0", "description": "ocelot.social new Frontend (in development and not fully implemented) by IT4C Boilerplate for frontends", "main": "build/index.js", "type": "module", diff --git a/package.json b/package.json index 4581d134a..4d577a221 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ocelot-social", - "version": "3.5.3", + "version": "3.6.0", "description": "Free and open source software program code available to run social networks.", "author": "ocelot.social Community", "license": "MIT", diff --git a/webapp/.env.template b/webapp/.env.template index 76bc502f9..ee9fd0578 100644 --- a/webapp/.env.template +++ b/webapp/.env.template @@ -6,3 +6,6 @@ MAPBOX_TOKEN="pk.eyJ1IjoiYnVzZmFrdG9yIiwiYSI6ImNraDNiM3JxcDBhaWQydG1uczhpZWtpOW4 PUBLIC_REGISTRATION=false INVITE_REGISTRATION=true CATEGORIES_ACTIVE=false +BADGES_ENABLED=true +INVITE_LINK_LIMIT=7 +NETWORK_NAME="Ocelot.social" diff --git a/webapp/assets/styles/main.scss b/webapp/assets/styles/main.scss index 4fba0b5e0..036b7b90d 100644 --- a/webapp/assets/styles/main.scss +++ b/webapp/assets/styles/main.scss @@ -142,6 +142,12 @@ hr { } } +body.dropdown-open { + max-height: 100vh; + overflow: hidden; + scrollbar-gutter: stable; +} + .base-card > .ds-section { padding: 0; margin: -$space-base; diff --git a/webapp/components/ContentMenu/GroupContentMenu.vue b/webapp/components/ContentMenu/GroupContentMenu.vue index e28a855ac..2c15ad28d 100644 --- a/webapp/components/ContentMenu/GroupContentMenu.vue +++ b/webapp/components/ContentMenu/GroupContentMenu.vue @@ -89,6 +89,11 @@ export default { path: `/groups/edit/${this.group.id}`, icon: 'edit', }) + routes.push({ + label: this.$t('group.contentMenu.inviteLinks'), + path: `/groups/edit/${this.group.id}/invites`, + icon: 'link', + }) } return routes diff --git a/webapp/components/ContentMenu/__snapshots__/GroupContentMenu.spec.js.snap b/webapp/components/ContentMenu/__snapshots__/GroupContentMenu.spec.js.snap index 0553dfa79..5875d2341 100644 --- a/webapp/components/ContentMenu/__snapshots__/GroupContentMenu.spec.js.snap +++ b/webapp/components/ContentMenu/__snapshots__/GroupContentMenu.spec.js.snap @@ -88,6 +88,27 @@ exports[`GroupContentMenu renders as groupProfile when I am the owner 1`] = ` + + + +
  • + + + + + + group.contentMenu.inviteLinks + + + +
  • diff --git a/webapp/components/Dropdown.vue b/webapp/components/Dropdown.vue index dd2b4a822..7b1aa4f80 100644 --- a/webapp/components/Dropdown.vue +++ b/webapp/components/Dropdown.vue @@ -29,11 +29,11 @@ export default { placement: { type: String, default: 'bottom-end' }, disabled: { type: Boolean, default: false }, offset: { type: [String, Number], default: '16' }, + noMouseLeaveClosing: { type: Boolean, default: false }, }, data() { return { isPopoverOpen: false, - developerNoAutoClosing: false, // stops automatic closing of menu for developer purposes: default is 'false' } }, computed: { @@ -94,8 +94,7 @@ export default { } }, popoverMouseLeave() { - if (this.developerNoAutoClosing) return - if (this.disabled) { + if (this.noMouseLeaveClosing || this.disabled) { return } this.clearTimeouts() diff --git a/webapp/components/InviteButton/InviteButton.spec.js b/webapp/components/InviteButton/InviteButton.spec.js deleted file mode 100644 index 1282c2bad..000000000 --- a/webapp/components/InviteButton/InviteButton.spec.js +++ /dev/null @@ -1,59 +0,0 @@ -import { mount } from '@vue/test-utils' -import InviteButton from './InviteButton.vue' - -const localVue = global.localVue - -const stubs = { - 'v-popover': { - template: '', - }, -} - -describe('InviteButton.vue', () => { - let wrapper - let mocks - let propsData - - beforeEach(() => { - mocks = { - $t: jest.fn(), - navigator: { - clipboard: { - writeText: jest.fn(), - }, - }, - } - propsData = {} - }) - - describe('mount', () => { - const Wrapper = () => { - return mount(InviteButton, { mocks, localVue, propsData, stubs }) - } - - beforeEach(() => { - wrapper = Wrapper() - }) - - it('renders', () => { - expect(wrapper.find('.invite-button').exists()).toBe(true) - }) - - it('open popup', () => { - wrapper.find('.base-button').trigger('click') - expect(wrapper.find('.invite-button').exists()).toBe(true) - }) - - it('invite codes not available', async () => { - wrapper.find('.base-button').trigger('click') // open popup - wrapper.find('.invite-button').trigger('click') // click copy button - expect(mocks.$t).toHaveBeenCalledWith('invite-codes.not-available') - }) - - it.skip('invite codes copied to clipboard', async () => { - wrapper.find('.base-button').trigger('click') // open popup - wrapper.find('.invite-button').trigger('click') // click copy button - expect(mocks.$t).toHaveBeenCalledWith('invite-codes.not-available') - }) - }) -}) diff --git a/webapp/components/InviteButton/InviteButton.vue b/webapp/components/InviteButton/InviteButton.vue index 3042a706a..51d8c3ff6 100644 --- a/webapp/components/InviteButton/InviteButton.vue +++ b/webapp/components/InviteButton/InviteButton.vue @@ -1,5 +1,5 @@ - diff --git a/webapp/components/Registration/RegistrationSlider.vue b/webapp/components/Registration/RegistrationSlider.vue index 6bd704b32..25cbe648a 100644 --- a/webapp/components/Registration/RegistrationSlider.vue +++ b/webapp/components/Registration/RegistrationSlider.vue @@ -30,7 +30,7 @@ @@ -163,6 +163,10 @@ export default { } return { + loginLink: { + name: 'login', + query: this.$route.query, + }, links, metadata, sliderData: { diff --git a/webapp/components/_new/features/Invitations/CreateInvitation.spec.js b/webapp/components/_new/features/Invitations/CreateInvitation.spec.js new file mode 100644 index 000000000..10e13d62c --- /dev/null +++ b/webapp/components/_new/features/Invitations/CreateInvitation.spec.js @@ -0,0 +1,51 @@ +import { render, screen, fireEvent } from '@testing-library/vue' + +import CreateInvitation from './CreateInvitation.vue' + +const localVue = global.localVue + +describe('CreateInvitation.vue', () => { + let wrapper + + const Wrapper = ({ isDisabled = false }) => { + return render(CreateInvitation, { + localVue, + propsData: { + isDisabled, + }, + mocks: { + $t: jest.fn((v) => v), + }, + }) + } + + it('renders', () => { + wrapper = Wrapper({}) + expect(wrapper.container).toMatchSnapshot() + }) + + it('renders with disabled button', () => { + wrapper = Wrapper({ isDisabled: true }) + expect(wrapper.container).toMatchSnapshot() + }) + + describe('when the form is submitted', () => { + beforeEach(() => { + wrapper = Wrapper({}) + }) + + it('emits generate-invite-code with empty comment', async () => { + const button = screen.getByRole('button') + await fireEvent.click(button) + expect(wrapper.emitted()['generate-invite-code']).toEqual([['']]) + }) + + it('emits generate-invite-code with comment', async () => { + const button = screen.getByRole('button') + const input = screen.getByPlaceholderText('invite-codes.comment-placeholder') + await fireEvent.update(input, 'Test comment') + await fireEvent.click(button) + expect(wrapper.emitted()['generate-invite-code']).toEqual([['Test comment']]) + }) + }) +}) diff --git a/webapp/components/_new/features/Invitations/CreateInvitation.vue b/webapp/components/_new/features/Invitations/CreateInvitation.vue new file mode 100644 index 000000000..6147ca682 --- /dev/null +++ b/webapp/components/_new/features/Invitations/CreateInvitation.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/webapp/components/_new/features/Invitations/Invitation.spec.js b/webapp/components/_new/features/Invitations/Invitation.spec.js new file mode 100644 index 000000000..dd62847ef --- /dev/null +++ b/webapp/components/_new/features/Invitations/Invitation.spec.js @@ -0,0 +1,115 @@ +import { render, screen, fireEvent } from '@testing-library/vue' +import '@testing-library/jest-dom' + +import Invitation from './Invitation.vue' + +const localVue = global.localVue + +Object.assign(navigator, { + clipboard: { + writeText: jest.fn(), + }, +}) + +const mutations = { + 'modal/SET_OPEN': jest.fn().mockResolvedValue(), +} + +describe('Invitation.vue', () => { + let wrapper + + const Wrapper = ({ wasRedeemed = false, withCopymessage = false }) => { + const propsData = { + inviteCode: { + code: 'test-invite-code', + comment: 'test-comment', + redeemedByCount: wasRedeemed ? 1 : 0, + }, + copyMessage: withCopymessage ? 'test-copy-message' : undefined, + } + return render(Invitation, { + localVue, + propsData, + mocks: { + $t: jest.fn((v) => v), + $toast: { + success: jest.fn(), + error: jest.fn(), + }, + }, + mutations, + }) + } + + describe('when the invite code was redeemed', () => { + beforeEach(() => { + wrapper = Wrapper({ wasRedeemed: true }) + }) + + it('renders', () => { + expect(wrapper.container).toMatchSnapshot() + }) + + it('says how many times the code was redeemed', () => { + const redeemedCount = screen.getByText('invite-codes.redeemed-count') + expect(redeemedCount).toBeInTheDocument() + }) + }) + + describe('when the invite code was not redeemed', () => { + beforeEach(() => { + wrapper = Wrapper({ wasRedeemed: false }) + }) + + it('renders', () => { + expect(wrapper.container).toMatchSnapshot() + }) + + it('says it was not redeemed', () => { + const redeemedCount = screen.queryByText('invite-codes.redeemed-count-0') + expect(redeemedCount).toBeInTheDocument() + }) + }) + + describe('without copy message', () => { + beforeEach(() => { + wrapper = Wrapper({ withCopymessage: false }) + }) + + it('can copy the link', async () => { + const clipboardMock = jest.spyOn(navigator.clipboard, 'writeText').mockResolvedValue() + const copyButton = screen.getByLabelText('invite-codes.copy-code') + await fireEvent.click(copyButton) + expect(clipboardMock).toHaveBeenCalledWith( + 'http://localhost/registration?method=invite-code&inviteCode=test-invite-code', + ) + }) + }) + + describe('with copy message', () => { + beforeEach(() => { + wrapper = Wrapper({ withCopymessage: true }) + }) + + it('can copy the link with message', async () => { + const clipboardMock = jest.spyOn(navigator.clipboard, 'writeText').mockResolvedValue() + const copyButton = screen.getByLabelText('invite-codes.copy-code') + await fireEvent.click(copyButton) + expect(clipboardMock).toHaveBeenCalledWith( + 'test-copy-message http://localhost/registration?method=invite-code&inviteCode=test-invite-code', + ) + }) + }) + + describe.skip('invalidate button', () => { + beforeEach(() => { + wrapper = Wrapper({ wasRedeemed: false }) + }) + + it('opens the delete modal', async () => { + const deleteButton = screen.getByLabelText('invite-codes.invalidate') + await fireEvent.click(deleteButton) + expect(mutations['modal/SET_OPEN']).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/webapp/components/_new/features/Invitations/Invitation.vue b/webapp/components/_new/features/Invitations/Invitation.vue new file mode 100644 index 000000000..f34beeb58 --- /dev/null +++ b/webapp/components/_new/features/Invitations/Invitation.vue @@ -0,0 +1,156 @@ + + + + + diff --git a/webapp/components/_new/features/Invitations/InvitationList.spec.js b/webapp/components/_new/features/Invitations/InvitationList.spec.js new file mode 100644 index 000000000..dc60bb93f --- /dev/null +++ b/webapp/components/_new/features/Invitations/InvitationList.spec.js @@ -0,0 +1,113 @@ +import { render, screen, fireEvent } from '@testing-library/vue' +import '@testing-library/jest-dom' + +import InvitationList from './InvitationList.vue' + +const localVue = global.localVue + +Object.assign(navigator, { + clipboard: { + writeText: jest.fn(), + }, +}) + +const sampleInviteCodes = [ + { + code: 'test-invite-code-1', + comment: 'test-comment', + redeemedByCount: 0, + isValid: true, + }, + { + code: 'test-invite-code-2', + comment: 'test-comment-2', + redeemedByCount: 1, + isValid: true, + }, + { + code: 'test-invite-code-3', + comment: 'test-comment-3', + redeemedByCount: 0, + isValid: false, + }, +] + +describe('InvitationList.vue', () => { + let wrapper + + const Wrapper = ({ withInviteCodes, withCopymessage = false, limit = 3 }) => { + const propsData = { + inviteCodes: withInviteCodes ? sampleInviteCodes : [], + copyMessage: withCopymessage ? 'test-copy-message' : undefined, + } + return render(InvitationList, { + localVue, + propsData, + mocks: { + $t: jest.fn((v) => v), + $toast: { + success: jest.fn(), + error: jest.fn(), + }, + $env: { + INVITE_LINK_LIMIT: limit, + }, + }, + stubs: { + 'client-only': true, + }, + }) + } + + it('renders', () => { + wrapper = Wrapper({ withInviteCodes: true }) + expect(wrapper.container).toMatchSnapshot() + }) + + it('renders empty state', () => { + wrapper = Wrapper({ withInviteCodes: false }) + expect(wrapper.container).toMatchSnapshot() + }) + + it('does not render invalid invite codes', () => { + wrapper = Wrapper({ withInviteCodes: true }) + const invalidInviteCode = screen.queryByText('invite-codes.test-invite-code-3') + expect(invalidInviteCode).not.toBeInTheDocument() + }) + + describe('without copy message', () => { + beforeEach(() => { + wrapper = Wrapper({ withCopymessage: false, withInviteCodes: true }) + }) + + it('can copy a link', async () => { + const clipboardMock = jest.spyOn(navigator.clipboard, 'writeText').mockResolvedValue() + const copyButton = screen.getAllByLabelText('invite-codes.copy-code')[0] + await fireEvent.click(copyButton) + expect(clipboardMock).toHaveBeenCalledWith( + 'http://localhost/registration?method=invite-code&inviteCode=test-invite-code-1', + ) + }) + }) + + describe('with copy message', () => { + beforeEach(() => { + wrapper = Wrapper({ withCopymessage: true, withInviteCodes: true }) + }) + + it('can copy the link with message', async () => { + const clipboardMock = jest.spyOn(navigator.clipboard, 'writeText').mockResolvedValue() + const copyButton = screen.getAllByLabelText('invite-codes.copy-code')[0] + await fireEvent.click(copyButton) + expect(clipboardMock).toHaveBeenCalledWith( + 'test-copy-message http://localhost/registration?method=invite-code&inviteCode=test-invite-code-1', + ) + }) + }) + + it('cannot generate more than the limit of invite codes', () => { + wrapper = Wrapper({ withInviteCodes: true, limit: 2 }) + const generateButton = screen.getByLabelText('invite-codes.generate-code') + expect(generateButton).toBeDisabled() + }) +}) diff --git a/webapp/components/_new/features/Invitations/InvitationList.vue b/webapp/components/_new/features/Invitations/InvitationList.vue new file mode 100644 index 000000000..355139cd2 --- /dev/null +++ b/webapp/components/_new/features/Invitations/InvitationList.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/webapp/components/_new/features/Invitations/__snapshots__/CreateInvitation.spec.js.snap b/webapp/components/_new/features/Invitations/__snapshots__/CreateInvitation.spec.js.snap new file mode 100644 index 000000000..ac2eb0a09 --- /dev/null +++ b/webapp/components/_new/features/Invitations/__snapshots__/CreateInvitation.spec.js.snap @@ -0,0 +1,140 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CreateInvitation.vue renders 1`] = ` +
    +
    +
    + invite-codes.generate-code-explanation +
    + +
    +
    + +
    + + + +
    + + + +
    + + +
    +
    +
    +`; + +exports[`CreateInvitation.vue renders with disabled button 1`] = ` +
    +
    +
    + invite-codes.generate-code-explanation +
    + +
    +
    + +
    + + + +
    + + + +
    + + +
    +
    +
    +`; diff --git a/webapp/components/_new/features/Invitations/__snapshots__/Invitation.spec.js.snap b/webapp/components/_new/features/Invitations/__snapshots__/Invitation.spec.js.snap new file mode 100644 index 000000000..10e9e68a5 --- /dev/null +++ b/webapp/components/_new/features/Invitations/__snapshots__/Invitation.spec.js.snap @@ -0,0 +1,147 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Invitation.vue when the invite code was not redeemed renders 1`] = ` +
    +
  • +
    +
    + + test-invite-code + + + — + + + + test-comment + +
    + +
    + + + invite-codes.redeemed-count-0 + + +
    +
    + +
    + + + +
    +
  • +
    +`; + +exports[`Invitation.vue when the invite code was redeemed renders 1`] = ` +
    +
  • +
    +
    + + test-invite-code + + + — + + + + test-comment + +
    + +
    + + + invite-codes.redeemed-count + + +
    +
    + +
    + + + +
    +
  • +
    +`; diff --git a/webapp/components/_new/features/Invitations/__snapshots__/InvitationList.spec.js.snap b/webapp/components/_new/features/Invitations/__snapshots__/InvitationList.spec.js.snap new file mode 100644 index 000000000..6bad7db58 --- /dev/null +++ b/webapp/components/_new/features/Invitations/__snapshots__/InvitationList.spec.js.snap @@ -0,0 +1,296 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`InvitationList.vue renders 1`] = ` +
    +
    + + +
    +
    + invite-codes.generate-code-explanation +
    + +
    +
    + +
    + + + +
    + + + +
    + + +
    +
    +
    +
    +`; + +exports[`InvitationList.vue renders empty state 1`] = ` +
    +
    +
    + + invite-codes.no-links + +
    + +
    +
    + invite-codes.generate-code-explanation +
    + +
    +
    + +
    + + + +
    + + + +
    + + +
    +
    +
    +
    +`; diff --git a/webapp/config/index.js b/webapp/config/index.js index fb275a8ec..074cb0736 100644 --- a/webapp/config/index.js +++ b/webapp/config/index.js @@ -36,6 +36,8 @@ const options = { COOKIE_HTTPS_ONLY: process.env.COOKIE_HTTPS_ONLY || process.env.NODE_ENV === 'production', // ensure true in production if not set explicitly CATEGORIES_ACTIVE: process.env.CATEGORIES_ACTIVE === 'true' || false, BADGES_ENABLED: process.env.BADGES_ENABLED === 'true' || false, + INVITE_LINK_LIMIT: process.env.INVITE_LINK_LIMIT || 7, + NETWORK_NAME: process.env.NETWORK_NAME || 'Ocelot.social', } const CONFIG = { diff --git a/webapp/graphql/InviteCode.js b/webapp/graphql/InviteCode.js new file mode 100644 index 000000000..10981327d --- /dev/null +++ b/webapp/graphql/InviteCode.js @@ -0,0 +1,138 @@ +import gql from 'graphql-tag' + +export const validateInviteCode = () => gql` + query validateInviteCode($code: String!) { + validateInviteCode(code: $code) { + code + invitedTo { + slug + groupType + name + about + avatar { + url + } + } + generatedBy { + name + avatar { + url + } + } + isValid + } + } +` + +export const generatePersonalInviteCode = () => gql` + mutation generatePersonalInviteCode($expiresAt: String, $comment: String) { + generatePersonalInviteCode(expiresAt: $expiresAt, comment: $comment) { + code + createdAt + generatedBy { + id + name + avatar { + url + } + } + redeemedBy { + id + name + avatar { + url + } + } + redeemedByCount + expiresAt + comment + invitedTo { + groupType + name + about + avatar { + url + } + } + isValid + } + } +` + +export const generateGroupInviteCode = () => gql` + mutation generateGroupInviteCode($groupId: ID!, $expiresAt: String, $comment: String) { + generateGroupInviteCode(groupId: $groupId, expiresAt: $expiresAt, comment: $comment) { + code + createdAt + generatedBy { + id + name + avatar { + url + } + } + redeemedBy { + id + name + avatar { + url + } + } + redeemedByCount + expiresAt + comment + invitedTo { + id + groupType + name + about + avatar { + url + } + } + isValid + } + } +` + +export const invalidateInviteCode = () => gql` + mutation invalidateInviteCode($code: String!) { + invalidateInviteCode(code: $code) { + code + createdAt + generatedBy { + id + name + avatar { + url + } + } + redeemedBy { + id + name + avatar { + url + } + } + redeemedByCount + expiresAt + comment + invitedTo { + id + groupType + name + about + avatar { + url + } + } + isValid + } + } +` + +export const redeemInviteCode = () => gql` + mutation redeemInviteCode($code: String!) { + redeemInviteCode(code: $code) + } +` diff --git a/webapp/graphql/User.js b/webapp/graphql/User.js index 7440b5051..2f2a0ce65 100644 --- a/webapp/graphql/User.js +++ b/webapp/graphql/User.js @@ -406,6 +406,15 @@ export const currentUserQuery = gql` query { currentUser { ...user + inviteCodes { + code + isValid + redeemedBy { + id + } + comment + redeemedByCount + } badgeTrophiesSelected { id icon diff --git a/webapp/graphql/groups.js b/webapp/graphql/groups.js index bd17b0484..5ce33407b 100644 --- a/webapp/graphql/groups.js +++ b/webapp/graphql/groups.js @@ -195,6 +195,16 @@ export const groupQuery = (i18n) => { lat } myRole + inviteCodes { + createdAt + code + isValid + redeemedBy { + id + } + comment + redeemedByCount + } } } ` diff --git a/webapp/graphql/inviteCodes.js b/webapp/graphql/inviteCodes.js new file mode 100644 index 000000000..9ff171759 --- /dev/null +++ b/webapp/graphql/inviteCodes.js @@ -0,0 +1,19 @@ +import gql from 'graphql-tag' + +export const validateInviteCodeQuery = gql` + query ($code: String!) { + validateInviteCode(code: $code) { + invitedTo { + id + slug + groupType + } + } + } +` + +export const redeemInviteCodeMutation = gql` + mutation ($code: String!) { + redeemInviteCode(code: $code) + } +` diff --git a/webapp/locales/de.json b/webapp/locales/de.json index fb6949b7d..2cd81412c 100644 --- a/webapp/locales/de.json +++ b/webapp/locales/de.json @@ -244,7 +244,10 @@ "length": "muss genau {inviteCodeLength} Buchstaben lang sein", "success": "Gültiger Einladungs-Code {inviteCode}!" } - } + }, + "invited-by": "Eingeladen von {invitedBy}", + "invited-by-and-to": "Einladung von {invitedBy} zur Grupppe {invitedTo}", + "invited-to-hidden-group": "Eingeladen von {invitedBy} zu einer versteckten Gruppe" }, "no-public-registrstion": { "title": "Keine öffentliche Registrierung möglich" @@ -509,6 +512,7 @@ "categoriesTitle": "Themen der Gruppe", "changeMemberRole": "Die Rolle wurde auf „{role}“ geändert!", "contentMenu": { + "inviteLinks": "Einladungslinks", "muteGroup": "Stummschalten", "unmuteGroup": "Nicht stummschalten", "visitGroupPage": "Gruppe anzeigen" @@ -531,6 +535,7 @@ "goal": "Ziel der Gruppe", "groupCreated": "Die Gruppe wurde angelegt!", "in": "in", + "invite-links": "Einladungslinks", "joinLeaveButton": { "iAmMember": "Bin Mitglied", "join": "Beitreten", @@ -628,10 +633,29 @@ "button": { "tooltip": "Freunde einladen" }, + "comment-placeholder": "Kommentar (optional)", "copy-code": "Einladungslink kopieren", "copy-success": "Einladungscode erfolgreich in die Zwischenablage kopiert", - "not-available": "Du hast keinen Einladungscode zur Verfügung!", - "your-code": "Sende diesen Link per E-Mail oder in sozialen Medien, um deine Freunde einzuladen:" + "create-error": "Einladungslink konnte nicht erstellt werden: {error}", + "create-success": "Einladungslink erfolgreich erstellt!", + "delete-modal": { + "message": "Möchtest du diesen Einladungslink wirklich ungültig machen?", + "title": "Einladungslink widerrufen" + }, + "generate-code": "Neuen Einladungslink erstellen", + "generate-code-explanation": "Erstelle einen neuen Link. Wenn du möchtest, füge einen Kommentar hinzu (nur für dich sichtbar). ", + "group-invite-links": "Gruppen-Einladungslinks", + "invalidate": "Widerrufen", + "invalidate-error": "Einladungslink konnte nicht ungültig gemacht werden: {error}", + "invalidate-success": "Einladungslink erfolgreich widerrufen", + "invite-link-message-group": "Du wurdest eingeladen, der Gruppe {groupName} auf {network} beizutreten.", + "invite-link-message-hidden-group": "Du wurdest eingeladen, einer versteckten Gruppe auf {network} beizutreten.", + "invite-link-message-personal": "Du wurdest eingeladen, dem Netzwerk {network} beizutreten", + "limit-reached": "Du hast die maximale Anzahl an Einladungslinks erreicht.", + "my-invite-links": "Meine Einladungslinks", + "no-links": "Keine Links vorhanden", + "redeemed-count": "{count} mal eingelöst", + "redeemed-count-0": "Noch von niemandem eingelöst" }, "localeSwitch": { "tooltip": "Sprache wählen" diff --git a/webapp/locales/en.json b/webapp/locales/en.json index 7b769bac2..b370efd38 100644 --- a/webapp/locales/en.json +++ b/webapp/locales/en.json @@ -244,7 +244,10 @@ "length": "must be {inviteCodeLength} characters long", "success": "Valid invite code {inviteCode}!" } - } + }, + "invited-by": "Invited by {invitedBy}.", + "invited-by-and-to": "Invited by {invitedBy} to group {invitedTo}.", + "invited-to-hidden-group": "Invited by {invitedBy} to a hidden group." }, "no-public-registrstion": { "title": "No Public Registration" @@ -509,6 +512,7 @@ "categoriesTitle": "Topics of the group", "changeMemberRole": "The role has been changed to “{role}”!", "contentMenu": { + "inviteLinks": "Invite links", "muteGroup": "Mute group", "unmuteGroup": "Unmute group", "visitGroupPage": "Show group" @@ -531,6 +535,7 @@ "goal": "Goal of group", "groupCreated": "The group was created!", "in": "in", + "invite-links": "Invite Links", "joinLeaveButton": { "iAmMember": "I'm a member", "join": "Join", @@ -628,10 +633,29 @@ "button": { "tooltip": "Invite friends" }, + "comment-placeholder": "Comment (optional)", "copy-code": "Copy Invite Link", "copy-success": "Invite code copied to clipboard", - "not-available": "You have no valid invite code available!", - "your-code": "Send this link per e-mail or in social media to invite your friends:" + "create-error": "Creating a new invite link failed! Error: {error}", + "create-success": "Invite link created successfully!", + "delete-modal": { + "message": "Do you really want to invalidate this invite link?", + "title": "Invalidate link?" + }, + "generate-code": "Create new link", + "generate-code-explanation": "Create a new link. You can add a comment if you like (only visible to you).", + "group-invite-links": "Group invite links", + "invalidate": "Invalidate link", + "invalidate-error": "Invalidating the invite link failed! Error: {error}", + "invalidate-success": "Invite link invalidated successfully!", + "invite-link-message-group": "You have been invited to join the group “{groupName}” on {network}.", + "invite-link-message-hidden-group": "You have been invited to join a hidden group on {network}.", + "invite-link-message-personal": "You have been invited to join {network}.", + "limit-reached": "You have reached the maximum number of invite links.", + "my-invite-links": "My invite links", + "no-links": "No invite links created yet.", + "redeemed-count": "This code has been used {count} times.", + "redeemed-count-0": "No one has used this code yet." }, "localeSwitch": { "tooltip": "Choose language" diff --git a/webapp/locales/es.json b/webapp/locales/es.json index b7d95d11c..3c4d06403 100644 --- a/webapp/locales/es.json +++ b/webapp/locales/es.json @@ -244,7 +244,10 @@ "length": null, "success": null } - } + }, + "invited-by": null, + "invited-by-and-to": null, + "invited-to-hidden-group": null }, "no-public-registrstion": { "title": null @@ -509,6 +512,7 @@ "categoriesTitle": null, "changeMemberRole": null, "contentMenu": { + "inviteLinks": null, "muteGroup": "Silenciar grupo", "unmuteGroup": "Desactivar silencio del grupo", "visitGroupPage": null @@ -531,6 +535,7 @@ "goal": null, "groupCreated": null, "in": null, + "invite-links": null, "joinLeaveButton": { "iAmMember": null, "join": null, @@ -628,10 +633,29 @@ "button": { "tooltip": null }, + "comment-placeholder": null, "copy-code": null, "copy-success": null, - "not-available": null, - "your-code": null + "create-error": null, + "create-success": null, + "delete-modal": { + "message": null, + "title": null + }, + "generate-code": null, + "generate-code-explanation": null, + "group-invite-links": null, + "invalidate": null, + "invalidate-error": null, + "invalidate-success": null, + "invite-link-message-group": null, + "invite-link-message-hidden-group": null, + "invite-link-message-personal": null, + "limit-reached": null, + "my-invite-links": null, + "no-links": null, + "redeemed-count": null, + "redeemed-count-0": null }, "localeSwitch": { "tooltip": null diff --git a/webapp/locales/fr.json b/webapp/locales/fr.json index 37a182c28..583726c52 100644 --- a/webapp/locales/fr.json +++ b/webapp/locales/fr.json @@ -244,7 +244,10 @@ "length": null, "success": null } - } + }, + "invited-by": null, + "invited-by-and-to": null, + "invited-to-hidden-group": null }, "no-public-registrstion": { "title": null @@ -509,6 +512,7 @@ "categoriesTitle": null, "changeMemberRole": null, "contentMenu": { + "inviteLinks": null, "muteGroup": null, "unmuteGroup": null, "visitGroupPage": null @@ -531,6 +535,7 @@ "goal": null, "groupCreated": null, "in": null, + "invite-links": null, "joinLeaveButton": { "iAmMember": null, "join": null, @@ -628,10 +633,29 @@ "button": { "tooltip": null }, + "comment-placeholder": null, "copy-code": null, "copy-success": null, - "not-available": null, - "your-code": null + "create-error": null, + "create-success": null, + "delete-modal": { + "message": null, + "title": null + }, + "generate-code": null, + "generate-code-explanation": null, + "group-invite-links": null, + "invalidate": null, + "invalidate-error": null, + "invalidate-success": null, + "invite-link-message-group": null, + "invite-link-message-hidden-group": null, + "invite-link-message-personal": null, + "limit-reached": null, + "my-invite-links": null, + "no-links": null, + "redeemed-count": null, + "redeemed-count-0": null }, "localeSwitch": { "tooltip": null diff --git a/webapp/locales/it.json b/webapp/locales/it.json index 6b686502c..2896c2c1f 100644 --- a/webapp/locales/it.json +++ b/webapp/locales/it.json @@ -244,7 +244,10 @@ "length": null, "success": null } - } + }, + "invited-by": null, + "invited-by-and-to": null, + "invited-to-hidden-group": null }, "no-public-registrstion": { "title": null @@ -509,6 +512,7 @@ "categoriesTitle": null, "changeMemberRole": null, "contentMenu": { + "inviteLinks": null, "muteGroup": null, "unmuteGroup": null, "visitGroupPage": null @@ -531,6 +535,7 @@ "goal": null, "groupCreated": null, "in": null, + "invite-links": null, "joinLeaveButton": { "iAmMember": null, "join": null, @@ -628,10 +633,29 @@ "button": { "tooltip": null }, + "comment-placeholder": null, "copy-code": null, "copy-success": null, - "not-available": null, - "your-code": null + "create-error": null, + "create-success": null, + "delete-modal": { + "message": null, + "title": null + }, + "generate-code": null, + "generate-code-explanation": null, + "group-invite-links": null, + "invalidate": null, + "invalidate-error": null, + "invalidate-success": null, + "invite-link-message-group": null, + "invite-link-message-hidden-group": null, + "invite-link-message-personal": null, + "limit-reached": null, + "my-invite-links": null, + "no-links": null, + "redeemed-count": null, + "redeemed-count-0": null }, "localeSwitch": { "tooltip": null diff --git a/webapp/locales/nl.json b/webapp/locales/nl.json index 714ed2b01..e4ae47dc4 100644 --- a/webapp/locales/nl.json +++ b/webapp/locales/nl.json @@ -244,7 +244,10 @@ "length": null, "success": null } - } + }, + "invited-by": null, + "invited-by-and-to": null, + "invited-to-hidden-group": null }, "no-public-registrstion": { "title": null @@ -509,6 +512,7 @@ "categoriesTitle": null, "changeMemberRole": null, "contentMenu": { + "inviteLinks": null, "muteGroup": null, "unmuteGroup": null, "visitGroupPage": null @@ -531,6 +535,7 @@ "goal": null, "groupCreated": null, "in": null, + "invite-links": null, "joinLeaveButton": { "iAmMember": null, "join": null, @@ -628,10 +633,29 @@ "button": { "tooltip": null }, + "comment-placeholder": null, "copy-code": null, "copy-success": null, - "not-available": null, - "your-code": null + "create-error": null, + "create-success": null, + "delete-modal": { + "message": null, + "title": null + }, + "generate-code": null, + "generate-code-explanation": null, + "group-invite-links": null, + "invalidate": null, + "invalidate-error": null, + "invalidate-success": null, + "invite-link-message-group": null, + "invite-link-message-hidden-group": null, + "invite-link-message-personal": null, + "limit-reached": null, + "my-invite-links": null, + "no-links": null, + "redeemed-count": null, + "redeemed-count-0": null }, "localeSwitch": { "tooltip": null diff --git a/webapp/locales/pl.json b/webapp/locales/pl.json index 61a6acf24..7783ef93d 100644 --- a/webapp/locales/pl.json +++ b/webapp/locales/pl.json @@ -244,7 +244,10 @@ "length": null, "success": null } - } + }, + "invited-by": null, + "invited-by-and-to": null, + "invited-to-hidden-group": null }, "no-public-registrstion": { "title": null @@ -509,6 +512,7 @@ "categoriesTitle": null, "changeMemberRole": null, "contentMenu": { + "inviteLinks": null, "muteGroup": null, "unmuteGroup": null, "visitGroupPage": null @@ -531,6 +535,7 @@ "goal": null, "groupCreated": null, "in": null, + "invite-links": null, "joinLeaveButton": { "iAmMember": null, "join": null, @@ -628,10 +633,29 @@ "button": { "tooltip": null }, + "comment-placeholder": null, "copy-code": null, "copy-success": null, - "not-available": null, - "your-code": null + "create-error": null, + "create-success": null, + "delete-modal": { + "message": null, + "title": null + }, + "generate-code": null, + "generate-code-explanation": null, + "group-invite-links": null, + "invalidate": null, + "invalidate-error": null, + "invalidate-success": null, + "invite-link-message-group": null, + "invite-link-message-hidden-group": null, + "invite-link-message-personal": null, + "limit-reached": null, + "my-invite-links": null, + "no-links": null, + "redeemed-count": null, + "redeemed-count-0": null }, "localeSwitch": { "tooltip": null diff --git a/webapp/locales/pt.json b/webapp/locales/pt.json index 80172daa3..7cd7b2a4b 100644 --- a/webapp/locales/pt.json +++ b/webapp/locales/pt.json @@ -244,7 +244,10 @@ "length": null, "success": null } - } + }, + "invited-by": null, + "invited-by-and-to": null, + "invited-to-hidden-group": null }, "no-public-registrstion": { "title": null @@ -509,6 +512,7 @@ "categoriesTitle": null, "changeMemberRole": null, "contentMenu": { + "inviteLinks": null, "muteGroup": null, "unmuteGroup": null, "visitGroupPage": null @@ -531,6 +535,7 @@ "goal": null, "groupCreated": null, "in": null, + "invite-links": null, "joinLeaveButton": { "iAmMember": null, "join": null, @@ -628,10 +633,29 @@ "button": { "tooltip": null }, + "comment-placeholder": null, "copy-code": null, "copy-success": null, - "not-available": null, - "your-code": null + "create-error": null, + "create-success": null, + "delete-modal": { + "message": null, + "title": null + }, + "generate-code": null, + "generate-code-explanation": null, + "group-invite-links": null, + "invalidate": null, + "invalidate-error": null, + "invalidate-success": null, + "invite-link-message-group": null, + "invite-link-message-hidden-group": null, + "invite-link-message-personal": null, + "limit-reached": null, + "my-invite-links": null, + "no-links": null, + "redeemed-count": null, + "redeemed-count-0": null }, "localeSwitch": { "tooltip": null diff --git a/webapp/locales/ru.json b/webapp/locales/ru.json index f7956755c..eb365dcdb 100644 --- a/webapp/locales/ru.json +++ b/webapp/locales/ru.json @@ -244,7 +244,10 @@ "length": null, "success": null } - } + }, + "invited-by": null, + "invited-by-and-to": null, + "invited-to-hidden-group": null }, "no-public-registrstion": { "title": null @@ -509,6 +512,7 @@ "categoriesTitle": null, "changeMemberRole": null, "contentMenu": { + "inviteLinks": null, "muteGroup": null, "unmuteGroup": null, "visitGroupPage": null @@ -531,6 +535,7 @@ "goal": null, "groupCreated": null, "in": null, + "invite-links": null, "joinLeaveButton": { "iAmMember": null, "join": null, @@ -628,10 +633,29 @@ "button": { "tooltip": null }, + "comment-placeholder": null, "copy-code": null, "copy-success": null, - "not-available": null, - "your-code": null + "create-error": null, + "create-success": null, + "delete-modal": { + "message": null, + "title": null + }, + "generate-code": null, + "generate-code-explanation": null, + "group-invite-links": null, + "invalidate": null, + "invalidate-error": null, + "invalidate-success": null, + "invite-link-message-group": null, + "invite-link-message-hidden-group": null, + "invite-link-message-personal": null, + "limit-reached": null, + "my-invite-links": null, + "no-links": null, + "redeemed-count": null, + "redeemed-count-0": null }, "localeSwitch": { "tooltip": null diff --git a/webapp/maintenance/source/package.json b/webapp/maintenance/source/package.json index da4027f84..7e817f01f 100644 --- a/webapp/maintenance/source/package.json +++ b/webapp/maintenance/source/package.json @@ -1,6 +1,6 @@ { "name": "@ocelot-social/maintenance", - "version": "3.5.3", + "version": "3.6.0", "description": "Maintenance page for ocelot.social", "repository": "https://github.com/Ocelot-Social-Community/Ocelot-Social", "author": "ocelot.social Community", diff --git a/webapp/package.json b/webapp/package.json index de3c0cc2d..a227d72e7 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -1,6 +1,6 @@ { "name": "ocelot-social-webapp", - "version": "3.5.3", + "version": "3.6.0", "description": "ocelot.social Frontend", "repository": "https://github.com/Ocelot-Social-Community/Ocelot-Social", "author": "ocelot.social Community", diff --git a/webapp/pages/groups/_id/__snapshots__/_slug.spec.js.snap b/webapp/pages/groups/_id/__snapshots__/_slug.spec.js.snap index 68c2b50ba..9c87ae4fd 100644 --- a/webapp/pages/groups/_id/__snapshots__/_slug.spec.js.snap +++ b/webapp/pages/groups/_id/__snapshots__/_slug.spec.js.snap @@ -151,6 +151,27 @@ exports[`GroupProfileSlug given a puplic group – "yoga-practice" given a close + + + +
  • + + + + + + group.contentMenu.inviteLinks + + + +
  • @@ -3009,6 +3030,27 @@ exports[`GroupProfileSlug given a puplic group – "yoga-practice" given a curre + + + +
  • + + + + + + group.contentMenu.inviteLinks + + + +
  • @@ -6489,6 +6531,27 @@ exports[`GroupProfileSlug given a puplic group – "yoga-practice" given a hidde + + + +
  • + + + + + + group.contentMenu.inviteLinks + + + +
  • diff --git a/webapp/pages/groups/edit/_id.vue b/webapp/pages/groups/edit/_id.vue index 57c7d9f6a..66eecd364 100644 --- a/webapp/pages/groups/edit/_id.vue +++ b/webapp/pages/groups/edit/_id.vue @@ -13,7 +13,7 @@ - + @@ -39,9 +39,18 @@ export default { name: this.$t('group.members'), path: `/groups/edit/${this.group.id}/members`, }, + { + name: this.$t('group.invite-links'), + path: `/groups/edit/${this.group.id}/invites`, + }, ] }, }, + data() { + return { + group: {}, + } + }, async asyncData(context) { const { app, @@ -62,5 +71,10 @@ export default { } return { group } }, + methods: { + updateInviteCodes(inviteCodes) { + this.group.inviteCodes = inviteCodes + }, + }, } diff --git a/webapp/pages/groups/edit/_id/__snapshots__/invites.spec.js.snap b/webapp/pages/groups/edit/_id/__snapshots__/invites.spec.js.snap new file mode 100644 index 000000000..2d1155691 --- /dev/null +++ b/webapp/pages/groups/edit/_id/__snapshots__/invites.spec.js.snap @@ -0,0 +1,167 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`invites.vue renders 1`] = ` +
    +
    +
    +

    + invite-codes.group-invite-links +

    + +
    + +
    +
      + +
    • +
      +
      + + INVITE1 + + + — + + + + Test invite 1 + +
      + +
      + + + invite-codes.redeemed-count-0 + + +
      +
      + +
      + + + +
      +
    • +
      +
    + +
    +
    + invite-codes.generate-code-explanation +
    + +
    +
    + +
    + + + +
    + + + +
    + + +
    +
    +
    + + +
    +
    +
    +`; diff --git a/webapp/pages/groups/edit/_id/invites.spec.js b/webapp/pages/groups/edit/_id/invites.spec.js new file mode 100644 index 000000000..8c163a4e9 --- /dev/null +++ b/webapp/pages/groups/edit/_id/invites.spec.js @@ -0,0 +1,86 @@ +import { render, screen, fireEvent } from '@testing-library/vue' + +import invites from './invites.vue' + +const localVue = global.localVue + +describe('invites.vue', () => { + let wrapper + let mocks + + beforeEach(() => { + mocks = { + $t: jest.fn((v) => v), + $apollo: { + mutate: jest.fn(), + }, + $env: { + NETWORK_NAME: 'test-network', + INVITE_LINK_LIMIT: 5, + }, + $toast: { + success: jest.fn(), + error: jest.fn(), + }, + localVue, + } + }) + + const Wrapper = () => { + return render(invites, { + localVue, + propsData: { + group: { + id: 'group1', + name: 'Group 1', + inviteCodes: [ + { + code: 'INVITE1', + comment: 'Test invite 1', + redeemedByCount: 0, + isValid: true, + }, + { + code: 'INVITE2', + comment: 'Test invite 2', + redeemedByCount: 1, + isValid: false, + }, + ], + }, + }, + mocks, + stubs: { + 'client-only': true, + }, + }) + } + + it('renders', () => { + wrapper = Wrapper() + expect(wrapper.container).toMatchSnapshot() + }) + + describe('when a new invite code is generated', () => { + beforeEach(async () => { + wrapper = Wrapper() + const createButton = screen.getByLabelText('invite-codes.generate-code') + await fireEvent.click(createButton) + }) + + it('calls the mutation to generate a new invite code', () => { + expect(mocks.$apollo.mutate).toHaveBeenCalledWith({ + mutation: expect.anything(), + update: expect.anything(), + variables: { + groupId: 'group1', + comment: '', + }, + }) + }) + + it('shows a success message', () => { + expect(mocks.$toast.success).toHaveBeenCalledWith('invite-codes.create-success') + }) + }) +}) diff --git a/webapp/pages/groups/edit/_id/invites.vue b/webapp/pages/groups/edit/_id/invites.vue new file mode 100644 index 000000000..5181c21f1 --- /dev/null +++ b/webapp/pages/groups/edit/_id/invites.vue @@ -0,0 +1,81 @@ + + + diff --git a/webapp/pages/login.spec.js b/webapp/pages/login.spec.js index 31ff2b489..6f54b2693 100644 --- a/webapp/pages/login.spec.js +++ b/webapp/pages/login.spec.js @@ -1,14 +1,20 @@ import Vuex from 'vuex' import { mount } from '@vue/test-utils' import login from './login.vue' +import LoginForm from '~/components/LoginForm/LoginForm.vue' const localVue = global.localVue const stubs = { 'client-only': true, 'nuxt-link': true, + 'router-link': true, } +const routerPushMock = jest.fn() +const routerReplaceMock = jest.fn() +const i18nSetMock = jest.fn() + describe('Login.vue', () => { let store let mocks @@ -22,6 +28,14 @@ describe('Login.vue', () => { $t: jest.fn(), $i18n: { locale: () => 'en', + set: i18nSetMock, + }, + $route: { + query: {}, + }, + $router: { + replace: routerReplaceMock, + push: routerPushMock, }, } asyncData = false @@ -73,5 +87,51 @@ describe('Login.vue', () => { wrapper = await Wrapper() expect(redirect).toHaveBeenCalledWith('/') }) + + describe('handle succcess', () => { + beforeEach(async () => { + asyncData = true + tosVersion = '0.0.4' + }) + + describe('with route query to invite code', () => { + beforeEach(async () => { + mocks.$route.query = { + inviteCode: 'ABCDEF', + } + wrapper = await Wrapper() + wrapper.findComponent(LoginForm).vm.$emit('success') + }) + + it('calls i18n.set', () => { + expect(i18nSetMock).toBeCalledWith('en') + }) + + it('call router push to registration page', () => { + expect(routerPushMock).toBeCalledWith({ + name: 'registration', + query: { + inviteCode: 'ABCDEF', + }, + }) + }) + }) + + describe('without route query to invite code', () => { + beforeEach(async () => { + mocks.$route.query = {} + wrapper = await Wrapper() + wrapper.findComponent(LoginForm).vm.$emit('success') + }) + + it('calls i18n.set', () => { + expect(i18nSetMock).toBeCalledWith('en') + }) + + it('call router push to registration page', () => { + expect(routerReplaceMock).toBeCalledWith('/') + }) + }) + }) }) }) diff --git a/webapp/pages/login.vue b/webapp/pages/login.vue index 6445679df..ca3fcf048 100644 --- a/webapp/pages/login.vue +++ b/webapp/pages/login.vue @@ -32,7 +32,14 @@ export default { this.$i18n.set(this.user.locale || 'en') try { - await this.$router.replace(this.$route.query.path || '/') + if (this.$route.query.inviteCode) { + this.$router.push({ + name: 'registration', + query: this.$route.query, + }) + } else { + await this.$router.replace(this.$route.query.path || '/') + } } catch (err) { // throw new Error(`Problem handling something: ${err}.`); // TODO this is causing trouble - most likely due to double redirect on terms&conditions diff --git a/webapp/pages/registration.spec.js b/webapp/pages/registration.spec.js index 8be08fa5e..578808ed9 100644 --- a/webapp/pages/registration.spec.js +++ b/webapp/pages/registration.spec.js @@ -2,6 +2,7 @@ import Vuex from 'vuex' import { mount } from '@vue/test-utils' import Registration from './registration.vue' import Vue from 'vue' +import { validateInviteCodeQuery, redeemInviteCodeMutation } from '~/graphql/inviteCodes' const localVue = global.localVue @@ -12,6 +13,18 @@ const stubs = { 'infinite-loading': true, } +const queryMock = jest.fn() +const mutationMock = jest.fn() + +const app = { + apolloProvider: { + defaultClient: { + query: queryMock, + mutate: mutationMock, + }, + }, +} + describe('Registration', () => { let wrapper let Wrapper @@ -20,6 +33,7 @@ describe('Registration', () => { let store let redirect let isLoggedIn + let route beforeEach(() => { mocks = { @@ -39,6 +53,9 @@ describe('Registration', () => { asyncData = false isLoggedIn = false redirect = jest.fn() + route = { + query: {}, + } }) describe('mount', () => { @@ -65,6 +82,8 @@ describe('Registration', () => { const aData = await Registration.asyncData({ store, redirect, + route, + app, }) Registration.data = function () { return { ...data, ...aData } @@ -330,6 +349,181 @@ describe('Registration', () => { expect(redirect).toHaveBeenCalledWith('/') }) + describe('already logged in', () => { + beforeEach(async () => { + asyncData = true + isLoggedIn = true + }) + + describe('route contains personal invite code', () => { + beforeEach(async () => { + jest.clearAllMocks() + queryMock.mockResolvedValue({ + data: { + validateInviteCode: { + invitedTo: null, + }, + }, + }) + route.query.inviteCode = 'ABCDEF' + wrapper = await Wrapper() + }) + + it('calls validate invite code', () => { + expect(queryMock).toHaveBeenCalledWith({ + query: validateInviteCodeQuery, + variables: { + code: 'ABCDEF', + }, + }) + }) + + it('redirects to index', () => { + expect(redirect).toHaveBeenCalledWith('/') + }) + + it('does not redeem the link', () => { + expect(mutationMock).not.toBeCalled() + }) + }) + + describe('route contains group invite code to public group', () => { + beforeEach(async () => { + jest.clearAllMocks() + queryMock.mockResolvedValue({ + data: { + validateInviteCode: { + invitedTo: { + id: 'public-group', + slug: 'public-group', + groupType: 'public', + }, + }, + }, + }) + mutationMock.mockResolvedValue({ + data: { + redeemInviteCode: true, + }, + }) + route.query.inviteCode = 'ABCDEF' + wrapper = await Wrapper() + }) + + it('calls validate invite code', () => { + expect(queryMock).toHaveBeenCalledWith({ + query: validateInviteCodeQuery, + variables: { + code: 'ABCDEF', + }, + }) + }) + + it('redirects to group', () => { + expect(redirect).toHaveBeenCalledWith('/groups/public-group/public-group') + }) + + it('redeems the code', () => { + expect(mutationMock).toBeCalledWith({ + mutation: redeemInviteCodeMutation, + variables: { + code: 'ABCDEF', + }, + }) + }) + }) + + describe('route contains group invite code to closed group', () => { + beforeEach(async () => { + jest.clearAllMocks() + queryMock.mockResolvedValue({ + data: { + validateInviteCode: { + invitedTo: { + id: 'closed-group', + slug: 'closed-group', + groupType: 'closed', + }, + }, + }, + }) + mutationMock.mockResolvedValue({ + data: { + redeemInviteCode: true, + }, + }) + route.query.inviteCode = 'ABCDEF' + wrapper = await Wrapper() + }) + + it('calls validate invite code', () => { + expect(queryMock).toHaveBeenCalledWith({ + query: validateInviteCodeQuery, + variables: { + code: 'ABCDEF', + }, + }) + }) + + it('redirects to index', () => { + expect(redirect).toHaveBeenCalledWith('/') + }) + + it('redeems the code', () => { + expect(mutationMock).toBeCalledWith({ + mutation: redeemInviteCodeMutation, + variables: { + code: 'ABCDEF', + }, + }) + }) + }) + + describe('route contains group invite code to public group, but redeem throws', () => { + beforeEach(async () => { + jest.clearAllMocks() + queryMock.mockResolvedValue({ + data: { + validateInviteCode: { + invitedTo: { + id: 'public-group', + slug: 'public-group', + groupType: 'public', + }, + }, + }, + }) + mutationMock.mockRejectedValue({ + error: 'Aua!', + }) + route.query.inviteCode = 'ABCDEF' + wrapper = await Wrapper() + }) + + it('calls validate invite code', () => { + expect(queryMock).toHaveBeenCalledWith({ + query: validateInviteCodeQuery, + variables: { + code: 'ABCDEF', + }, + }) + }) + + it('redirects to index', () => { + expect(redirect).toHaveBeenCalledWith('/') + }) + + it('redeems the code', () => { + expect(mutationMock).toBeCalledWith({ + mutation: redeemInviteCodeMutation, + variables: { + code: 'ABCDEF', + }, + }) + }) + }) + }) + // copied from webapp/components/Registration/Signup.spec.js as testing template // describe('with invitation code', () => { // let action diff --git a/webapp/pages/registration.vue b/webapp/pages/registration.vue index c62d28c06..af0eb30e7 100644 --- a/webapp/pages/registration.vue +++ b/webapp/pages/registration.vue @@ -11,6 +11,7 @@