feat(package/ui): os-modal & webapp integration (#9375)

This commit is contained in:
Ulf Gebhardt 2026-03-13 03:30:54 +01:00 committed by GitHub
parent f5b5c6d306
commit 237798b0f0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
74 changed files with 3590 additions and 3433 deletions

View File

@ -2,7 +2,7 @@ import { defineStep } from '@badeball/cypress-cucumber-preprocessor'
defineStep(/^I confirm the reporting dialog .*:$/, message => {
cy.contains(message) // wait for element to become visible
cy.get('.ds-modal')
cy.get('.os-modal')
.within(() => {
cy.get('.ds-radio-option-label')
.first()

View File

@ -14,7 +14,7 @@ Phase 3: OsButton ██████████ 100% (133/133 Buttons) ✅
Phase 4: Tier 1 ██████████ 100% (OsButton, OsIcon, OsSpinner, OsCard) ✅
Phase 4: Tier A → HTML ██████████ 100% (10 ds-* Wrapper → Plain HTML) ✅
Phase 4: Tier B ████████░░ 80% (ds-chip→OsBadge✅, ds-tag→OsBadge✅, ds-grid✅, ds-number→OsNumber✅, ds-radio⬜)
Phase 4: Tier B+ ████████░░ 75% (ds-table→HTML✅) | Tier 2-3 ausstehend (OsModal, OsInput, OsMenu, OsSelect)
Phase 4: Tier B+ ████████░░ 75% (ds-table→HTML✅) | Tier 2 begonnen (OsModal✅) | Rest ausstehend (OsInput, OsMenu, OsSelect)
```
### Statistiken
@ -87,7 +87,7 @@ Phase 4: Tier B+ ████████░░ 75% (ds-table→HTML✅)
| 24 | FlexItem | ✅ → HTML | Tier A: Plain HTML + CSS @media Queries |
| 25 | Grid | ✅ → HTML | 2 Dateien → CSS Grid (class="ds-grid") |
| 26 | GridItem | ✅ → HTML | 8 Dateien → CSS Grid |
| 27 | Modal | ⬜ Tier 2 | 7 Dateien → OsModal |
| 27 | Modal | ✅ Tier 2 | → OsModal (h() Render, Focus-Trap, Scroll-Lock, A11y; ConfirmModal + ReportModal nutzen OsModal) |
| 28 | Page | ⬜ Nicht genutzt | Nicht direkt in Webapp verwendet |
| 29 | PageTitle | ⬜ Nicht genutzt | Nicht direkt in Webapp verwendet |
| 30 | Section | ✅ → HTML | Tier A: `<section class="ds-section">` |
@ -303,7 +303,7 @@ Phase 4: Tier B+ ████████░░ 75% (ds-table→HTML✅)
| Webapp | Styleguide | Aktion | Status |
|--------|------------|--------|--------|
| Logo | Logo | Konsolidieren zu OsLogo | ⬜ Ausstehend |
| Modal | Modal | Konsolidieren zu OsModal | ⬜ Ausstehend |
| Modal | Modal | Konsolidieren zu OsModal | ✅ Erledigt |
| ~~BaseCard~~ | Card | → OsCard | ✅ Erledigt (BaseCard gelöscht) |
| ~~BaseIcon~~ | Icon | → OsIcon | ✅ Erledigt (BaseIcon gelöscht) |
| ~~LoadingSpinner~~ | Spinner | → OsSpinner | ✅ Erledigt (LoadingSpinner gelöscht) |
@ -330,16 +330,16 @@ Phase 4: Tier B+ ████████░░ 75% (ds-table→HTML✅)
| CtaJoinLeaveGroup | ✅ Nutzt OsButton | Feature-spezifisch |
| CtaUnblockAuthor | ✅ Nutzt OsButton | Feature-spezifisch |
### Modal-Familie (zur Konsolidierung)
| Komponente | Beschreibung | Ziel |
|------------|--------------|------|
| Modal (Styleguide) | Basis-Modal | OsModal |
| Modal (Webapp) | Basis-Modal | → OsModal |
| ConfirmModal | Bestätigungs-Dialog | → OsModal type="confirm" |
| DeleteUserModal | Löschen-Dialog | → OsModal type="confirm" |
| DisableModal | Deaktivieren-Dialog | → OsModal type="confirm" |
| ReleaseModal | Release-Dialog | Feature-spezifisch |
| ReportModal | Report-Dialog | Feature-spezifisch |
### Modal-Familie ✅ (alle nutzen OsModal)
| Komponente | Status | Notizen |
|------------|--------|---------|
| ~~Modal (Styleguide)~~ | ✅ Ersetzt | → OsModal |
| ~~Modal (Webapp)~~ | ✅ Ersetzt | → OsModal |
| ConfirmModal | ✅ Nutzt OsModal | Generischer Confirm-Dialog mit Callbacks |
| ~~DeleteUserModal~~ | ✅ Gelöscht | → ConfirmModal |
| ~~DisableModal~~ | ✅ Gelöscht | → ConfirmModal |
| ~~ReleaseModal~~ | ✅ Gelöscht | → ConfirmModal |
| ReportModal | ✅ Nutzt OsModal | Feature-spezifisch |
### Menu-Familie (zur Konsolidierung)
| Komponente | Beschreibung | Ziel |
@ -365,7 +365,7 @@ Phase 4: Tier B+ ████████░░ 75% (ds-table→HTML✅)
- ~~Spinner → OsSpinner~~
### Basis-Komponenten — UI-Library (ausstehend)
- Modal → OsModal
- Modal → OsModal
- Input → OsInput
- Select → OsSelect
- Avatar → OsAvatar (falls benötigt)
@ -441,8 +441,8 @@ Phase 4: Tier B+ ████████░░ 75% (ds-table→HTML✅)
17. [ ] ds-number (5 Dateien) → `<div class="ds-number">`
18. [ ] ds-radio (1 Datei) → native `<input type="radio">`
### Phase 4: Tier 2-4 — UI-Library (ausstehend)
18. [ ] OsModal (7 Dateien)
### Phase 4: Tier 2-4 — UI-Library
18. [x] OsModal (h() Render, Focus-Trap, Scroll-Lock, A11y; ConfirmModal + ReportModal nutzen OsModal; DeleteUserModal/DisableModal/ReleaseModal gelöscht) ✅
19. [ ] OsInput (23 Dateien, gekoppelt mit ds-form)
20. [ ] OsMenu / OsMenuItem (17 Dateien)
21. [ ] OsSelect (3 Dateien), OsTable (7 Dateien)
@ -450,7 +450,7 @@ Phase 4: Tier B+ ████████░░ 75% (ds-table→HTML✅)
---
**✅ Phase 0-3 abgeschlossen. Phase 4: Tier 1 + Tier A ✅, Tier B 60% (Chip→OsBadge, Tag→OsBadge, Grid→HTML), Tier 2-4 ausstehend.**
**✅ Phase 0-3 abgeschlossen. Phase 4: Tier 1 + Tier A ✅, Tier B 80% (Chip→OsBadge, Tag→OsBadge, Grid→HTML, Number→OsNumber, Table→HTML), Tier 2: OsModal ✅, Rest ausstehend.**
---
@ -684,36 +684,29 @@ interface OsButtonProps {
---
### Feature-Modals (nutzen DsModal)
### Feature-Modals ✅ (nutzen OsModal)
| Modal | Business-Logik | Migration |
|-------|----------------|-----------|
| **ConfirmModal** | Generischer Confirm-Dialog mit Callbacks | Bleibt, nutzt OsModal |
| **DisableModal** | GraphQL Mutation für Disable | Bleibt, nutzt OsModal |
| **ReportModal** | Report-Logik | Bleibt, nutzt OsModal |
| **DeleteUserModal** | User-Lösch-Logik | Bleibt, nutzt OsModal |
| **ReleaseModal** | Release-Logik | Bleibt, nutzt OsModal |
| Modal | Status | Notizen |
|-------|--------|---------|
| **ConfirmModal** | ✅ Nutzt OsModal | Generischer Confirm-Dialog mit Callbacks, Success-Animation |
| ~~**DisableModal**~~ | ✅ Gelöscht | → ConfirmModal (inline modalData) |
| **ReportModal** | ✅ Nutzt OsModal | Report-Logik mit Radio-Auswahl |
| ~~**DeleteUserModal**~~ | ✅ Gelöscht | → ConfirmModal (inline modalData) |
| ~~**ReleaseModal**~~ | ✅ Gelöscht | → ConfirmModal (inline modalData) |
**Erkenntnis:** Alle Feature-Modals nutzen bereits DsModal als Basis und fügen nur spezifische Business-Logik hinzu. Dies ist das gewünschte Pattern!
**Ergebnis:** DsModal vollständig durch OsModal ersetzt. Vuex Modal Store entfernt — Modals werden inline gerendert (v-if + showConfirmModal). DeleteUserModal, DisableModal, ReleaseModal wurden in ConfirmModal konsolidiert.
---
### Webapp Modal.vue (Modal-Manager)
### ~~Webapp Modal.vue (Modal-Manager)~~ ✅ Gelöscht
**Pfad:** `webapp/components/Modal.vue`
**Vuex Modal Store (`store/modal.js`) und Modal-Router (`components/Modal.vue`) wurden entfernt.**
**Funktion:** Kein UI-Modal, sondern ein **State-basierter Modal-Router**:
- Liest `modal/open` und `modal/data` aus Vuex
- Rendert das passende Feature-Modal basierend auf State
- Leitet Props an Feature-Modals weiter
**Mögliche Migration:**
- Als `OsModalManager` beibehalten oder
- Durch Vue 3 `<Teleport>` + Composable ersetzen
Modals werden jetzt inline gerendert: Jede Komponente hat eigenes `showConfirmModal`/`currentModalData` State und rendert `<confirm-modal v-if="showConfirmModal">` direkt.
---
### Konsolidierungsvorschlag: OsModal
### ~~Konsolidierungsvorschlag~~ OsModal ✅ (implementiert)
```typescript
interface OsModalProps {
@ -964,14 +957,14 @@ interface OsDropdownProps {
| — | ds-number | 5 | `<div class="ds-number">` | ⬜ |
| — | ds-radio | 1 | native `<input type="radio">` | ⬜ |
### Tier 2: Layout & Feedback (ausstehend)
### Tier 2: Layout & Feedback
| # | Komponente | Dateien | Abhängigkeiten |
|---|------------|---------|----------------|
| 5 | **OsModal** | 7 | OsButton, OsCard |
| 6 | **OsDropdown** | — | OsButton |
| 7 | **OsAvatar** | — | - |
| 8 | **OsInput** | 23 | gekoppelt mit ds-form (18 Dateien) |
| # | Komponente | Dateien | Abhängigkeiten | Status |
|---|------------|---------|----------------|--------|
| 5 | **OsModal** | 7 | OsButton | ✅ |
| 6 | **OsDropdown** | — | OsButton | ⬜ |
| 7 | **OsAvatar** | — | - | ⬜ |
| 8 | **OsInput** | 23 | gekoppelt mit ds-form (18 Dateien) | ⬜ |
### Tier 3: Navigation (ausstehend)

View File

@ -81,10 +81,10 @@ Phase 0: ██████████ 100% (6/6 Aufgaben) ✅
Phase 1: ██████████ 100% (6/6 Aufgaben) ✅
Phase 2: ██████████ 100% (26/26 Aufgaben) ✅
Phase 3: ██████████ 100% (24/24 Aufgaben) ✅ - Webapp-Integration komplett
Phase 4: ██████░░░░ 63% (17/27 Aufgaben) - Tier 1 ✅, Tier A ✅, Infra ✅, OsBadge ✅, ds-grid ✅, ds-table→HTML ✅, OsNumber ✅ | Tier B (rest), Tier 2-3 ausstehend
Phase 4: ███████░░░ 67% (18/27 Aufgaben) - Tier 1 ✅, Tier A ✅, Infra ✅, OsBadge ✅, ds-grid ✅, ds-table→HTML ✅, OsNumber ✅, OsModal ✅ | Tier B (rest), Tier 2-3 ausstehend
Phase 5: ░░░░░░░░░░ 0% (0/7 Aufgaben)
───────────────────────────────────────
Gesamt: ████████░░ 82% (79/96 Aufgaben)
Gesamt: ████████░░ 83% (80/96 Aufgaben)
```
### Katalogisierung (Details in KATALOG.md)
@ -405,7 +405,8 @@ ds-chip + ds-tag → OsBadge (UI-Library): ✅
- [x] OsBadge Komponente + ds-chip/ds-tag → OsBadge Webapp-Migration ✅
- [x] OsNumber Komponente + ds-number/CountTo → OsNumber Webapp-Migration ✅
- [ ] Tier B (Rest): ds-radio → Plain HTML
- [ ] Weitere Tier 2 Komponenten (OsModal, OsDropdown, OsAvatar, OsInput)
- [x] OsModal Komponente + DsModal/ConfirmModal/ReportModal → OsModal Webapp-Integration ✅
- [ ] Weitere Tier 2 Komponenten (OsDropdown, OsAvatar, OsInput)
- [ ] ds-form + ds-input → OsForm + OsInput (stark gekoppelt, 18+23 Dateien)
- [ ] ds-menu / ds-menu-item → OsMenu / OsMenuItem
- [ ] ds-select → OsSelect
@ -680,7 +681,7 @@ Jeder migrierte Button muss manuell geprüft werden: Normal, Hover, Focus, Activ
- [ ] ds-radio (1 Datei) → native `<input type="radio">`
**Tier 2: Layout & Feedback (UI-Library)**
- [ ] OsModal (Basis: DsModal, 7 Dateien)
- [x] OsModal (Basis: DsModal → h() Render-Function, Vue 2/3 Compat, Focus-Trap, Scroll-Lock, A11y) ✅
- [ ] OsDropdown (Basis: Webapp Dropdown)
- [ ] OsAvatar (vereint DsAvatar + ProfileAvatar)
- [ ] OsInput (Basis: DsInput, 23 Dateien — gekoppelt mit ds-form)
@ -1831,6 +1832,13 @@ Bei der Migration werden:
| 2026-02-20 | **CountTo.vue gelöscht** | vue-count-to Dependency entfernt, followedByCountStartValue/membersCountStartValue Pattern entfernt |
| 2026-02-20 | **CSS-Variable --color-text-soft** | Neuer Contract-Eintrag in tailwind.preset.ts + ocelot-ui-variables.scss (Label-Farbe) |
| 2026-02-20 | **Admin-Label uppercase** | `.admin-stats__item .os-number-label { text-transform: uppercase }` per CSS statt neuem Prop |
| 2026-03-13 | **OsModal Komponente** | Neue Tier 2 Komponente: h() Render-Function, Vue 2/3 via vue-demi, Focus-Trap, Body Scroll-Lock, ESC-Key, Backdrop-Click, A11y (role=dialog, aria-modal, aria-labelledby/aria-label), 37 Unit-Tests, 5 Visual Tests, 100% Coverage |
| 2026-03-13 | **OsModal Features** | open Prop (v-model:open), title, cancelLabel/confirmLabel, ariaLabel Fallback, footer Scoped-Slot ({confirm, cancel}), Scroll-Fade (top gradient), tabindex=0 auf scrollbarem Content |
| 2026-03-13 | **OsModal Events** | update:open, confirm, cancel, close (mit Typ: 'confirm'/'cancel'/'close'/'backdrop'), opened |
| 2026-03-13 | **OsModal Vue 2 Compat** | attrs Forwarding in beiden Vue 2 Branches (closed + open), eventProps() Helper, $listeners Forwarding, $createElement für Icons |
| 2026-03-13 | **Modal Webapp-Integration** | ConfirmModal + ReportModal nutzen OsModal; Vuex Modal Store entfernt; Modals inline gerendert |
| 2026-03-13 | **Modal Bugfixes** | z-index Stacking Context Fix (PostTeaser/GroupTeaser), Callback-Promise Propagation (ReportList, MySomethingList), Group Leave Authorization Fix ($nextTick), Cypress .ds-modal → .os-modal |
| 2026-03-13 | **Modal A11y** | scrollable-region-focusable Fix (tabindex=0), aria-label Fallback wenn kein Title, body overflow save/restore |
---
@ -1848,11 +1856,11 @@ Bei der Migration werden:
**Styleguide-Migration:**
| Status | Komponenten |
|--------|------------|
| ✅ UI-Library | OsButton, OsIcon, OsSpinner, OsCard, OsBadge, OsNumber (6) |
| ✅ UI-Library | OsButton, OsIcon, OsSpinner, OsCard, OsBadge, OsNumber, OsModal (7) |
| ✅ → Plain HTML | Section, Placeholder, List, ListItem, Container, Heading, Text, Space, Flex, FlexItem, Grid, GridItem, Table (13) — Tier A/B |
| ✅ → UI-Library | Chip, Tag → OsBadge (2), Number → OsNumber (1) — Tier B |
| ⬜ → Plain HTML | Radio (1) — Tier B |
| ⬜ → UI-Library | Modal, Input, Menu, MenuItem, Select (5) — Tier 2-3 |
| ⬜ → UI-Library | Input, Menu, MenuItem, Select (4) — Tier 2-3 |
| ⬜ Nicht genutzt | Code, CopyField, FormItem, InputError, InputLabel, Page, PageTitle, Logo, Avatar, TableCol, TableHeadCol (11) |
| ⬜ Offen | Form (18 Dateien — HTML `<form>` oder OsForm?) |
@ -2932,7 +2940,7 @@ Jede Komponente durchläuft:
| OsSpinner | Niedrig | Nur Animation + Größen |
| OsButton | Hoch | Viele Varianten, Link-Support, States |
| OsCard | Niedrig | Einfaches Layout |
| OsModal | Hoch | Teleport, Focus-Trap, Animations, A11y |
| OsModal | Hoch ✅ | Focus-Trap, Scroll-Lock, Body Overflow Save/Restore, Vue 2/3 h() Compat, A11y (dialog, aria-modal, aria-labelledby), ESC-Key, Backdrop-Click, Scrollable Content tabindex |
| OsDropdown | Hoch | Positioning, Click-Outside, Hover-States |
| OsInput | Mittel | Validierung, States, Icons |
| OsAvatar | Niedrig | Bild + Fallback |

View File

@ -0,0 +1,425 @@
import { mount } from '@vue/test-utils'
import { afterEach, describe, expect, it } from 'vitest'
import OsModal from './OsModal.vue'
describe('osModal', () => {
afterEach(() => {
document.body.style.overflow = ''
})
describe('rendering', () => {
it('renders an empty wrapper when closed', () => {
const wrapper = mount(OsModal)
expect(wrapper.find('.os-modal-wrapper').exists()).toBe(true)
expect(wrapper.find('.os-modal').exists()).toBe(false)
})
it('renders the modal when open', () => {
const wrapper = mount(OsModal, {
props: { open: true },
})
expect(wrapper.find('.os-modal').exists()).toBe(true)
})
it('renders backdrop when open', () => {
const wrapper = mount(OsModal, {
props: { open: true },
})
expect(wrapper.find('.os-modal__backdrop').exists()).toBe(true)
})
it('renders title in header', () => {
const wrapper = mount(OsModal, {
props: { open: true, title: 'Test Title' },
})
expect(wrapper.find('.os-modal__title').text()).toBe('Test Title')
})
it('does not render title element when title is null', () => {
const wrapper = mount(OsModal, {
props: { open: true },
})
expect(wrapper.find('.os-modal__title').exists()).toBe(false)
})
it('renders default slot content', () => {
const wrapper = mount(OsModal, {
props: { open: true },
slots: { default: '<p>Modal body</p>' },
})
expect(wrapper.find('.os-modal__content').text()).toBe('Modal body')
})
it('renders close button', () => {
const wrapper = mount(OsModal, {
props: { open: true },
})
expect(wrapper.find('[data-testid="os-modal-close"]').exists()).toBe(true)
})
})
describe('scroll fade', () => {
it('does not show top fade when content is not scrolled', () => {
const wrapper = mount(OsModal, {
props: { open: true },
slots: { default: '<div style="height: 2000px">tall content</div>' },
})
expect(wrapper.find('.os-modal__header .bg-gradient-to-b').exists()).toBe(false)
})
it('shows top fade after content is scrolled down', async () => {
const wrapper = mount(OsModal, {
props: { open: true },
slots: { default: '<div style="height: 2000px">tall content</div>' },
})
const content = wrapper.find('.os-modal__content')
Object.defineProperty(content.element, 'scrollTop', { value: 50, writable: true })
await content.trigger('scroll')
expect(wrapper.find('.os-modal__header .bg-gradient-to-b').exists()).toBe(true)
})
})
describe('css', () => {
it('has os-modal-wrapper class on root', () => {
const wrapper = mount(OsModal)
expect(wrapper.classes()).toContain('os-modal-wrapper')
})
it('merges custom classes', () => {
const wrapper = mount(OsModal, {
attrs: { class: 'my-custom-modal' },
})
expect(wrapper.classes()).toContain('os-modal-wrapper')
expect(wrapper.classes()).toContain('my-custom-modal')
})
})
describe('panel width', () => {
it('applies max-w-[500px]', () => {
const wrapper = mount(OsModal, {
props: { open: true },
})
expect(wrapper.find('.os-modal').classes()).toContain('max-w-[500px]')
})
})
describe('built-in footer buttons', () => {
it('renders cancel and confirm buttons by default', () => {
const wrapper = mount(OsModal, {
props: { open: true },
})
expect(wrapper.find('[data-testid="os-modal-cancel"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="os-modal-confirm"]').exists()).toBe(true)
})
it('uses custom cancelLabel', () => {
const wrapper = mount(OsModal, {
props: { open: true, cancelLabel: 'Abbrechen' },
})
expect(wrapper.find('[data-testid="os-modal-cancel"]').text()).toBe('Abbrechen')
})
it('uses custom confirmLabel', () => {
const wrapper = mount(OsModal, {
props: { open: true, confirmLabel: 'Bestätigen' },
})
expect(wrapper.find('[data-testid="os-modal-confirm"]').text()).toBe('Bestätigen')
})
it('cancel button has ghost appearance', () => {
const wrapper = mount(OsModal, {
props: { open: true },
})
expect(wrapper.find('[data-testid="os-modal-cancel"]').attributes('data-appearance')).toBe(
'ghost',
)
})
it('confirm button has primary variant', () => {
const wrapper = mount(OsModal, {
props: { open: true },
})
expect(wrapper.find('[data-testid="os-modal-confirm"]').attributes('data-variant')).toBe(
'primary',
)
})
})
describe('custom footer slot', () => {
it('renders custom footer instead of built-in buttons', () => {
const wrapper = mount(OsModal, {
props: { open: true },
slots: {
footer: '<button class="custom-btn">Custom</button>',
},
})
expect(wrapper.find('.custom-btn').exists()).toBe(true)
expect(wrapper.find('[data-testid="os-modal-cancel"]').exists()).toBe(false)
expect(wrapper.find('[data-testid="os-modal-confirm"]').exists()).toBe(false)
})
it('provides confirm and cancel functions to scoped slot', () => {
const wrapper = mount(OsModal, {
props: { open: true },
slots: {
footer: `<template #footer="{ confirm, cancel }">
<button class="slot-confirm" @click="confirm">OK</button>
<button class="slot-cancel" @click="cancel">Nope</button>
</template>`,
},
})
expect(wrapper.find('.os-modal__footer').exists()).toBe(true)
})
})
describe('events', () => {
it('emits update:open false and close on cancel button click', async () => {
const wrapper = mount(OsModal, {
props: { open: true },
})
await wrapper.find('[data-testid="os-modal-cancel"]').trigger('click')
expect(wrapper.emitted('update:open')).toStrictEqual([[false]])
expect(wrapper.emitted('cancel')).toHaveLength(1)
expect(wrapper.emitted('close')).toStrictEqual([['cancel']])
})
it('emits update:open false and close on confirm button click', async () => {
const wrapper = mount(OsModal, {
props: { open: true },
})
await wrapper.find('[data-testid="os-modal-confirm"]').trigger('click')
expect(wrapper.emitted('update:open')).toStrictEqual([[false]])
expect(wrapper.emitted('confirm')).toHaveLength(1)
expect(wrapper.emitted('close')).toStrictEqual([['confirm']])
})
it('emits cancel on close button click with type "close"', async () => {
const wrapper = mount(OsModal, {
props: { open: true },
})
await wrapper.find('[data-testid="os-modal-close"]').trigger('click')
expect(wrapper.emitted('cancel')).toHaveLength(1)
expect(wrapper.emitted('close')).toStrictEqual([['close']])
})
it('emits cancel on backdrop click', async () => {
const wrapper = mount(OsModal, {
props: { open: true },
})
await wrapper.find('.os-modal__overlay').trigger('click')
expect(wrapper.emitted('cancel')).toHaveLength(1)
expect(wrapper.emitted('close')).toStrictEqual([['backdrop']])
})
it('emits opened when open becomes true', async () => {
const wrapper = mount(OsModal, {
props: { open: false },
})
await wrapper.setProps({ open: true })
expect(wrapper.emitted('opened')).toHaveLength(1)
})
})
describe('esc key', () => {
it('closes modal on ESC key press', () => {
const wrapper = mount(OsModal, {
props: { open: true },
attachTo: document.body,
})
const event = new KeyboardEvent('keydown', { key: 'Escape' })
document.dispatchEvent(event)
expect(wrapper.emitted('cancel')).toHaveLength(1)
expect(wrapper.emitted('close')).toStrictEqual([['backdrop']])
wrapper.unmount()
})
it('does not close on ESC when closed', () => {
const wrapper = mount(OsModal, {
props: { open: false },
attachTo: document.body,
})
const event = new KeyboardEvent('keydown', { key: 'Escape' })
document.dispatchEvent(event)
expect(wrapper.emitted('cancel')).toBeUndefined()
wrapper.unmount()
})
})
describe('body scroll lock', () => {
it('sets body overflow hidden when open', () => {
mount(OsModal, {
props: { open: true },
})
expect(document.body.style.overflow).toBe('hidden')
})
it('removes body overflow hidden when closed', async () => {
const wrapper = mount(OsModal, {
props: { open: true },
})
await wrapper.setProps({ open: false })
expect(document.body.style.overflow).toBe('')
})
it('cleans up body overflow on unmount', () => {
const wrapper = mount(OsModal, {
props: { open: true },
})
expect(document.body.style.overflow).toBe('hidden')
wrapper.unmount()
expect(document.body.style.overflow).toBe('')
})
it('restores previous body overflow value when closed', async () => {
document.body.style.overflow = 'auto'
const wrapper = mount(OsModal, {
props: { open: true },
})
expect(document.body.style.overflow).toBe('hidden')
await wrapper.setProps({ open: false })
expect(document.body.style.overflow).toBe('auto')
})
})
describe('aria attributes', () => {
it('has role="dialog" on the panel', () => {
const wrapper = mount(OsModal, {
props: { open: true },
})
expect(wrapper.find('[data-testid="os-modal-panel"]').attributes('role')).toBe('dialog')
})
it('has aria-modal="true"', () => {
const wrapper = mount(OsModal, {
props: { open: true },
})
expect(wrapper.find('[data-testid="os-modal-panel"]').attributes('aria-modal')).toBe('true')
})
it('has aria-labelledby linking to title', () => {
const wrapper = mount(OsModal, {
props: { open: true, title: 'My Title' },
})
const panel = wrapper.find('[data-testid="os-modal-panel"]')
const titleEl = wrapper.find('.os-modal__title')
expect(panel.attributes('aria-labelledby')).toBe(titleEl.attributes('id'))
})
it('has aria-label fallback when no title', () => {
const wrapper = mount(OsModal, {
props: { open: true },
})
const panel = wrapper.find('[data-testid="os-modal-panel"]')
expect(panel.attributes('aria-labelledby')).toBeUndefined()
expect(panel.attributes('aria-label')).toBe('Dialog')
})
it('uses custom ariaLabel when no title', () => {
const wrapper = mount(OsModal, {
props: { open: true, ariaLabel: 'Confirm deletion' },
})
expect(wrapper.find('[data-testid="os-modal-panel"]').attributes('aria-label')).toBe(
'Confirm deletion',
)
})
})
describe('keyboard accessibility', () => {
it('traps focus within the modal on Tab', async () => {
const wrapper = mount(OsModal, {
props: { open: true, title: 'Test' },
attachTo: document.body,
})
const panel = wrapper.find('[data-testid="os-modal-panel"]')
const buttons = wrapper.findAll('button')
const lastButton = buttons[buttons.length - 1]
// Focus the last button
;(lastButton.element as HTMLElement).focus()
// Press Tab → should wrap to first
await panel.trigger('keydown', { key: 'Tab' })
// The focus trap should have prevented default
// (we can't fully test focus movement in jsdom, but we test the handler exists)
expect(panel.exists()).toBe(true)
wrapper.unmount()
})
it('traps focus within the modal on Shift+Tab', async () => {
const wrapper = mount(OsModal, {
props: { open: true, title: 'Test' },
attachTo: document.body,
})
const panel = wrapper.find('[data-testid="os-modal-panel"]')
const buttons = wrapper.findAll('button')
// Focus the first button
;(buttons[0].element as HTMLElement).focus()
// Press Shift+Tab → should wrap to last
await panel.trigger('keydown', { key: 'Tab', shiftKey: true })
expect(panel.exists()).toBe(true)
wrapper.unmount()
})
})
})

View File

@ -0,0 +1,102 @@
import { computed } from 'vue'
import OsButton from '#src/components/OsButton/OsButton.vue'
import OsModal from './OsModal.vue'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
const meta: Meta<typeof OsModal> = {
title: 'Components/OsModal',
component: OsModal,
tags: ['autodocs'],
}
export default meta
type Story = StoryObj<typeof OsModal>
interface PlaygroundArgs {
open: boolean
title: string
cancelLabel: string
confirmLabel: string
content: string
}
export const Playground: StoryObj<PlaygroundArgs> = {
argTypes: {
open: { control: 'boolean' },
title: { control: 'text' },
cancelLabel: { control: 'text' },
confirmLabel: { control: 'text' },
content: { control: 'text' },
},
args: {
open: true,
title: 'Modal Title',
cancelLabel: 'Cancel',
confirmLabel: 'Confirm',
content: 'This is the modal body content.',
},
render: (args) => ({
components: { OsModal },
setup() {
const modalProps = computed(() => ({
open: args.open,
title: args.title,
cancelLabel: args.cancelLabel,
confirmLabel: args.confirmLabel,
}))
const content = computed(() => args.content)
return { modalProps, content }
},
template: `<OsModal v-bind="modalProps">{{ content }}</OsModal>`,
}),
}
export const DefaultSize: Story = {
render: () => ({
components: { OsModal },
template: `
<div data-testid="default-size">
<OsModal :open="true" title="Default Size Modal">
<p>This is a modal with default size (max-width: 500px).</p>
<p>It contains the standard cancel and confirm buttons.</p>
</OsModal>
</div>
`,
}),
}
export const CustomFooter: Story = {
render: () => ({
components: { OsModal, OsButton },
template: `
<div data-testid="custom-footer">
<OsModal :open="true" title="Custom Footer">
<p>This modal has a custom footer with different buttons.</p>
<template #footer="{ confirm, cancel }">
<OsButton variant="danger" appearance="outline" @click="cancel">Delete</OsButton>
<OsButton variant="primary" @click="confirm">Save Changes</OsButton>
</template>
</OsModal>
</div>
`,
}),
}
export const ScrollableContent: Story = {
render: () => ({
components: { OsModal },
template: `
<div data-testid="scrollable-content">
<OsModal :open="true" title="Scrollable Content">
<div>
<p>This modal has a lot of content that will scroll.</p>
<p v-for="i in 20" :key="i">Paragraph {{ i }}: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p>
</div>
</OsModal>
</div>
`,
}),
}

View File

@ -0,0 +1,85 @@
import { AxeBuilder } from '@axe-core/playwright'
import { expect, test } from '@playwright/test'
import type { Page } from '@playwright/test'
const STORY_URL = '/iframe.html?id=components-osmodal'
const STORY_ROOT = '#storybook-root'
async function waitForReady(page: Page) {
await page.evaluate(async () => document.fonts.ready)
}
async function checkA11y(page: Page) {
const results = await new AxeBuilder({ page }).include(STORY_ROOT).analyze()
expect(results.violations).toEqual([])
}
test.describe('OsModal keyboard accessibility', () => {
test('close button is focusable via Tab', async ({ page }) => {
await page.goto(`${STORY_URL}--default-size&viewMode=story`)
const modal = page.locator('.os-modal')
await modal.waitFor()
const closeBtn = page.locator('[data-testid="os-modal-close"]')
await expect(closeBtn).toBeVisible()
await page.keyboard.press('Tab')
await expect(closeBtn).toBeFocused()
})
test('footer buttons are focusable via Tab', async ({ page }) => {
await page.goto(`${STORY_URL}--default-size&viewMode=story`)
const modal = page.locator('.os-modal')
await modal.waitFor()
const cancelBtn = page.locator('[data-testid="os-modal-cancel"]')
const confirmBtn = page.locator('[data-testid="os-modal-confirm"]')
await expect(cancelBtn).toBeVisible()
await expect(confirmBtn).toBeVisible()
// Tab through to find the buttons
await page.keyboard.press('Tab') // close button
await page.keyboard.press('Tab') // content area
await page.keyboard.press('Tab') // cancel
await expect(cancelBtn).toBeFocused()
await page.keyboard.press('Tab') // confirm
await expect(confirmBtn).toBeFocused()
})
})
test.describe('OsModal visual regression', () => {
test('default size', async ({ page }) => {
await page.goto(`${STORY_URL}--default-size&viewMode=story`)
const panel = page.locator('[data-testid="os-modal-panel"]')
await panel.waitFor()
await waitForReady(page)
await expect(panel).toHaveScreenshot('default-size.png')
await checkA11y(page)
})
test('custom footer', async ({ page }) => {
await page.goto(`${STORY_URL}--custom-footer&viewMode=story`)
const panel = page.locator('[data-testid="os-modal-panel"]')
await panel.waitFor()
await waitForReady(page)
await expect(panel).toHaveScreenshot('custom-footer.png')
await checkA11y(page)
})
test('scrollable content', async ({ page }) => {
await page.goto(`${STORY_URL}--scrollable-content&viewMode=story`)
const panel = page.locator('[data-testid="os-modal-panel"]')
await panel.waitFor()
await waitForReady(page)
await expect(panel).toHaveScreenshot('scrollable-content.png')
await checkA11y(page)
})
})

View File

@ -0,0 +1,404 @@
<script lang="ts">
import {
computed,
defineComponent,
getCurrentInstance,
h,
isVue2,
onBeforeUnmount,
onMounted,
ref,
watch,
} from 'vue-demi'
import OsButton from '#src/components/OsButton/OsButton.vue'
import { IconCheck, IconClose } from '#src/components/OsIcon'
import { cn } from '#src/utils'
import { modalPanelVariants } from './modal.variants'
/**
* Modal dialog component with backdrop, focus trap, and body scroll lock.
*
* Supports two modes:
* - **Custom footer** via the `footer` scoped slot (receives `{ confirm, cancel }`)
* - **Built-in buttons** using `cancelLabel` / `confirmLabel` props
*
* Vue 2: `<os-modal :open.sync="show" />`
* Vue 3: `<os-modal v-model:open="show" />`
*
* @slot default - Modal body content
* @slot footer - Custom footer (scoped: { confirm, cancel })
*/
export default defineComponent({
name: 'OsModal',
inheritAttrs: false,
props: {
/** Whether the modal is open */
open: {
type: Boolean,
default: false,
},
/** Modal title displayed in the header */
title: {
type: String,
default: null,
},
/** Accessible label for the dialog when no visible title is provided */
ariaLabel: {
type: String,
default: 'Dialog',
},
/** Label for the built-in cancel button */
cancelLabel: {
type: String,
default: 'Cancel',
},
/** Label for the built-in confirm button */
confirmLabel: {
type: String,
default: 'Confirm',
},
},
emits: ['update:open', 'confirm', 'cancel', 'close', 'opened'],
setup(props, { slots, attrs, emit }) {
const panelClasses = computed(() => modalPanelVariants())
const modalRef = ref<HTMLElement | null>(null)
const isScrolled = ref(false)
let previousOverflow = ''
const titleId = `os-modal-title-${Math.random().toString(36).slice(2, 7)}`
/* v8 ignore start -- Vue 2 only */
const instance = isVue2 ? getCurrentInstance() : null
// In Vue 2, global h() needs currentInstance; use $createElement for icon render fns
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const createElement = isVue2 ? (instance?.proxy as any)?.$createElement : h
/* v8 ignore stop */
// Vue 2's h() does NOT convert onClick on.click. Helper to build event props.
function eventProps(
events: Record<string, (...args: unknown[]) => void>,
): Record<string, unknown> {
/* v8 ignore start -- Vue 2 branch */
if (isVue2) {
return { on: events }
}
/* v8 ignore stop */
const result: Record<string, unknown> = {}
for (const [name, fn] of Object.entries(events)) {
result[`on${name.charAt(0).toUpperCase()}${name.slice(1)}`] = fn
}
return result
}
// --- Scroll tracking for top fade ---
function onContentScroll(e: Event) {
const target = e.target as HTMLElement
isScrolled.value = target.scrollTop > 0
}
// --- Actions ---
function close(type: string) {
emit('update:open', false)
emit('close', type)
}
function confirm() {
emit('confirm')
close('confirm')
}
function cancel(type = 'cancel') {
emit('cancel')
close(type)
}
// --- Body scroll lock ---
watch(
() => props.open,
(show) => {
/* v8 ignore start -- SSR guard */
if (typeof document === 'undefined') return
/* v8 ignore stop */
if (show) {
previousOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
emit('opened')
} else {
document.body.style.overflow = previousOverflow
}
},
{ immediate: true },
)
// --- ESC key handler ---
function onKeydown(e: KeyboardEvent) {
if (props.open && e.key === 'Escape') {
cancel('backdrop')
}
}
onMounted(() => {
document.addEventListener('keydown', onKeydown)
})
onBeforeUnmount(() => {
document.removeEventListener('keydown', onKeydown)
/* v8 ignore start -- cleanup guard */
if (typeof document !== 'undefined') {
document.body.style.overflow = previousOverflow
}
/* v8 ignore stop */
})
// --- Focus trap ---
/* v8 ignore start -- focus wrapping requires real browser (tested in visual tests) */
function onFocusTrap(e: KeyboardEvent) {
if (e.key !== 'Tab' || !modalRef.value) return
const focusable = modalRef.value.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
)
if (focusable.length === 0) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault()
last.focus()
}
} else {
if (document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
}
/* v8 ignore stop */
return () => {
if (!props.open) {
// Render an empty wrapper so v-if consumers can still find the root element
/* v8 ignore start -- Vue 2 branch */
if (isVue2) {
const proxy = instance?.proxy as any // eslint-disable-line @typescript-eslint/no-explicit-any
const parentClass = proxy?.$vnode?.data?.staticClass || ''
const parentDynClass = proxy?.$vnode?.data?.class
return h('div', {
class: ['os-modal-wrapper', parentClass, parentDynClass].filter(Boolean),
attrs,
})
}
/* v8 ignore stop */
const { class: closedAttrClass } = attrs as Record<string, unknown>
return h('div', { class: cn('os-modal-wrapper', (closedAttrClass as string) || '') })
}
// --- Close button (native <button> for Vue 2/3 compatibility) ---
const closeBtn = h(
'button',
{
class:
'os-modal__close absolute top-3 right-3 flex items-center justify-center w-[26px] h-[26px] rounded-full bg-transparent border-0 cursor-pointer p-0 hover:bg-[var(--color-default-hover)] text-[var(--color-default-contrast)]',
type: 'button',
'aria-label': 'Close',
'data-testid': 'os-modal-close',
...eventProps({ click: () => cancel('close') }),
},
[
h('span', { class: 'w-4 h-4 fill-current inline-flex', 'aria-hidden': 'true' }, [
IconClose(createElement, isVue2),
]),
],
)
// --- Header ---
const headerChildren: ReturnType<typeof h>[] = []
if (props.title) {
headerChildren.push(
h(
'h2',
{ class: 'os-modal__title text-[1.5rem] font-semibold m-0', id: titleId },
props.title,
),
)
}
headerChildren.push(closeBtn)
// Top fade: only visible when content is scrolled down
if (isScrolled.value) {
headerChildren.push(
h('div', {
class:
'absolute bottom-0 left-0 w-[calc(100%-10px)] h-[30px] translate-y-full bg-gradient-to-b from-white to-transparent pointer-events-none z-10',
}),
)
}
const header = h(
'div',
{ class: 'os-modal__header relative px-6 pt-5 pb-2 pr-12' },
headerChildren,
)
// --- Content ---
const content = h(
'div',
{
class: 'os-modal__content px-6 pt-4 pb-6 overflow-y-auto max-h-[50vh]',
tabindex: '0',
...eventProps({ scroll: onContentScroll as (...args: unknown[]) => void }),
},
slots.default?.(),
)
// --- Footer ---
let footerContent: ReturnType<typeof h>[] | undefined
if (slots.footer) {
footerContent = slots.footer({ confirm, cancel })
} else {
/* v8 ignore start -- Vue 2 branch: must use VNode data format for component props */
if (isVue2) {
footerContent = [
h(
OsButton,
{
props: { appearance: 'ghost' },
attrs: { 'data-testid': 'os-modal-cancel' },
on: { click: () => cancel('cancel') },
},
[
h('template', { slot: 'icon' }, [IconClose(createElement, true)]),
props.cancelLabel,
],
),
h(
OsButton,
{
props: { variant: 'primary' },
attrs: { 'data-testid': 'os-modal-confirm' },
on: { click: () => confirm() },
},
[
h('template', { slot: 'icon' }, [IconCheck(createElement, true)]),
props.confirmLabel,
],
),
]
} else {
/* v8 ignore stop */
footerContent = [
h(
OsButton,
{
appearance: 'ghost',
'data-testid': 'os-modal-cancel',
onClick: () => cancel('cancel'),
},
{
icon: () => [IconClose(createElement, false)],
default: () => [props.cancelLabel],
},
),
h(
OsButton,
{
variant: 'primary',
'data-testid': 'os-modal-confirm',
onClick: () => confirm(),
},
{
icon: () => [IconCheck(createElement, false)],
default: () => [props.confirmLabel],
},
),
]
}
}
const footer = h(
'footer',
{
class:
'os-modal__footer bg-[#f5f5f5] px-3 py-3 flex justify-end gap-2 rounded-b-lg shadow-[0_-20px_15px_-10px_rgba(255,255,255,0.9)]',
},
footerContent,
)
// --- Backdrop (click-to-close is handled on the overlay wrapper below) ---
const backdrop = h('div', {
class: 'os-modal__backdrop fixed inset-0 bg-black/70 z-[9998]',
'data-testid': 'os-modal-backdrop',
})
// --- Panel (stops click propagation so overlay click only fires on backdrop area) ---
const panelProps: Record<string, unknown> = {
class: panelClasses.value,
role: 'dialog',
'aria-modal': 'true',
'data-testid': 'os-modal-panel',
...eventProps({
keydown: onFocusTrap as (...args: unknown[]) => void,
click: (e: unknown) => (e as Event).stopPropagation(),
}),
ref: modalRef,
}
if (props.title) {
panelProps['aria-labelledby'] = titleId
} else {
panelProps['aria-label'] = props.ariaLabel
}
const panel = h('div', panelProps, [header, content, footer])
// --- Overlay: captures clicks outside the panel to close ---
const overlayProps: Record<string, unknown> = {
class: 'os-modal__overlay fixed inset-0 z-[9999] flex items-center justify-center',
...eventProps({
click: () => cancel('backdrop'),
}),
}
const overlay = h('div', overlayProps, [panel])
// --- Root wrapper ---
/* v8 ignore start -- Vue 2 branch tested in webapp Jest tests */
if (isVue2) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const proxy = instance?.proxy as any
const listeners = proxy?.$listeners || {}
const parentClass = proxy?.$vnode?.data?.staticClass || ''
const parentDynClass = proxy?.$vnode?.data?.class
return h(
'div',
{
class: ['os-modal-wrapper', parentClass, parentDynClass].filter(Boolean),
attrs,
on: {
...listeners,
keydown: onFocusTrap,
},
},
[backdrop, overlay],
)
}
/* v8 ignore stop */
const { class: attrClass, ...restAttrs } = attrs as Record<string, unknown>
return h(
'div',
{
class: cn('os-modal-wrapper', (attrClass as string) || ''),
...restAttrs,
},
[backdrop, overlay],
)
}
},
})
</script>

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

View File

@ -0,0 +1,2 @@
export { default as OsModal } from './OsModal.vue'
export { modalPanelVariants } from './modal.variants'

View File

@ -0,0 +1,12 @@
import { cva } from 'class-variance-authority'
/**
* Modal panel classes using CVA (Class Variance Authority)
*/
export const modalPanelVariants = cva([
'os-modal',
'relative',
'bg-white rounded-lg shadow-xl',
'flex flex-col',
'w-[90vw] max-w-[500px]',
])

View File

@ -28,3 +28,4 @@ export {
type BadgeVariants,
} from './OsBadge'
export { OsNumber, numberVariants, type NumberVariants } from './OsNumber'
export { OsModal, modalPanelVariants } from './OsModal'

View File

@ -1,6 +1,9 @@
declare module '*.svg?icon' {
import type { VNode } from 'vue-demi'
import type { Component, VNode } from 'vue-demi'
const icon: () => VNode
// Icon render functions accept optional (h, isVue2) for cross-version rendering.
// The intersection with Component allows usage in component registrations.
type IconFn = ((h?: unknown, isVue2?: boolean) => VNode) & Component
const icon: IconFn
export default icon
}

View File

@ -347,7 +347,6 @@ $z-index-page-header: 2000;
$z-index-page-sidebar: 1500;
$z-index-sticky-float: 150;
$z-index-sticky: 100;
$z-index-post-teaser-link: 5;
$z-index-surface: 1;
/**

View File

@ -1,32 +1,39 @@
<template>
<os-button
data-test="join-leave-btn"
:variant="isMember && hovered ? 'danger' : 'primary'"
:appearance="filled || (isMember && !hovered) ? 'filled' : 'outline'"
:disabled="disabled"
:loading="localLoading"
full-width
v-tooltip="tooltip"
@mouseenter="onHover"
@mouseleave="hovered = false"
@click.prevent="toggle"
>
<template #icon>
<os-icon :icon="icon" />
</template>
{{ label }}
</os-button>
<div>
<os-button
data-test="join-leave-btn"
:variant="isMember && hovered ? 'danger' : 'primary'"
:appearance="filled || (isMember && !hovered) ? 'filled' : 'outline'"
:disabled="disabled"
:loading="localLoading"
full-width
v-tooltip="tooltip"
@mouseenter="onHover"
@mouseleave="hovered = false"
@click.prevent="toggle"
>
<template #icon>
<os-icon :icon="icon" />
</template>
{{ label }}
</os-button>
<confirm-modal
v-if="showConfirmModal"
:modalData="leaveModalData"
@close="showConfirmModal = false"
/>
</div>
</template>
<script>
import { mapMutations } from 'vuex'
import { OsButton, OsIcon } from '@ocelot-social/ui'
import { iconRegistry } from '~/utils/iconRegistry'
import ConfirmModal from '~/components/Modal/ConfirmModal'
import { joinGroupMutation, leaveGroupMutation } from '~/graphql/groups'
export default {
name: 'JoinLeaveButton',
components: { OsButton, OsIcon },
components: { ConfirmModal, OsButton, OsIcon },
props: {
group: { type: Object, required: true },
userId: { type: String, required: true },
@ -40,9 +47,32 @@ export default {
return {
localLoading: this.loading,
hovered: false,
showConfirmModal: false,
}
},
computed: {
leaveModalData() {
return {
titleIdent: 'group.leaveModal.title',
messageIdent: 'group.leaveModal.message',
messageParams: {
name: this.group.name,
},
buttons: {
confirm: {
danger: true,
icon: this.icons.signOut,
textIdent: 'group.leaveModal.confirmButton',
callback: this.joinLeave,
},
cancel: {
icon: this.icons.close,
textIdent: 'actions.cancel',
callback: () => {},
},
},
}
},
icon() {
if (this.isMember) {
if (this.isNonePendingMember) {
@ -87,9 +117,6 @@ export default {
this.icons = iconRegistry
},
methods: {
...mapMutations({
commitModalData: 'modal/SET_OPEN',
}),
onHover() {
if (!this.disabled && !this.localLoading) {
this.hovered = true
@ -103,36 +130,7 @@ export default {
}
},
openLeaveModal() {
this.commitModalData(this.leaveModalData())
},
leaveModalData() {
return {
name: 'confirm',
data: {
type: '',
resource: { id: '' },
modalData: {
titleIdent: 'group.leaveModal.title',
messageIdent: 'group.leaveModal.message',
messageParams: {
name: this.group.name,
},
buttons: {
confirm: {
danger: true,
icon: this.icons.signOut,
textIdent: 'group.leaveModal.confirmButton',
callback: this.joinLeave,
},
cancel: {
icon: this.icons.close,
textIdent: 'actions.cancel',
callback: () => {},
},
},
},
},
}
this.showConfirmModal = true
},
async joinLeave() {
const join = !this.isMember

View File

@ -1,7 +1,10 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`JoinLeaveButton.vue shallowMount renders 1`] = `
<os-button-stub as="button" variant="primary" appearance="outline" size="md" fullwidth="true" type="button" data-original-title="null" class=" has-tooltip">
group.joinLeaveButton.join
</os-button-stub>
<div>
<os-button-stub as="button" variant="primary" appearance="outline" size="md" fullwidth="true" type="button" data-original-title="null" class=" has-tooltip">
group.joinLeaveButton.join
</os-button-stub>
<!---->
</div>
`;

View File

@ -201,7 +201,7 @@ export default {
const {
data: { DeleteComment },
} = await this.$apollo.mutate({
mutation: CommentMutations(this.$i18n).DeleteComment,
mutation: CommentMutations().DeleteComment,
variables: { id: this.comment.id },
})
this.$toast.success(this.$t(`delete.comment.success`))

View File

@ -101,14 +101,14 @@ export default {
async handleSubmit() {
const mutateParams = !this.update
? {
mutation: CommentMutations(this.$i18n).CreateComment,
mutation: CommentMutations().CreateComment,
variables: {
postId: this.post.id,
content: this.form.content,
},
}
: {
mutation: CommentMutations(this.$i18n).UpdateComment,
mutation: CommentMutations().UpdateComment,
variables: {
id: this.comment.id,
content: this.form.content,

View File

@ -34,9 +34,6 @@ describe('ContentMenu.vue - Group', () => {
},
}
const mutations = {
'modal/SET_OPEN': jest.fn(),
}
const getters = {
'auth/isModerator': () => false,
'auth/isAdmin': () => false,
@ -48,7 +45,7 @@ describe('ContentMenu.vue - Group', () => {
}
const openContentMenu = async (values = {}) => {
const store = new Vuex.Store({ mutations, getters, actions })
const store = new Vuex.Store({ getters, actions })
const wrapper = mount(ContentMenu, {
propsData: {
...values,

View File

@ -14,9 +14,11 @@ const stubs = {
'router-link': {
template: '<span><slot /></span>',
},
'confirm-modal': { template: '<div class="confirm-modal-stub" />' },
'report-modal': { template: '<div class="report-modal-stub" />' },
}
let getters, mutations, actions, mocks, menuToggle, openModalSpy
let getters, actions, mocks, menuToggle, openModalSpy
const maxPinnedPostsMock = jest.fn()
const currentlyPinnedPostsMock = jest.fn()
@ -35,9 +37,6 @@ describe('ContentMenu.vue', () => {
})
describe('mount', () => {
mutations = {
'modal/SET_OPEN': jest.fn(),
}
getters = {
'auth/isModerator': () => false,
'auth/isAdmin': () => false,
@ -49,7 +48,7 @@ describe('ContentMenu.vue', () => {
}
const openContentMenu = async (values = {}) => {
const store = new Vuex.Store({ mutations, getters, actions })
const store = new Vuex.Store({ getters, actions })
const wrapper = mount(ContentMenu, {
propsData: {
...values,
@ -614,7 +613,7 @@ describe('ContentMenu.vue', () => {
.filter((item) => item.text() === 'disable.contribution.title')
.at(0)
.trigger('click')
expect(openModalSpy).toHaveBeenCalledWith('disable')
expect(openModalSpy).toHaveBeenCalledWith('confirm', 'disable')
})
it('can disable comments', async () => {
@ -632,7 +631,7 @@ describe('ContentMenu.vue', () => {
.filter((item) => item.text() === 'disable.comment.title')
.at(0)
.trigger('click')
expect(openModalSpy).toHaveBeenCalledWith('disable')
expect(openModalSpy).toHaveBeenCalledWith('confirm', 'disable')
})
it('can disable users', async () => {
@ -650,7 +649,7 @@ describe('ContentMenu.vue', () => {
.filter((item) => item.text() === 'disable.user.title')
.at(0)
.trigger('click')
expect(openModalSpy).toHaveBeenCalledWith('disable')
expect(openModalSpy).toHaveBeenCalledWith('confirm', 'disable')
})
it('can disable organizations', async () => {
@ -668,7 +667,7 @@ describe('ContentMenu.vue', () => {
.filter((item) => item.text() === 'disable.organization.title')
.at(0)
.trigger('click')
expect(openModalSpy).toHaveBeenCalledWith('disable')
expect(openModalSpy).toHaveBeenCalledWith('confirm', 'disable')
})
it('can release posts', async () => {
@ -686,7 +685,7 @@ describe('ContentMenu.vue', () => {
.filter((item) => item.text() === 'release.contribution.title')
.at(0)
.trigger('click')
expect(openModalSpy).toHaveBeenCalledWith('release')
expect(openModalSpy).toHaveBeenCalledWith('confirm', 'release')
})
it('can release comments', async () => {
@ -704,7 +703,7 @@ describe('ContentMenu.vue', () => {
.filter((item) => item.text() === 'release.comment.title')
.at(0)
.trigger('click')
expect(openModalSpy).toHaveBeenCalledWith('release')
expect(openModalSpy).toHaveBeenCalledWith('confirm', 'release')
})
it('can release users', async () => {
@ -722,7 +721,7 @@ describe('ContentMenu.vue', () => {
.filter((item) => item.text() === 'release.user.title')
.at(0)
.trigger('click')
expect(openModalSpy).toHaveBeenCalledWith('release')
expect(openModalSpy).toHaveBeenCalledWith('confirm', 'release')
})
it('can release organizations', async () => {
@ -740,7 +739,7 @@ describe('ContentMenu.vue', () => {
.filter((item) => item.text() === 'release.organization.title')
.at(0)
.trigger('click')
expect(openModalSpy).toHaveBeenCalledWith('release')
expect(openModalSpy).toHaveBeenCalledWith('confirm', 'release')
})
})

View File

@ -1,53 +1,72 @@
<template>
<dropdown class="content-menu" :placement="placement" offset="5">
<template #default="{ toggleMenu }">
<slot name="button" :toggleMenu="toggleMenu">
<os-button
data-test="content-menu-button"
variant="primary"
appearance="outline"
size="sm"
circle
:aria-label="$t('actions.menu')"
@click.prevent="toggleMenu()"
>
<template #icon>
<os-icon :icon="icons.ellipsisV" />
</template>
</os-button>
</slot>
</template>
<template #popover="{ toggleMenu }">
<div class="content-menu-popover">
<ds-menu :routes="routes">
<template #menuitem="item">
<ds-menu-item
:route="item.route"
:parents="item.parents"
@click.stop.prevent="openItem(item.route, toggleMenu)"
>
<os-icon :icon="item.route.icon" />
{{ item.route.label }}
</ds-menu-item>
</template>
</ds-menu>
</div>
</template>
</dropdown>
<div class="content-menu" @click.stop.prevent>
<dropdown :placement="placement" offset="5">
<template #default="{ toggleMenu }">
<slot name="button" :toggleMenu="toggleMenu">
<os-button
data-test="content-menu-button"
variant="primary"
appearance="outline"
size="sm"
circle
:aria-label="$t('actions.menu')"
@click.prevent="toggleMenu()"
>
<template #icon>
<os-icon :icon="icons.ellipsisV" />
</template>
</os-button>
</slot>
</template>
<template #popover="{ toggleMenu }">
<div class="content-menu-popover">
<ds-menu :routes="routes">
<template #menuitem="item">
<ds-menu-item
:route="item.route"
:parents="item.parents"
@click.stop.prevent="openItem(item.route, toggleMenu)"
>
<os-icon :icon="item.route.icon" />
{{ item.route.label }}
</ds-menu-item>
</template>
</ds-menu>
</div>
</template>
</dropdown>
<confirm-modal
v-if="showConfirmModal"
:modalData="currentModalData"
@close="showConfirmModal = false"
/>
<report-modal
v-if="showReportModal"
:name="getResourceName()"
:type="resourceType"
:id="resource.id"
@close="showReportModal = false"
/>
</div>
</template>
<script>
import { OsButton, OsIcon } from '@ocelot-social/ui'
import { iconRegistry } from '~/utils/iconRegistry'
import Dropdown from '~/components/Dropdown'
import ConfirmModal from '~/components/Modal/ConfirmModal'
import ReportModal from '~/components/Modal/ReportModal'
import { reviewMutation } from '~/graphql/Moderation.js'
import PinnedPostsMixin from '~/mixins/pinnedPosts'
export default {
name: 'ContentMenu',
components: {
ConfirmModal,
Dropdown,
OsButton,
OsIcon,
ReportModal,
},
mixins: [PinnedPostsMixin],
props: {
@ -69,6 +88,13 @@ export default {
},
},
},
data() {
return {
showConfirmModal: false,
showReportModal: false,
currentModalData: null,
}
},
created() {
this.icons = iconRegistry
},
@ -188,7 +214,7 @@ export default {
routes.push({
label: this.$t(`disable.${this.resourceType}.title`),
callback: () => {
this.openModal('disable')
this.openModal('confirm', 'disable')
},
icon: this.icons.eyeSlash,
})
@ -196,7 +222,7 @@ export default {
routes.push({
label: this.$t(`release.${this.resourceType}.title`),
callback: () => {
this.openModal('release')
this.openModal('confirm', 'release')
},
icon: this.icons.eye,
})
@ -308,14 +334,67 @@ export default {
toggleMenu()
},
openModal(dialog, modalDataName = null) {
this.$store.commit('modal/SET_OPEN', {
name: dialog,
data: {
type: this.resourceType,
resource: this.resource,
modalData: modalDataName ? this.modalsData[modalDataName] : {},
if (dialog === 'report') {
this.showReportModal = true
return
}
let modalData = {}
if (modalDataName) {
if (modalDataName === 'disable' || modalDataName === 'release') {
modalData = this.reviewModalData(modalDataName)
} else {
modalData = this.modalsData[modalDataName] || {}
}
}
this.currentModalData = modalData
this.showConfirmModal = true
},
reviewModalData(action) {
const disable = action === 'disable'
const name = this.getResourceName()
return {
titleIdent: `${action}.${this.resourceType}.title`,
messageIdent: `${action}.${this.resourceType}.message`,
messageParams: { name: this.$filters.truncate(name, 30) },
buttons: {
confirm: {
danger: true,
icon: this.icons.exclamationCircle,
textIdent: `${action}.submit`,
callback: async () => {
try {
await this.$apollo.mutate({
mutation: reviewMutation(),
variables: { resourceId: this.resource.id, disable, closed: false },
})
this.$toast.success(this.$t(`${action}.success`))
this.$set(this.resource, 'disabled', disable)
} catch (err) {
this.$toast.error(err.message)
throw err
}
},
},
cancel: {
icon: this.icons.close,
textIdent: `${action}.cancel`,
callback: () => {},
},
},
})
}
},
getResourceName() {
switch (this.resourceType) {
case 'user':
case 'organization':
return this.resource.name || ''
case 'contribution':
return this.resource.title || ''
case 'comment':
return this.resource.author?.name || ''
default:
return ''
}
},
},
}

View File

@ -1,40 +1,42 @@
<template>
<dropdown class="group-content-menu" :placement="placement" offset="5">
<template #default="{ toggleMenu }">
<slot name="button" :toggleMenu="toggleMenu">
<os-button
variant="primary"
appearance="outline"
size="sm"
circle
:aria-label="$t('group.contentMenu.menuButton')"
data-test="group-menu-button"
@click.prevent="toggleMenu()"
>
<template #icon>
<os-icon :icon="icons.ellipsisV" />
</template>
</os-button>
</slot>
</template>
<template #popover="{ toggleMenu }">
<div class="group-menu-popover">
<ds-menu :routes="routes">
<template #menuitem="item">
{{ item.parents }}
<ds-menu-item
:route="item.route"
:parents="item.parents"
@click.stop.prevent="openItem(item.route, toggleMenu)"
>
<os-icon :icon="item.route.icon" />
{{ item.route.label }}
</ds-menu-item>
</template>
</ds-menu>
</div>
</template>
</dropdown>
<div class="content-menu" @click.stop.prevent>
<dropdown class="group-content-menu" :placement="placement" offset="5">
<template #default="{ toggleMenu }">
<slot name="button" :toggleMenu="toggleMenu">
<os-button
variant="primary"
appearance="outline"
size="sm"
circle
:aria-label="$t('group.contentMenu.menuButton')"
data-test="group-menu-button"
@click.prevent="toggleMenu()"
>
<template #icon>
<os-icon :icon="icons.ellipsisV" />
</template>
</os-button>
</slot>
</template>
<template #popover="{ toggleMenu }">
<div class="group-menu-popover">
<ds-menu :routes="routes">
<template #menuitem="item">
{{ item.parents }}
<ds-menu-item
:route="item.route"
:parents="item.parents"
@click.stop.prevent="openItem(item.route, toggleMenu)"
>
<os-icon :icon="item.route.icon" />
{{ item.route.label }}
</ds-menu-item>
</template>
</ds-menu>
</div>
</template>
</dropdown>
</div>
</template>
<script>

View File

@ -2,463 +2,479 @@
exports[`GroupContentMenu renders as groupProfile when I am the owner 1`] = `
<div>
<v-popover-stub
autohide="true"
class="group-content-menu"
container="body"
delay="0"
handleresize="true"
offset="5"
openclass="open"
opengroup="0"
placement="bottom-end"
popoverarrowclass="tooltip-arrow popover-arrow"
popoverbaseclass="tooltip popover"
popoverclass="vue-popover-theme"
popoverinnerclass="tooltip-inner popover-inner"
popoverwrapperclass="wrapper"
popperoptions="[object Object]"
trigger="manual"
<div
class="content-menu"
>
<button
aria-label="group.contentMenu.menuButton"
class="os-button inline-flex items-center justify-center [white-space-collapse:collapse] relative appearance-none font-semibold transition-[color,background-color] duration-[80ms] ease-[cubic-bezier(0.25,0.46,0.45,0.94)] cursor-pointer select-none border-[0.8px] border-solid focus:outline-1 disabled:pointer-events-none disabled:cursor-default focus:outline-dashed focus:outline-[var(--color-primary)] bg-transparent shadow-none disabled:border-[var(--color-disabled)] disabled:text-[var(--color-disabled)] h-[26px] min-w-[26px] text-[12px] leading-[normal] tracking-[0.6px] overflow-hidden whitespace-nowrap align-middle border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-[var(--color-primary-contrast)] active:bg-[var(--color-primary-active)] active:border-[var(--color-primary-active)] active:text-[var(--color-primary-contrast)] rounded-full p-0 w-[26px]"
data-appearance="outline"
data-test="group-menu-button"
data-variant="primary"
type="button"
<v-popover-stub
autohide="true"
class="group-content-menu"
container="body"
delay="0"
handleresize="true"
offset="5"
openclass="open"
opengroup="0"
placement="bottom-end"
popoverarrowclass="tooltip-arrow popover-arrow"
popoverbaseclass="tooltip popover"
popoverclass="vue-popover-theme"
popoverinnerclass="tooltip-inner popover-inner"
popoverwrapperclass="wrapper"
popperoptions="[object Object]"
trigger="manual"
>
<span
class="inline-flex items-center"
<button
aria-label="group.contentMenu.menuButton"
class="os-button inline-flex items-center justify-center [white-space-collapse:collapse] relative appearance-none font-semibold transition-[color,background-color] duration-[80ms] ease-[cubic-bezier(0.25,0.46,0.45,0.94)] cursor-pointer select-none border-[0.8px] border-solid focus:outline-1 disabled:pointer-events-none disabled:cursor-default focus:outline-dashed focus:outline-[var(--color-primary)] bg-transparent shadow-none disabled:border-[var(--color-disabled)] disabled:text-[var(--color-disabled)] h-[26px] min-w-[26px] text-[12px] leading-[normal] tracking-[0.6px] overflow-hidden whitespace-nowrap align-middle border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-[var(--color-primary-contrast)] active:bg-[var(--color-primary-active)] active:border-[var(--color-primary-active)] active:text-[var(--color-primary-contrast)] rounded-full p-0 w-[26px]"
data-appearance="outline"
data-test="group-menu-button"
data-variant="primary"
type="button"
>
<span
class="os-button__icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
class="inline-flex items-center"
>
<span
aria-hidden="true"
class="os-icon inline-flex items-center align-bottom shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
class="os-button__icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
>
<svg
fill="currentColor"
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
<span
aria-hidden="true"
class="os-icon inline-flex items-center align-bottom shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
>
<path
d="M16 6c1.105 0 2 0.895 2 2s-0.895 2-2 2-2-0.895-2-2 0.895-2 2-2zM16 14c1.105 0 2 0.895 2 2s-0.895 2-2 2-2-0.895-2-2 0.895-2 2-2zM16 22c1.105 0 2 0.895 2 2s-0.895 2-2 2-2-0.895-2-2 0.895-2 2-2z"
/>
</svg>
<svg
fill="currentColor"
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M16 6c1.105 0 2 0.895 2 2s-0.895 2-2 2-2-0.895-2-2 0.895-2 2-2zM16 14c1.105 0 2 0.895 2 2s-0.895 2-2 2-2-0.895-2-2 0.895-2 2-2zM16 22c1.105 0 2 0.895 2 2s-0.895 2-2 2-2-0.895-2-2 0.895-2 2-2z"
/>
</svg>
</span>
</span>
</span>
</span>
</button>
<div>
<div
class="group-menu-popover"
>
<nav
class="ds-menu"
</button>
<div>
<div
class="group-menu-popover"
>
<ul
class="ds-menu-list"
<nav
class="ds-menu"
>
<li
class="ds-menu-item ds-menu-item-level-0"
<ul
class="ds-menu-list"
>
<a
class="ds-menu-item-link"
exact="true"
href="/"
>
<span
aria-hidden="true"
class="os-icon inline-flex items-center align-bottom shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
>
<svg
fill="currentColor"
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15 4.594v22.813l-1.719-1.688-4.719-4.719h-4.563v-10h4.563l4.719-4.719zM13 9.438l-3.281 3.281-0.313 0.281h-3.406v6h3.406l0.313 0.281 3.281 3.281v-13.125zM20.219 11.781l2.781 2.781 2.781-2.781 1.438 1.438-2.781 2.781 2.781 2.781-1.438 1.438-2.781-2.781-2.781 2.781-1.438-1.438 2.781-2.781-2.781-2.781z"
/>
</svg>
</span>
group.contentMenu.muteGroup
</a>
<!---->
</li>
<li
class="ds-menu-item ds-menu-item-level-0"
>
<a
class="ds-menu-item-link"
href="/groups/edit/groupid"
>
<span
aria-hidden="true"
class="os-icon inline-flex items-center align-bottom shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
>
<svg
fill="currentColor"
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M25 4.031c0.765 0 1.517 0.298 2.094 0.875 1.154 1.154 1.154 3.034 0 4.188l-10.094 10.125-0.313 0.063-3.5 0.688-1.469 0.313 0.313-1.469 0.688-3.5 0.063-0.313 0.219-0.219 9.906-9.875c0.577-0.577 1.329-0.875 2.094-0.875zM25 5.969c-0.235 0-0.464 0.121-0.688 0.344l-9.688 9.688-0.344 1.719 1.719-0.344 9.688-9.688c0.446-0.446 0.446-0.929 0-1.375-0.223-0.223-0.453-0.344-0.688-0.344zM4 8h13.188l-2 2h-9.188v16h16v-9.188l2-2v13.188h-20v-20z"
/>
</svg>
</span>
admin.settings.name
</a>
<!---->
</li>
<li
class="ds-menu-item ds-menu-item-level-0"
>
<a
class="ds-menu-item-link"
href="/groups/edit/groupid/invites"
<li
class="ds-menu-item ds-menu-item-level-0"
>
<span
aria-hidden="true"
class="os-icon inline-flex items-center align-bottom shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
<a
class="ds-menu-item-link"
exact="true"
href="/"
>
<svg
fill="currentColor"
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
<span
aria-hidden="true"
class="os-icon inline-flex items-center align-bottom shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
>
<path
d="M21.75 4c1.671 0 3.225 0.661 4.406 1.844s1.844 2.735 1.844 4.406-0.662 3.255-1.844 4.438l-1.469 1.469c-1.181 1.183-2.766 1.844-4.438 1.844-0.793 0-1.565-0.153-2.281-0.438l1.625-1.625c0.215 0.038 0.432 0.063 0.656 0.063 1.138 0 2.226-0.445 3.031-1.25l1.469-1.469c1.66-1.66 1.66-4.372 0-6.031-0.804-0.805-1.863-1.25-3-1.25s-2.227 0.444-3.031 1.25l-1.469 1.469c-0.997 0.996-1.391 2.393-1.188 3.688l-1.625 1.625c-0.285-0.716-0.438-1.487-0.438-2.281 0-1.671 0.662-3.255 1.844-4.438l1.469-1.469c1.181-1.183 2.766-1.844 4.438-1.844zM19.281 11.281l1.438 1.438-8 8-1.438-1.438zM11.75 14c0.793 0 1.565 0.153 2.281 0.438l-1.625 1.625c-0.215-0.038-0.432-0.063-0.656-0.063-1.138 0-2.226 0.445-3.031 1.25l-1.469 1.469c-1.66 1.66-1.66 4.372 0 6.031 0.804 0.805 1.863 1.25 3 1.25s2.227-0.444 3.031-1.25l1.469-1.469c0.997-0.996 1.391-2.393 1.188-3.688l1.625-1.625c0.285 0.716 0.438 1.487 0.438 2.281 0 1.671-0.662 3.256-1.844 4.438l-1.469 1.469c-1.181 1.183-2.766 1.844-4.438 1.844s-3.225-0.661-4.406-1.844c-1.182-1.182-1.844-2.735-1.844-4.406s0.662-3.256 1.844-4.438l1.469-1.469c1.181-1.183 2.766-1.844 4.438-1.844z"
/>
</svg>
</span>
group.contentMenu.inviteLinks
</a>
<!---->
</li>
</ul>
</nav>
<svg
fill="currentColor"
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15 4.594v22.813l-1.719-1.688-4.719-4.719h-4.563v-10h4.563l4.719-4.719zM13 9.438l-3.281 3.281-0.313 0.281h-3.406v6h3.406l0.313 0.281 3.281 3.281v-13.125zM20.219 11.781l2.781 2.781 2.781-2.781 1.438 1.438-2.781 2.781 2.781 2.781-1.438 1.438-2.781-2.781-2.781 2.781-1.438-1.438 2.781-2.781-2.781-2.781z"
/>
</svg>
</span>
group.contentMenu.muteGroup
</a>
<!---->
</li>
<li
class="ds-menu-item ds-menu-item-level-0"
>
<a
class="ds-menu-item-link"
href="/groups/edit/groupid"
>
<span
aria-hidden="true"
class="os-icon inline-flex items-center align-bottom shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
>
<svg
fill="currentColor"
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M25 4.031c0.765 0 1.517 0.298 2.094 0.875 1.154 1.154 1.154 3.034 0 4.188l-10.094 10.125-0.313 0.063-3.5 0.688-1.469 0.313 0.313-1.469 0.688-3.5 0.063-0.313 0.219-0.219 9.906-9.875c0.577-0.577 1.329-0.875 2.094-0.875zM25 5.969c-0.235 0-0.464 0.121-0.688 0.344l-9.688 9.688-0.344 1.719 1.719-0.344 9.688-9.688c0.446-0.446 0.446-0.929 0-1.375-0.223-0.223-0.453-0.344-0.688-0.344zM4 8h13.188l-2 2h-9.188v16h16v-9.188l2-2v13.188h-20v-20z"
/>
</svg>
</span>
admin.settings.name
</a>
<!---->
</li>
<li
class="ds-menu-item ds-menu-item-level-0"
>
<a
class="ds-menu-item-link"
href="/groups/edit/groupid/invites"
>
<span
aria-hidden="true"
class="os-icon inline-flex items-center align-bottom shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
>
<svg
fill="currentColor"
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M21.75 4c1.671 0 3.225 0.661 4.406 1.844s1.844 2.735 1.844 4.406-0.662 3.255-1.844 4.438l-1.469 1.469c-1.181 1.183-2.766 1.844-4.438 1.844-0.793 0-1.565-0.153-2.281-0.438l1.625-1.625c0.215 0.038 0.432 0.063 0.656 0.063 1.138 0 2.226-0.445 3.031-1.25l1.469-1.469c1.66-1.66 1.66-4.372 0-6.031-0.804-0.805-1.863-1.25-3-1.25s-2.227 0.444-3.031 1.25l-1.469 1.469c-0.997 0.996-1.391 2.393-1.188 3.688l-1.625 1.625c-0.285-0.716-0.438-1.487-0.438-2.281 0-1.671 0.662-3.255 1.844-4.438l1.469-1.469c1.181-1.183 2.766-1.844 4.438-1.844zM19.281 11.281l1.438 1.438-8 8-1.438-1.438zM11.75 14c0.793 0 1.565 0.153 2.281 0.438l-1.625 1.625c-0.215-0.038-0.432-0.063-0.656-0.063-1.138 0-2.226 0.445-3.031 1.25l-1.469 1.469c-1.66 1.66-1.66 4.372 0 6.031 0.804 0.805 1.863 1.25 3 1.25s2.227-0.444 3.031-1.25l1.469-1.469c0.997-0.996 1.391-2.393 1.188-3.688l1.625-1.625c0.285 0.716 0.438 1.487 0.438 2.281 0 1.671-0.662 3.256-1.844 4.438l-1.469 1.469c-1.181 1.183-2.766 1.844-4.438 1.844s-3.225-0.661-4.406-1.844c-1.182-1.182-1.844-2.735-1.844-4.406s0.662-3.256 1.844-4.438l1.469-1.469c1.181-1.183 2.766-1.844 4.438-1.844z"
/>
</svg>
</span>
group.contentMenu.inviteLinks
</a>
<!---->
</li>
</ul>
</nav>
</div>
</div>
</div>
</v-popover-stub>
</v-popover-stub>
</div>
</div>
`;
exports[`GroupContentMenu renders as groupProfile, muted 1`] = `
<div>
<v-popover-stub
autohide="true"
class="group-content-menu"
container="body"
delay="0"
handleresize="true"
offset="5"
openclass="open"
opengroup="0"
placement="bottom-end"
popoverarrowclass="tooltip-arrow popover-arrow"
popoverbaseclass="tooltip popover"
popoverclass="vue-popover-theme"
popoverinnerclass="tooltip-inner popover-inner"
popoverwrapperclass="wrapper"
popperoptions="[object Object]"
trigger="manual"
<div
class="content-menu"
>
<button
aria-label="group.contentMenu.menuButton"
class="os-button inline-flex items-center justify-center [white-space-collapse:collapse] relative appearance-none font-semibold transition-[color,background-color] duration-[80ms] ease-[cubic-bezier(0.25,0.46,0.45,0.94)] cursor-pointer select-none border-[0.8px] border-solid focus:outline-1 disabled:pointer-events-none disabled:cursor-default focus:outline-dashed focus:outline-[var(--color-primary)] bg-transparent shadow-none disabled:border-[var(--color-disabled)] disabled:text-[var(--color-disabled)] h-[26px] min-w-[26px] text-[12px] leading-[normal] tracking-[0.6px] overflow-hidden whitespace-nowrap align-middle border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-[var(--color-primary-contrast)] active:bg-[var(--color-primary-active)] active:border-[var(--color-primary-active)] active:text-[var(--color-primary-contrast)] rounded-full p-0 w-[26px]"
data-appearance="outline"
data-test="group-menu-button"
data-variant="primary"
type="button"
<v-popover-stub
autohide="true"
class="group-content-menu"
container="body"
delay="0"
handleresize="true"
offset="5"
openclass="open"
opengroup="0"
placement="bottom-end"
popoverarrowclass="tooltip-arrow popover-arrow"
popoverbaseclass="tooltip popover"
popoverclass="vue-popover-theme"
popoverinnerclass="tooltip-inner popover-inner"
popoverwrapperclass="wrapper"
popperoptions="[object Object]"
trigger="manual"
>
<span
class="inline-flex items-center"
<button
aria-label="group.contentMenu.menuButton"
class="os-button inline-flex items-center justify-center [white-space-collapse:collapse] relative appearance-none font-semibold transition-[color,background-color] duration-[80ms] ease-[cubic-bezier(0.25,0.46,0.45,0.94)] cursor-pointer select-none border-[0.8px] border-solid focus:outline-1 disabled:pointer-events-none disabled:cursor-default focus:outline-dashed focus:outline-[var(--color-primary)] bg-transparent shadow-none disabled:border-[var(--color-disabled)] disabled:text-[var(--color-disabled)] h-[26px] min-w-[26px] text-[12px] leading-[normal] tracking-[0.6px] overflow-hidden whitespace-nowrap align-middle border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-[var(--color-primary-contrast)] active:bg-[var(--color-primary-active)] active:border-[var(--color-primary-active)] active:text-[var(--color-primary-contrast)] rounded-full p-0 w-[26px]"
data-appearance="outline"
data-test="group-menu-button"
data-variant="primary"
type="button"
>
<span
class="os-button__icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
class="inline-flex items-center"
>
<span
aria-hidden="true"
class="os-icon inline-flex items-center align-bottom shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
class="os-button__icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
>
<svg
fill="currentColor"
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
<span
aria-hidden="true"
class="os-icon inline-flex items-center align-bottom shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
>
<path
d="M16 6c1.105 0 2 0.895 2 2s-0.895 2-2 2-2-0.895-2-2 0.895-2 2-2zM16 14c1.105 0 2 0.895 2 2s-0.895 2-2 2-2-0.895-2-2 0.895-2 2-2zM16 22c1.105 0 2 0.895 2 2s-0.895 2-2 2-2-0.895-2-2 0.895-2 2-2z"
/>
</svg>
<svg
fill="currentColor"
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M16 6c1.105 0 2 0.895 2 2s-0.895 2-2 2-2-0.895-2-2 0.895-2 2-2zM16 14c1.105 0 2 0.895 2 2s-0.895 2-2 2-2-0.895-2-2 0.895-2 2-2zM16 22c1.105 0 2 0.895 2 2s-0.895 2-2 2-2-0.895-2-2 0.895-2 2-2z"
/>
</svg>
</span>
</span>
</span>
</span>
</button>
<div>
<div
class="group-menu-popover"
>
<nav
class="ds-menu"
</button>
<div>
<div
class="group-menu-popover"
>
<ul
class="ds-menu-list"
<nav
class="ds-menu"
>
<li
class="ds-menu-item ds-menu-item-level-0"
<ul
class="ds-menu-list"
>
<a
class="ds-menu-item-link"
exact="true"
href="/"
<li
class="ds-menu-item ds-menu-item-level-0"
>
<span
aria-hidden="true"
class="os-icon inline-flex items-center align-bottom shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
<a
class="ds-menu-item-link"
exact="true"
href="/"
>
<svg
fill="currentColor"
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
<span
aria-hidden="true"
class="os-icon inline-flex items-center align-bottom shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
>
<path
d="M15 4.594v22.813l-1.719-1.688-4.719-4.719h-4.563v-10h4.563l4.719-4.719zM24.125 6.375c2.385 2.511 3.875 5.896 3.875 9.625s-1.49 7.113-3.875 9.625l-1.406-1.406c2.024-2.149 3.281-5.041 3.281-8.219s-1.257-6.071-3.281-8.219zM21.313 9.188c1.661 1.786 2.688 4.187 2.688 6.813s-1.026 5.026-2.688 6.813l-1.406-1.438c1.3-1.424 2.094-3.3 2.094-5.375s-0.794-3.952-2.094-5.375zM13 9.438l-3.281 3.281-0.313 0.281h-3.406v6h3.406l0.313 0.281 3.281 3.281v-13.125zM18.5 12.031c0.939 1.059 1.5 2.446 1.5 3.969s-0.561 2.91-1.5 3.969l-1.438-1.438c0.578-0.694 0.938-1.559 0.938-2.531s-0.36-1.837-0.938-2.531z"
/>
</svg>
</span>
group.contentMenu.unmuteGroup
</a>
<!---->
</li>
</ul>
</nav>
<svg
fill="currentColor"
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15 4.594v22.813l-1.719-1.688-4.719-4.719h-4.563v-10h4.563l4.719-4.719zM24.125 6.375c2.385 2.511 3.875 5.896 3.875 9.625s-1.49 7.113-3.875 9.625l-1.406-1.406c2.024-2.149 3.281-5.041 3.281-8.219s-1.257-6.071-3.281-8.219zM21.313 9.188c1.661 1.786 2.688 4.187 2.688 6.813s-1.026 5.026-2.688 6.813l-1.406-1.438c1.3-1.424 2.094-3.3 2.094-5.375s-0.794-3.952-2.094-5.375zM13 9.438l-3.281 3.281-0.313 0.281h-3.406v6h3.406l0.313 0.281 3.281 3.281v-13.125zM18.5 12.031c0.939 1.059 1.5 2.446 1.5 3.969s-0.561 2.91-1.5 3.969l-1.438-1.438c0.578-0.694 0.938-1.559 0.938-2.531s-0.36-1.837-0.938-2.531z"
/>
</svg>
</span>
group.contentMenu.unmuteGroup
</a>
<!---->
</li>
</ul>
</nav>
</div>
</div>
</div>
</v-popover-stub>
</v-popover-stub>
</div>
</div>
`;
exports[`GroupContentMenu renders as groupProfile, not muted 1`] = `
<div>
<v-popover-stub
autohide="true"
class="group-content-menu"
container="body"
delay="0"
handleresize="true"
offset="5"
openclass="open"
opengroup="0"
placement="bottom-end"
popoverarrowclass="tooltip-arrow popover-arrow"
popoverbaseclass="tooltip popover"
popoverclass="vue-popover-theme"
popoverinnerclass="tooltip-inner popover-inner"
popoverwrapperclass="wrapper"
popperoptions="[object Object]"
trigger="manual"
<div
class="content-menu"
>
<button
aria-label="group.contentMenu.menuButton"
class="os-button inline-flex items-center justify-center [white-space-collapse:collapse] relative appearance-none font-semibold transition-[color,background-color] duration-[80ms] ease-[cubic-bezier(0.25,0.46,0.45,0.94)] cursor-pointer select-none border-[0.8px] border-solid focus:outline-1 disabled:pointer-events-none disabled:cursor-default focus:outline-dashed focus:outline-[var(--color-primary)] bg-transparent shadow-none disabled:border-[var(--color-disabled)] disabled:text-[var(--color-disabled)] h-[26px] min-w-[26px] text-[12px] leading-[normal] tracking-[0.6px] overflow-hidden whitespace-nowrap align-middle border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-[var(--color-primary-contrast)] active:bg-[var(--color-primary-active)] active:border-[var(--color-primary-active)] active:text-[var(--color-primary-contrast)] rounded-full p-0 w-[26px]"
data-appearance="outline"
data-test="group-menu-button"
data-variant="primary"
type="button"
<v-popover-stub
autohide="true"
class="group-content-menu"
container="body"
delay="0"
handleresize="true"
offset="5"
openclass="open"
opengroup="0"
placement="bottom-end"
popoverarrowclass="tooltip-arrow popover-arrow"
popoverbaseclass="tooltip popover"
popoverclass="vue-popover-theme"
popoverinnerclass="tooltip-inner popover-inner"
popoverwrapperclass="wrapper"
popperoptions="[object Object]"
trigger="manual"
>
<span
class="inline-flex items-center"
<button
aria-label="group.contentMenu.menuButton"
class="os-button inline-flex items-center justify-center [white-space-collapse:collapse] relative appearance-none font-semibold transition-[color,background-color] duration-[80ms] ease-[cubic-bezier(0.25,0.46,0.45,0.94)] cursor-pointer select-none border-[0.8px] border-solid focus:outline-1 disabled:pointer-events-none disabled:cursor-default focus:outline-dashed focus:outline-[var(--color-primary)] bg-transparent shadow-none disabled:border-[var(--color-disabled)] disabled:text-[var(--color-disabled)] h-[26px] min-w-[26px] text-[12px] leading-[normal] tracking-[0.6px] overflow-hidden whitespace-nowrap align-middle border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-[var(--color-primary-contrast)] active:bg-[var(--color-primary-active)] active:border-[var(--color-primary-active)] active:text-[var(--color-primary-contrast)] rounded-full p-0 w-[26px]"
data-appearance="outline"
data-test="group-menu-button"
data-variant="primary"
type="button"
>
<span
class="os-button__icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
class="inline-flex items-center"
>
<span
aria-hidden="true"
class="os-icon inline-flex items-center align-bottom shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
class="os-button__icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
>
<svg
fill="currentColor"
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
<span
aria-hidden="true"
class="os-icon inline-flex items-center align-bottom shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
>
<path
d="M16 6c1.105 0 2 0.895 2 2s-0.895 2-2 2-2-0.895-2-2 0.895-2 2-2zM16 14c1.105 0 2 0.895 2 2s-0.895 2-2 2-2-0.895-2-2 0.895-2 2-2zM16 22c1.105 0 2 0.895 2 2s-0.895 2-2 2-2-0.895-2-2 0.895-2 2-2z"
/>
</svg>
<svg
fill="currentColor"
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M16 6c1.105 0 2 0.895 2 2s-0.895 2-2 2-2-0.895-2-2 0.895-2 2-2zM16 14c1.105 0 2 0.895 2 2s-0.895 2-2 2-2-0.895-2-2 0.895-2 2-2zM16 22c1.105 0 2 0.895 2 2s-0.895 2-2 2-2-0.895-2-2 0.895-2 2-2z"
/>
</svg>
</span>
</span>
</span>
</span>
</button>
<div>
<div
class="group-menu-popover"
>
<nav
class="ds-menu"
</button>
<div>
<div
class="group-menu-popover"
>
<ul
class="ds-menu-list"
<nav
class="ds-menu"
>
<li
class="ds-menu-item ds-menu-item-level-0"
<ul
class="ds-menu-list"
>
<a
class="ds-menu-item-link"
exact="true"
href="/"
<li
class="ds-menu-item ds-menu-item-level-0"
>
<span
aria-hidden="true"
class="os-icon inline-flex items-center align-bottom shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
<a
class="ds-menu-item-link"
exact="true"
href="/"
>
<svg
fill="currentColor"
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
<span
aria-hidden="true"
class="os-icon inline-flex items-center align-bottom shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
>
<path
d="M15 4.594v22.813l-1.719-1.688-4.719-4.719h-4.563v-10h4.563l4.719-4.719zM13 9.438l-3.281 3.281-0.313 0.281h-3.406v6h3.406l0.313 0.281 3.281 3.281v-13.125zM20.219 11.781l2.781 2.781 2.781-2.781 1.438 1.438-2.781 2.781 2.781 2.781-1.438 1.438-2.781-2.781-2.781 2.781-1.438-1.438 2.781-2.781-2.781-2.781z"
/>
</svg>
</span>
group.contentMenu.muteGroup
</a>
<!---->
</li>
</ul>
</nav>
<svg
fill="currentColor"
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15 4.594v22.813l-1.719-1.688-4.719-4.719h-4.563v-10h4.563l4.719-4.719zM13 9.438l-3.281 3.281-0.313 0.281h-3.406v6h3.406l0.313 0.281 3.281 3.281v-13.125zM20.219 11.781l2.781 2.781 2.781-2.781 1.438 1.438-2.781 2.781 2.781 2.781-1.438 1.438-2.781-2.781-2.781 2.781-1.438-1.438 2.781-2.781-2.781-2.781z"
/>
</svg>
</span>
group.contentMenu.muteGroup
</a>
<!---->
</li>
</ul>
</nav>
</div>
</div>
</div>
</v-popover-stub>
</v-popover-stub>
</div>
</div>
`;
exports[`GroupContentMenu renders as groupTeaser 1`] = `
<div>
<v-popover-stub
autohide="true"
class="group-content-menu"
container="body"
delay="0"
handleresize="true"
offset="5"
openclass="open"
opengroup="0"
placement="bottom-end"
popoverarrowclass="tooltip-arrow popover-arrow"
popoverbaseclass="tooltip popover"
popoverclass="vue-popover-theme"
popoverinnerclass="tooltip-inner popover-inner"
popoverwrapperclass="wrapper"
popperoptions="[object Object]"
trigger="manual"
<div
class="content-menu"
>
<button
aria-label="group.contentMenu.menuButton"
class="os-button inline-flex items-center justify-center [white-space-collapse:collapse] relative appearance-none font-semibold transition-[color,background-color] duration-[80ms] ease-[cubic-bezier(0.25,0.46,0.45,0.94)] cursor-pointer select-none border-[0.8px] border-solid focus:outline-1 disabled:pointer-events-none disabled:cursor-default focus:outline-dashed focus:outline-[var(--color-primary)] bg-transparent shadow-none disabled:border-[var(--color-disabled)] disabled:text-[var(--color-disabled)] h-[26px] min-w-[26px] text-[12px] leading-[normal] tracking-[0.6px] overflow-hidden whitespace-nowrap align-middle border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-[var(--color-primary-contrast)] active:bg-[var(--color-primary-active)] active:border-[var(--color-primary-active)] active:text-[var(--color-primary-contrast)] rounded-full p-0 w-[26px]"
data-appearance="outline"
data-test="group-menu-button"
data-variant="primary"
type="button"
<v-popover-stub
autohide="true"
class="group-content-menu"
container="body"
delay="0"
handleresize="true"
offset="5"
openclass="open"
opengroup="0"
placement="bottom-end"
popoverarrowclass="tooltip-arrow popover-arrow"
popoverbaseclass="tooltip popover"
popoverclass="vue-popover-theme"
popoverinnerclass="tooltip-inner popover-inner"
popoverwrapperclass="wrapper"
popperoptions="[object Object]"
trigger="manual"
>
<span
class="inline-flex items-center"
<button
aria-label="group.contentMenu.menuButton"
class="os-button inline-flex items-center justify-center [white-space-collapse:collapse] relative appearance-none font-semibold transition-[color,background-color] duration-[80ms] ease-[cubic-bezier(0.25,0.46,0.45,0.94)] cursor-pointer select-none border-[0.8px] border-solid focus:outline-1 disabled:pointer-events-none disabled:cursor-default focus:outline-dashed focus:outline-[var(--color-primary)] bg-transparent shadow-none disabled:border-[var(--color-disabled)] disabled:text-[var(--color-disabled)] h-[26px] min-w-[26px] text-[12px] leading-[normal] tracking-[0.6px] overflow-hidden whitespace-nowrap align-middle border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-[var(--color-primary-contrast)] active:bg-[var(--color-primary-active)] active:border-[var(--color-primary-active)] active:text-[var(--color-primary-contrast)] rounded-full p-0 w-[26px]"
data-appearance="outline"
data-test="group-menu-button"
data-variant="primary"
type="button"
>
<span
class="os-button__icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
class="inline-flex items-center"
>
<span
aria-hidden="true"
class="os-icon inline-flex items-center align-bottom shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
class="os-button__icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
>
<svg
fill="currentColor"
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
<span
aria-hidden="true"
class="os-icon inline-flex items-center align-bottom shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
>
<path
d="M16 6c1.105 0 2 0.895 2 2s-0.895 2-2 2-2-0.895-2-2 0.895-2 2-2zM16 14c1.105 0 2 0.895 2 2s-0.895 2-2 2-2-0.895-2-2 0.895-2 2-2zM16 22c1.105 0 2 0.895 2 2s-0.895 2-2 2-2-0.895-2-2 0.895-2 2-2z"
/>
</svg>
<svg
fill="currentColor"
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M16 6c1.105 0 2 0.895 2 2s-0.895 2-2 2-2-0.895-2-2 0.895-2 2-2zM16 14c1.105 0 2 0.895 2 2s-0.895 2-2 2-2-0.895-2-2 0.895-2 2-2zM16 22c1.105 0 2 0.895 2 2s-0.895 2-2 2-2-0.895-2-2 0.895-2 2-2z"
/>
</svg>
</span>
</span>
</span>
</span>
</button>
<div>
<div
class="group-menu-popover"
>
<nav
class="ds-menu"
</button>
<div>
<div
class="group-menu-popover"
>
<ul
class="ds-menu-list"
<nav
class="ds-menu"
>
<li
class="ds-menu-item ds-menu-item-level-0"
<ul
class="ds-menu-list"
>
<a
class="ds-menu-item-link"
href="/groups/groupid"
<li
class="ds-menu-item ds-menu-item-level-0"
>
<span
aria-hidden="true"
class="os-icon inline-flex items-center align-bottom shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
<a
class="ds-menu-item-link"
href="/groups/groupid"
>
<svg
fill="currentColor"
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
<span
aria-hidden="true"
class="os-icon inline-flex items-center align-bottom shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
>
<path
d="M16 2.594l0.719 0.688 13 13-1.438 1.438-1.281-1.281v11.563h-9v-10h-4v10h-9v-11.563l-1.281 1.281-1.438-1.438 13-13zM16 5.438l-9 9v11.563h5v-10h8v10h5v-11.563z"
/>
</svg>
</span>
group.contentMenu.visitGroupPage
</a>
<!---->
</li>
</ul>
</nav>
<svg
fill="currentColor"
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M16 2.594l0.719 0.688 13 13-1.438 1.438-1.281-1.281v11.563h-9v-10h-4v10h-9v-11.563l-1.281 1.281-1.438-1.438 13-13zM16 5.438l-9 9v11.563h5v-10h8v10h5v-11.563z"
/>
</svg>
</span>
group.contentMenu.visitGroupPage
</a>
<!---->
</li>
</ul>
</nav>
</div>
</div>
</div>
</v-popover-stub>
</v-popover-stub>
</div>
</div>
`;

View File

@ -11,8 +11,11 @@ exports[`CtaJoinLeaveGroup.vue mount renders 1`] = `
</h4>
<p class="ds-text">
contribution.comment.commenting-disabled.no-group-member.call-to-action
</p> <button data-variant="primary" data-appearance="filled" type="button" class="os-button inline-flex items-center justify-center [white-space-collapse:collapse] relative appearance-none font-semibold tracking-[0.05em] transition-[color,background-color] duration-[80ms] ease-[cubic-bezier(0.25,0.46,0.45,0.94)] cursor-pointer select-none border-[0.8px] border-solid focus:outline-1 disabled:pointer-events-none disabled:cursor-default focus:outline-dashed focus:outline-[var(--color-primary)] shadow-[inset_0_0_0_1px_rgba(0,0,0,0.05)] disabled:bg-[var(--color-disabled)] disabled:text-[var(--color-disabled-contrast)] disabled:border-[var(--color-disabled)] disabled:shadow-[inset_0_0_0_1px_transparent] h-[36px] min-w-[36px] px-[16px] py-0 text-[15px] leading-[normal] rounded-[5px] align-middle w-full bg-[var(--color-primary)] text-[var(--color-primary-contrast)] border-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] hover:border-[var(--color-primary-hover)] hover:text-[var(--color-primary-contrast)] active:bg-[var(--color-primary-active)] active:border-[var(--color-primary-active)] active:text-[var(--color-primary-contrast)] has-tooltip" data-original-title="null"><span class="inline-flex items-center gap-2"><span class="os-button__icon inline-flex items-center shrink-0 h-[1.2em] [&amp;>svg]:h-full [&amp;>svg]:w-auto [&amp;>svg]:fill-current -ml-1"><span aria-hidden="true" class="os-icon inline-flex items-center align-bottom shrink-0 h-[1.2em] [&amp;>svg]:h-full [&amp;>svg]:w-auto [&amp;>svg]:fill-current"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="currentColor"><path d="M15 5h2v10h10v2h-10v10h-2v-10h-10v-2h10v-10z"></path></svg></span></span>
group.joinLeaveButton.join
</span></button>
</p>
<div><button data-variant="primary" data-appearance="filled" type="button" class="os-button inline-flex items-center justify-center [white-space-collapse:collapse] relative appearance-none font-semibold tracking-[0.05em] transition-[color,background-color] duration-[80ms] ease-[cubic-bezier(0.25,0.46,0.45,0.94)] cursor-pointer select-none border-[0.8px] border-solid focus:outline-1 disabled:pointer-events-none disabled:cursor-default focus:outline-dashed focus:outline-[var(--color-primary)] shadow-[inset_0_0_0_1px_rgba(0,0,0,0.05)] disabled:bg-[var(--color-disabled)] disabled:text-[var(--color-disabled-contrast)] disabled:border-[var(--color-disabled)] disabled:shadow-[inset_0_0_0_1px_transparent] h-[36px] min-w-[36px] px-[16px] py-0 text-[15px] leading-[normal] rounded-[5px] align-middle w-full bg-[var(--color-primary)] text-[var(--color-primary-contrast)] border-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] hover:border-[var(--color-primary-hover)] hover:text-[var(--color-primary-contrast)] active:bg-[var(--color-primary-active)] active:border-[var(--color-primary-active)] active:text-[var(--color-primary-contrast)] has-tooltip" data-original-title="null"><span class="inline-flex items-center gap-2"><span class="os-button__icon inline-flex items-center shrink-0 h-[1.2em] [&amp;>svg]:h-full [&amp;>svg]:w-auto [&amp;>svg]:fill-current -ml-1"><span aria-hidden="true" class="os-icon inline-flex items-center align-bottom shrink-0 h-[1.2em] [&amp;>svg]:h-full [&amp;>svg]:w-auto [&amp;>svg]:fill-current"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="currentColor"><path d="M15 5h2v10h10v2h-10v10h-2v-10h-10v-2h10v-10z"></path></svg></span></span>
group.joinLeaveButton.join
</span></button>
<!---->
</div>
</div>
`;

View File

@ -4,14 +4,10 @@
<div class="ds-mb-small"></div>
<div class="ds-mb-large">
<select-user-search :id="id" ref="selectUserSearch" @select-user="selectUser" />
<ds-modal
<os-modal
v-if="isOpen"
force
extended
:confirm-label="$t('group.modal.confirm')"
:cancel-label="$t('group.modal.cancel')"
:title="$t('group.modal.confirmAddGroupMemberTitle')"
v-model="isOpen"
:title="$t('group.addMember')"
:open.sync="isOpen"
@close="closeModal"
@confirm="confirmModal"
@cancel="cancelModal"
@ -19,18 +15,33 @@
<p class="ds-text ds-text-size-large">
{{ $t('group.modal.confirmAddGroupMemberText', { name: user.name }) }}
</p>
</ds-modal>
<template #footer="{ confirm, cancel }">
<os-button appearance="outline" @click="cancel">
<template #icon><os-icon :icon="icons.close" /></template>
{{ $t('group.modal.cancel') }}
</os-button>
<os-button variant="primary" @click="confirm">
<template #icon><os-icon :icon="icons.check" /></template>
{{ $t('group.modal.confirm') }}
</os-button>
</template>
</os-modal>
</div>
</div>
</template>
<script>
import { OsButton, OsIcon, OsModal } from '@ocelot-social/ui'
import { iconRegistry } from '~/utils/iconRegistry'
import { changeGroupMemberRoleMutation } from '~/graphql/groups.js'
import SelectUserSearch from '~/components/generic/SelectUserSearch/SelectUserSearch'
export default {
name: 'AddGroupMember',
components: {
OsButton,
OsIcon,
OsModal,
SelectUserSearch,
},
props: {
@ -43,6 +54,9 @@ export default {
required: false,
},
},
created() {
this.icons = iconRegistry
},
data() {
return {
id: 'search-user-to-add-to-group',

View File

@ -87,7 +87,7 @@ describe('GroupMember', () => {
})
it('has no modal', () => {
expect(wrapper.find('div.ds-modal-wrapper').exists()).toBe(false)
expect(wrapper.find('div.os-modal-wrapper').exists()).toBe(false)
})
describe('change user role', () => {
@ -145,22 +145,28 @@ describe('GroupMember', () => {
})
it('opens the modal', () => {
expect(wrapper.find('div.ds-modal-wrapper').isVisible()).toBe(true)
expect(wrapper.find('div.os-modal-wrapper').isVisible()).toBe(true)
})
describe('click on cancel', () => {
beforeEach(() => {
wrapper.find('div.ds-modal-wrapper').find('button.ds-button-ghost').trigger('click')
wrapper
.find('div.os-modal-wrapper')
.find('[data-testid="os-modal-cancel"]')
.trigger('click')
})
it('closes the modal', () => {
expect(wrapper.find('div.ds-modal-wrapper').exists()).toBe(false)
expect(wrapper.find('div.os-modal-wrapper').exists()).toBe(false)
})
})
describe('click on confirm with server error', () => {
beforeEach(() => {
wrapper.find('div.ds-modal-wrapper').find('button.ds-button-primary').trigger('click')
wrapper
.find('div.os-modal-wrapper')
.find('[data-testid="os-modal-confirm"]')
.trigger('click')
})
it('toasts an error message', () => {
@ -168,14 +174,17 @@ describe('GroupMember', () => {
})
it('closes the modal', () => {
expect(wrapper.find('div.ds-modal-wrapper').exists()).toBe(false)
expect(wrapper.find('div.os-modal-wrapper').exists()).toBe(false)
})
})
describe('click on confirm with success', () => {
beforeEach(() => {
jest.clearAllMocks()
wrapper.find('div.ds-modal-wrapper').find('button.ds-button-primary').trigger('click')
wrapper
.find('div.os-modal-wrapper')
.find('[data-testid="os-modal-confirm"]')
.trigger('click')
})
it('calls the API', () => {
@ -194,7 +203,7 @@ describe('GroupMember', () => {
})
it('closes the modal', () => {
expect(wrapper.find('div.ds-modal-wrapper').exists()).toBe(false)
expect(wrapper.find('div.os-modal-wrapper').exists()).toBe(false)
})
})
})

View File

@ -80,6 +80,7 @@
@click="
isOpen = true
userId = member.user.id
userName = member.user.name
"
>
<template #icon>
@ -92,20 +93,30 @@
</tbody>
</table>
</div>
<ds-modal
<os-modal
v-if="isOpen"
v-model="isOpen"
:title="`${$t('group.removeMember')}`"
force
extended
:confirm-label="$t('group.removeMember')"
:cancel-label="$t('actions.cancel')"
:open.sync="isOpen"
:title="$t('group.removeMemberTitle')"
@confirm="removeUser()"
/>
>
<p class="ds-text ds-text-size-large">
{{ $t('group.removeMemberConfirmText', { name: userName }) }}
</p>
<template #footer="{ confirm, cancel }">
<os-button appearance="outline" data-testid="os-modal-cancel" @click="cancel">
<template #icon><os-icon :icon="icons.close" /></template>
{{ $t('actions.cancel') }}
</os-button>
<os-button variant="danger" data-testid="os-modal-confirm" @click="confirm">
<template #icon><os-icon :icon="icons.check" /></template>
{{ $t('group.removeMember') }}
</os-button>
</template>
</os-modal>
</div>
</template>
<script>
import { OsBadge, OsButton, OsIcon } from '@ocelot-social/ui'
import { OsBadge, OsButton, OsIcon, OsModal } from '@ocelot-social/ui'
import { iconRegistry } from '~/utils/iconRegistry'
import { changeGroupMemberRoleMutation, removeUserFromGroupMutation } from '~/graphql/groups.js'
import ProfileAvatar from '~/components/_new/generic/ProfileAvatar/ProfileAvatar'
@ -118,6 +129,7 @@ export default {
OsBadge,
OsButton,
OsIcon,
OsModal,
ProfileAvatar,
},
props: {
@ -143,6 +155,7 @@ export default {
user: {},
isOpen: false,
userId: null,
userName: null,
}
},
methods: {

View File

@ -2,6 +2,7 @@
<nuxt-link
class="group-teaser"
:to="{ name: 'groups-id-slug', params: { id: group.id, slug: group.slug } }"
@click.native.capture="guardNavigation"
>
<os-card
:class="{
@ -108,6 +109,13 @@ export default {
created() {
this.icons = iconRegistry
},
methods: {
guardNavigation(event) {
if (event.target.closest('.content-menu')) {
event.preventDefault()
}
},
},
computed: {
descriptionExcerpt() {
return this.$filters.removeLinks(this.group.descriptionExcerpt)
@ -174,7 +182,6 @@ export default {
> .content-menu {
position: relative;
z-index: $z-index-post-teaser-link;
}
}

View File

@ -1,43 +1,52 @@
<template>
<dropdown class="invite-button" offset="8" :placement="placement" noMouseLeaveClosing>
<template #default="{ toggleMenu }">
<os-button
variant="primary"
appearance="ghost"
circle
:aria-label="$t('invite-codes.button.tooltip')"
v-tooltip="{
content: $t('invite-codes.button.tooltip'),
placement: 'bottom-start',
}"
@click.prevent="toggleMenu"
>
<template #icon>
<os-icon :icon="icons.userPlus" />
</template>
</os-button>
</template>
<template #popover>
<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>
<div class="invite-button">
<dropdown ref="dropdown" offset="8" :placement="placement" noMouseLeaveClosing>
<template #default="{ toggleMenu }">
<os-button
variant="primary"
appearance="ghost"
circle
:aria-label="$t('invite-codes.button.tooltip')"
v-tooltip="{
content: $t('invite-codes.button.tooltip'),
placement: 'bottom-start',
}"
@click.prevent="toggleMenu"
>
<template #icon>
<os-icon :icon="icons.userPlus" />
</template>
</os-button>
</template>
<template #popover>
<div class="invite-list">
<h2>{{ $t('invite-codes.my-invite-links') }}</h2>
<invitation-list
@generate-invite-code="generatePersonalInviteCode"
@invalidate-invite-code="invalidateInviteCode"
@open-delete-modal="openDeleteModal"
:inviteCodes="user.inviteCodes"
:copy-message="
$t('invite-codes.invite-link-message-personal', {
network: $env.NETWORK_NAME,
})
"
/>
</div>
</template>
</dropdown>
<confirm-modal
v-if="showConfirmModal"
:modalData="currentModalData"
@close="showConfirmModal = false"
/>
</div>
</template>
<script>
import { OsButton, OsIcon } from '@ocelot-social/ui'
import { iconRegistry } from '~/utils/iconRegistry'
import ConfirmModal from '~/components/Modal/ConfirmModal'
import Dropdown from '~/components/Dropdown'
import { mapGetters, mapMutations } from 'vuex'
import InvitationList from '~/components/_new/features/Invitations/InvitationList.vue'
@ -45,6 +54,7 @@ import { generatePersonalInviteCode, invalidateInviteCode } from '~/graphql/Invi
export default {
components: {
ConfirmModal,
OsButton,
OsIcon,
Dropdown,
@ -53,6 +63,12 @@ export default {
props: {
placement: { type: String, default: 'top-end' },
},
data() {
return {
showConfirmModal: false,
currentModalData: null,
}
},
computed: {
...mapGetters({
user: 'auth/user',
@ -68,6 +84,11 @@ export default {
...mapMutations({
setCurrentUser: 'auth/SET_USER_PARTIAL',
}),
openDeleteModal(modalData) {
this.$refs.dropdown.isPopoverOpen = false
this.currentModalData = modalData
this.showConfirmModal = true
},
async generatePersonalInviteCode(comment) {
try {
await this.$apollo.mutate({

View File

@ -1,172 +0,0 @@
import { shallowMount } from '@vue/test-utils'
import Modal from './Modal.vue'
import ConfirmModal from './Modal/ConfirmModal.vue'
import DisableModal from './Modal/DisableModal.vue'
import ReportModal from './Modal/ReportModal.vue'
import Vuex from 'vuex'
import { getters, mutations } from '../store/modal'
import Vue from 'vue'
const localVue = global.localVue
describe('Modal.vue', () => {
let wrapper
let store
let state
let mocks
const createWrapper = (mountMethod) => {
return () => {
store = new Vuex.Store({
state,
getters: {
'modal/open': getters.open,
'modal/data': getters.data,
},
mutations: {
'modal/SET_OPEN': mutations.SET_OPEN,
},
})
return mountMethod(Modal, {
store,
mocks,
localVue,
})
}
}
beforeEach(() => {
mocks = {
$filters: {
truncate: (a) => a,
},
$toast: {
success: () => {},
error: () => {},
},
$t: () => {},
}
state = {
open: null,
data: {},
}
})
describe('shallowMount', () => {
const Wrapper = createWrapper(shallowMount)
it('initially empty', () => {
wrapper = Wrapper()
expect(wrapper.findComponent(ConfirmModal).exists()).toBe(false)
expect(wrapper.findComponent(DisableModal).exists()).toBe(false)
expect(wrapper.findComponent(ReportModal).exists()).toBe(false)
})
describe('store/modal holds data to disable', () => {
beforeEach(() => {
state = {
open: 'disable',
data: {
type: 'contribution',
resource: {
id: 'c456',
title: 'some title',
},
},
}
wrapper = Wrapper()
})
it('renders disable modal', () => {
expect(wrapper.findComponent(DisableModal).exists()).toBe(true)
})
it('passes data to disable modal', () => {
expect(wrapper.findComponent(DisableModal).props()).toEqual({
type: 'contribution',
name: 'some title',
id: 'c456',
})
})
describe('child component emits close', () => {
it('turns empty', async () => {
wrapper.findComponent(DisableModal).vm.$emit('close')
await Vue.nextTick()
expect(wrapper.findComponent(DisableModal).exists()).toBe(false)
})
})
describe('store/modal data contains a comment', () => {
it('passes author name to disable modal', () => {
state.data = {
type: 'comment',
resource: {
id: 'c456',
author: {
name: 'Author name',
},
},
}
wrapper = Wrapper()
expect(wrapper.findComponent(DisableModal).props()).toEqual({
type: 'comment',
name: 'Author name',
id: 'c456',
})
})
it('does not crash if author is undefined', () => {
state.data = {
type: 'comment',
resource: {
id: 'c456',
},
}
wrapper = Wrapper()
expect(wrapper.findComponent(DisableModal).props()).toEqual({
type: 'comment',
name: '',
id: 'c456',
})
})
})
describe('store/modal data contains an user', () => {
it('passes user name to report modal', () => {
state.data = {
type: 'user',
resource: {
id: 'u456',
name: 'Username',
},
}
wrapper = Wrapper()
expect(wrapper.findComponent(DisableModal).props()).toEqual({
type: 'user',
name: 'Username',
id: 'u456',
})
})
})
describe('store/modal data contains no valid datatype', () => {
it('passes something as datatype to modal', () => {
state.data = {
type: 'something',
resource: {
id: 's456',
name: 'Username',
},
}
wrapper = Wrapper()
expect(wrapper.findComponent(DisableModal).props()).toEqual({
type: 'something',
name: null,
id: 's456',
})
})
})
})
})
})

View File

@ -1,84 +0,0 @@
<template>
<div class="modal-wrapper">
<!-- Todo: Put all modals with 2 buttons and equal properties in one customiced 'danger-action-modal' -->
<disable-modal
v-if="open === 'disable'"
:id="data.resource.id"
:type="data.type"
:name="name"
@close="close"
/>
<release-modal
v-if="open === 'release'"
:id="data.resource.id"
:type="data.type"
:name="name"
@close="close"
/>
<report-modal
v-if="open === 'report'"
:id="data.resource.id"
:type="data.type"
:name="name"
@close="close"
/>
<!-- "id", "type", and "name" props are only used for compatibility with the other modals -->
<confirm-modal
v-if="open === 'confirm'"
:id="data.resource.id"
:type="data.type"
:name="name"
:modalData="data.modalData"
@close="close"
/>
<delete-user-modal v-if="open === 'delete'" :userdata="data.userdata" @close="close" />
</div>
</template>
<script>
import ConfirmModal from '~/components/Modal/ConfirmModal'
import DisableModal from '~/components/Modal/DisableModal'
import ReleaseModal from '~/components/ReleaseModal/ReleaseModal.vue'
import ReportModal from '~/components/Modal/ReportModal'
import DeleteUserModal from '~/components/Modal/DeleteUserModal.vue'
import { mapGetters } from 'vuex'
export default {
name: 'Modal',
components: {
DisableModal,
ReleaseModal,
ReportModal,
ConfirmModal,
DeleteUserModal,
},
computed: {
...mapGetters({
data: 'modal/data',
open: 'modal/open',
}),
name() {
// REFACTORING: This gets unneccesary if we use "modalData" in all modals by probaply replacing them all by "confirm-modal"
if (!this.data || !this.data.resource) return ''
const {
resource: { name, title, author },
} = this.data
switch (this.data.type) {
case 'user':
return name
case 'contribution': // REFACTORING: In ConfirmModal Already replaced "title" by "this.menuModalsData.delete.messageParams".
return title
case 'comment':
return author && author.name
default:
return null
}
},
},
methods: {
close() {
this.$store.commit('modal/SET_OPEN', {})
},
},
}
</script>

View File

@ -7,6 +7,9 @@ const localVue = global.localVue
const stubs = {
'sweetalert-icon': true,
'os-modal': {
template: '<div><slot /><slot name="footer" /></div>',
},
}
describe('ConfirmModal.vue', () => {
@ -20,9 +23,6 @@ describe('ConfirmModal.vue', () => {
beforeEach(() => {
propsData = {
type: 'contribution',
id: 'p23',
name: postName,
modalData: postMenuModalsData(postName, confirmCallback, cancelCallback).delete,
}
mocks = {
@ -63,12 +63,6 @@ describe('ConfirmModal.vue', () => {
describe('given a post', () => {
beforeEach(() => {
propsData = {
...propsData,
type: 'contribution',
id: 'p23',
name: postName,
}
wrapper = Wrapper()
})

View File

@ -1,5 +1,5 @@
<template>
<ds-modal :title="title" :is-open="isOpen" @cancel="cancel" data-test="confirm-modal">
<os-modal :title="title" :open="isOpen" @cancel="cancel" data-test="confirm-modal">
<transition name="ds-transition-fade">
<div v-if="success" class="ds-flex ds-flex-centered hc-modal-success">
<sweetalert-icon icon="success" />
@ -37,11 +37,11 @@
{{ $t(modalData.buttons.confirm.textIdent) }}
</os-button>
</template>
</ds-modal>
</os-modal>
</template>
<script>
import { OsButton, OsIcon } from '@ocelot-social/ui'
import { OsButton, OsIcon, OsModal } from '@ocelot-social/ui'
import { SweetalertIcon } from 'vue-sweetalert-icons'
export default {
@ -49,14 +49,12 @@ export default {
components: {
OsButton,
OsIcon,
OsModal,
SweetalertIcon,
},
emits: ['close'],
props: {
name: { type: String, default: '' }, // only used for compatibility with the other modals in 'Modal.vue'
type: { type: String, required: true }, // only used for compatibility with the other modals in 'Modal.vue'
modalData: { type: Object, required: true },
id: { type: String, required: true }, // only used for compatibility with the other modals in 'Modal.vue'
},
data() {
return {

View File

@ -1,123 +0,0 @@
import { mount, shallowMount } from '@vue/test-utils'
import Vuex from 'vuex'
import DeleteUserModal from './DeleteUserModal.vue'
const localVue = global.localVue
const stubs = {
'sweetalert-icon': true,
'nuxt-link': true,
'client-only': true,
}
localVue.use(DeleteUserModal)
const getters = {
'auth/isAdmin': () => true,
'auth/isModerator': () => false,
}
describe('DeleteUserModal.vue', () => {
const store = new Vuex.Store({ getters })
let wrapper
let propsData = {
userdata: {
name: 'another-user',
slug: 'another-user',
createdAt: '2020-08-12T08:34:05.803Z',
contributionsCount: '4',
commentedCount: '2',
},
}
const mocks = {
$t: jest.fn(),
$filters: {
truncate: (a) => a,
},
$toast: {
success: jest.fn(),
error: jest.fn(),
},
$i18n: {
locale: () => 'en',
},
}
afterEach(() => {
jest.clearAllMocks()
})
describe('shallowMount', () => {
const Wrapper = () => {
return shallowMount(DeleteUserModal, {
propsData,
mocks,
store,
localVue,
stubs,
})
}
describe('defaults', () => {
beforeEach(() => {
wrapper = Wrapper()
})
it('success false', () => {
expect(wrapper.vm.success).toBe(false)
})
it('loading false', () => {
expect(wrapper.vm.loading).toBe(false)
})
})
})
describe('mount', () => {
const Wrapper = () => {
return mount(DeleteUserModal, {
propsData,
mocks,
store,
localVue,
stubs,
})
}
beforeEach(() => {
jest.useFakeTimers()
})
describe('given another user', () => {
beforeEach(() => {
propsData = {
...propsData,
type: 'user',
id: 'u4711',
}
wrapper = Wrapper()
})
describe('click cancel button', () => {
beforeEach(() => {
wrapper = Wrapper()
wrapper.find('button.cancel').trigger('click')
})
it('does not emit "close" yet', () => {
expect(wrapper.emitted().close).toBeFalsy()
})
it('fades away', () => {
expect(wrapper.vm.isOpen).toBe(false)
})
describe('after timeout', () => {
beforeEach(jest.runAllTimers)
it('emits "close"', () => {
expect(wrapper.emitted().close).toBeTruthy()
})
})
})
})
})
})

View File

@ -1,195 +0,0 @@
<template>
<ds-modal class="delete-user-modal" :title="title" :is-open="isOpen" @cancel="cancel">
<transition name="ds-transition-fade">
<div v-if="success" class="ds-flex ds-flex-centered hc-modal-success">
<sweetalert-icon icon="success" />
</div>
</transition>
<div>
<section class="ds-section">
<div class="ds-flex">
<div style="flex: 0 0 50%; width: 50%">
<user-teaser :user="userdata" />
</div>
<div style="flex: 0 0 20%; width: 20%">
<p class="ds-text ds-text-size-small">
<span class="bold">{{ $t('modals.deleteUser.created') }}</span>
<br />
<date-time :date-time="userdata.createdAt" />
</p>
</div>
<div style="flex: 0 0 15%; width: 15%">
<p class="ds-text ds-text-size-small">
<span class="bold">{{ $t('common.post', null, userdata.contributionsCount) }}</span>
<br />
{{ userdata.contributionsCount }}
</p>
</div>
<div style="flex: 0 0 15%; width: 15%">
<p class="ds-text ds-text-size-small">
<span class="bold">{{ $t('common.comment', null, userdata.commentedCount) }}</span>
<br />
{{ userdata.commentedCount }}
</p>
</div>
</div>
</section>
</div>
<template #footer>
<os-button variant="primary" appearance="outline" class="cancel" @click="cancel">
{{ $t('actions.cancel') }}
</os-button>
<os-button
variant="danger"
appearance="filled"
class="confirm"
:loading="loading"
@click="openModal"
>
<template #icon><os-icon :icon="icons.exclamationCircle" /></template>
{{ $t('settings.deleteUserAccount.name') }}
</os-button>
</template>
</ds-modal>
</template>
<script>
import { OsButton, OsIcon } from '@ocelot-social/ui'
import { iconRegistry } from '~/utils/iconRegistry'
import gql from 'graphql-tag'
import { mapMutations } from 'vuex'
import { SweetalertIcon } from 'vue-sweetalert-icons'
import DateTime from '~/components/DateTime'
import UserTeaser from '~/components/UserTeaser/UserTeaser'
export default {
name: 'DeleteUserModal',
components: {
DateTime,
OsButton,
OsIcon,
SweetalertIcon,
UserTeaser,
},
props: {
userdata: { type: Object, required: true },
},
data() {
return {
isOpen: true,
success: false,
loading: false,
}
},
created() {
this.icons = iconRegistry
},
computed: {
title() {
return this.$t('settings.deleteUserAccount.name')
},
modalData() {
return (userdata) => {
return {
name: 'confirm',
data: {
type: userdata.name,
resource: userdata,
modalData: {
titleIdent: this.$t('settings.deleteUserAccount.accountWarningIsAdmin'),
messageIdent: this.$t('settings.deleteUserAccount.infoAdmin'),
messageParams: {},
buttons: {
confirm: {
danger: true,
icon: this.icons.trash,
textIdent: this.$t('settings.deleteUserAccount.confirmDeleting'),
callback: () => {
this.confirm(userdata)
},
},
cancel: {
icon: this.icons.close,
textIdent: this.$t('actions.cancel'),
callback: () => {},
},
},
},
},
}
}
},
},
methods: {
...mapMutations({
commitModalData: 'modal/SET_OPEN',
}),
openModal() {
this.commitModalData(this.modalData(this.userdata))
},
cancel() {
// TODO: Use the "modalData" structure introduced in "ConfirmModal" and refactor this here. Be aware that all the Jest tests have to be refactored as well !!!
// await this.modalData.buttons.cancel.callback()
this.isOpen = false
setTimeout(() => {
this.$emit('close')
}, 1000)
},
async confirm() {
this.loading = true
try {
await this.$apollo.mutate({
mutation: gql`
mutation ($id: ID!, $resource: [Deletable]) {
DeleteUser(id: $id, resource: $resource) {
id
}
}
`,
variables: { id: this.userdata.id, resource: ['Post', 'Comment'] },
})
this.success = true
this.$toast.success(this.$t('settings.deleteUserAccount.success'))
setTimeout(() => {
this.isOpen = false
setTimeout(() => {
this.success = false
this.$emit('close')
this.$router.replace('/')
}, 500)
}, 1500)
} catch (err) {
this.success = false
this.$toast.error(err.message)
this.isOpen = false
this.$emit('close')
} finally {
this.loading = false
}
},
},
}
</script>
<style lang="scss">
.delete-user-modal.ds-modal {
max-width: 700px !important;
}
.delete-user-modal .hc-modal-success {
pointer-events: none;
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
background-color: #fff;
opacity: 1;
z-index: $z-index-modal;
border-radius: $border-radius-x-large;
}
.delete-user-modal .bold {
font-weight: 700;
}
</style>

View File

@ -1,195 +0,0 @@
import { shallowMount, mount } from '@vue/test-utils'
import DisableModal from './DisableModal.vue'
const localVue = global.localVue
describe('DisableModal.vue', () => {
let mocks
let propsData
let wrapper
beforeEach(() => {
propsData = {
type: 'contribution',
id: 'c42',
name: 'blah',
}
mocks = {
$filters: {
truncate: (a) => a,
},
$toast: {
success: jest.fn(),
error: jest.fn(),
},
$t: jest.fn(),
$apollo: {
mutate: jest.fn().mockResolvedValueOnce().mockRejectedValue({
message: 'Not Authorized!',
}),
},
location: {
reload: jest.fn(),
},
}
})
describe('shallowMount', () => {
const Wrapper = () => {
return shallowMount(DisableModal, {
propsData,
mocks,
localVue,
})
}
describe('given a user', () => {
beforeEach(() => {
propsData = {
...propsData,
type: 'user',
name: 'Bob Ross',
id: 'u2',
}
})
it('mentions user name', () => {
Wrapper()
const calls = mocks.$t.mock.calls
const expected = [
[
'disable.user.message',
{
name: 'Bob Ross',
},
],
]
expect(calls).toEqual(expect.arrayContaining(expected))
})
})
describe('given a contribution', () => {
beforeEach(() => {
propsData = {
...propsData,
type: 'contribution',
name: 'This is some post title.',
id: 'c3',
}
})
it('mentions contribution title', () => {
Wrapper()
const calls = mocks.$t.mock.calls
const expected = [
[
'disable.contribution.message',
{
name: 'This is some post title.',
},
],
]
expect(calls).toEqual(expect.arrayContaining(expected))
})
})
})
describe('mount', () => {
const Wrapper = () => {
return mount(DisableModal, {
propsData,
mocks,
localVue,
})
}
beforeEach(() => {
jest.useFakeTimers()
})
describe('given id', () => {
beforeEach(() => {
propsData = {
...propsData,
type: 'user',
id: 'u4711',
}
})
describe('click cancel button', () => {
beforeEach(async () => {
wrapper = Wrapper()
await wrapper.find('button.cancel').trigger('click')
})
it('does not emit "close" yet', () => {
expect(wrapper.emitted().close).toBeFalsy()
})
it('fades away', () => {
expect(wrapper.vm.isOpen).toBe(false)
})
describe('after timeout', () => {
beforeEach(jest.runAllTimers)
it('does not call mutation', () => {
expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
})
it('emits close', () => {
expect(wrapper.emitted().close).toBeTruthy()
})
})
})
describe('click confirm button', () => {
beforeEach(async () => {
wrapper = Wrapper()
await wrapper.find('button.confirm').trigger('click')
})
afterEach(() => {
jest.clearAllMocks()
})
it('calls mutation', () => {
expect(mocks.$apollo.mutate).toHaveBeenCalled()
})
it('passes parameters to mutation', () => {
const calls = mocks.$apollo.mutate.mock.calls
const [[{ variables }]] = calls
expect(variables).toMatchObject({
resourceId: 'u4711',
disable: true,
closed: false,
})
})
it('fades away', () => {
expect(wrapper.vm.isOpen).toBe(false)
})
describe('after timeout', () => {
beforeEach(jest.runAllTimers)
it('emits close', () => {
expect(wrapper.emitted().close).toBeTruthy()
})
})
describe('handles errors', () => {
beforeEach(() => {
wrapper = Wrapper()
// second submission causes mutation to reject
wrapper.find('button.confirm').trigger('click')
})
it('shows an error toaster when mutation rejects', async () => {
await expect(mocks.$toast.error).toHaveBeenCalledWith('Not Authorized!')
})
})
})
})
})
})

View File

@ -1,94 +0,0 @@
<template>
<ds-modal :title="title" :is-open="isOpen" @cancel="cancel">
<!-- eslint-disable-next-line vue/no-v-html -->
<p v-html="message" />
<template #footer>
<os-button variant="primary" appearance="outline" class="cancel" @click="cancel">
{{ $t('disable.cancel') }}
</os-button>
<os-button
variant="danger"
appearance="filled"
class="confirm"
:loading="loading"
@click="confirm"
>
<template #icon><os-icon :icon="icons.exclamationCircle" /></template>
{{ $t('disable.submit') }}
</os-button>
</template>
</ds-modal>
</template>
<script>
import { OsButton, OsIcon } from '@ocelot-social/ui'
import { iconRegistry } from '~/utils/iconRegistry'
import gql from 'graphql-tag'
export default {
name: 'DisableModal',
components: { OsButton, OsIcon },
props: {
name: { type: String, default: '' },
type: { type: String, required: true },
id: { type: String, required: true },
},
data() {
return {
isOpen: true,
success: false,
loading: false,
}
},
computed: {
title() {
return this.$t(`disable.${this.type}.title`)
},
message() {
const name = this.$filters.truncate(this.name, 30)
return this.$t(`disable.${this.type}.message`, { name })
},
},
created() {
this.icons = iconRegistry
},
methods: {
async cancel() {
// TODO: Use the "modalData" structure introduced in "ConfirmModal" and refactor this here. Be aware that all the Jest tests have to be refactored as well !!!
// await this.modalData.buttons.cancel.callback()
this.isOpen = false
setTimeout(() => {
this.$emit('close')
}, 1000)
},
async confirm() {
this.loading = true
try {
// TODO: Use the "modalData" structure introduced in "ConfirmModal" and refactor this here. Be aware that all the Jest tests have to be refactored as well !!!
// await this.modalData.buttons.confirm.callback()
await this.$apollo.mutate({
mutation: gql`
mutation ($resourceId: ID!, $disable: Boolean, $closed: Boolean) {
review(resourceId: $resourceId, disable: $disable, closed: $closed) {
disable
}
}
`,
variables: { resourceId: this.id, disable: true, closed: false },
})
this.$toast.success(this.$t('disable.success'))
this.isOpen = false
setTimeout(() => {
this.$emit('close')
}, 1000)
} catch (err) {
this.$toast.error(err.message)
this.isOpen = false
} finally {
this.loading = false
}
},
},
}
</script>

View File

@ -6,6 +6,9 @@ const localVue = global.localVue
const stubs = {
'sweetalert-icon': true,
'os-modal': {
template: '<div><slot /><slot name="footer" /></div>',
},
}
describe('ReportModal.vue', () => {

View File

@ -1,5 +1,5 @@
<template>
<ds-modal class="report-modal" :title="title" :is-open="isOpen" @cancel="cancel">
<os-modal class="report-modal" :title="title" :open="isOpen" @cancel="cancel">
<transition name="ds-transition-fade">
<div v-if="success" class="ds-flex ds-flex-centered hc-modal-success">
<sweetalert-icon icon="success" />
@ -8,6 +8,7 @@
<!-- eslint-disable-next-line vue/no-v-html -->
<p v-html="message" />
<div class="ds-mb-small"></div>
<ds-radio
v-model="form.reasonCategory"
:schema="formSchema.reasonCategory"
@ -27,7 +28,6 @@
<small class="smallTag">
{{ form.reasonDescription.length }}/{{ formSchema.reasonDescription.max }}
</small>
<div class="ds-mb-large"></div>
<template #footer>
<os-button class="cancel" variant="primary" appearance="outline" @click="cancel">
<template #icon>
@ -50,11 +50,11 @@
{{ $t('report.submit') }}
</os-button>
</template>
</ds-modal>
</os-modal>
</template>
<script>
import { OsButton, OsIcon } from '@ocelot-social/ui'
import { OsButton, OsIcon, OsModal } from '@ocelot-social/ui'
import { iconRegistry } from '~/utils/iconRegistry'
import { SweetalertIcon } from 'vue-sweetalert-icons'
import { reportMutation } from '~/graphql/Moderation.js'
@ -66,6 +66,7 @@ export default {
components: {
OsButton,
OsIcon,
OsModal,
SweetalertIcon,
},
props: {
@ -169,7 +170,7 @@ export default {
</script>
<style lang="scss">
.report-modal.ds-modal {
.report-modal.os-modal {
width: 700px !important;
max-width: 700px !important;
}

View File

@ -3,6 +3,7 @@
class="post-teaser"
:class="{ 'post-teaser--horizontal': singleColumn && post.image }"
:to="{ name: 'post-id-slug', params: { id: post.id, slug: post.slug } }"
@click.native.capture="guardNavigation"
>
<os-card
:lang="post.language"
@ -259,6 +260,11 @@ export default {
this.icons = iconRegistry
},
methods: {
guardNavigation(event) {
if (event.target.closest('.content-menu')) {
event.preventDefault()
}
},
async deletePostCallback() {
try {
const {
@ -412,7 +418,6 @@ export default {
> .content-menu {
position: relative;
z-index: $z-index-post-teaser-link;
}
.os-badge {

View File

@ -1,192 +0,0 @@
import { shallowMount, mount } from '@vue/test-utils'
import ReleaseModal from './ReleaseModal.vue'
const localVue = global.localVue
describe('ReleaseModal.vue', () => {
let mocks
let propsData
let wrapper
let Wrapper
beforeEach(() => {
propsData = {
type: 'contribution',
name: 'blah',
id: 'c42',
}
mocks = {
$filters: {
truncate: (a) => a,
},
$toast: {
success: jest.fn(),
error: jest.fn(),
},
$t: jest.fn(),
$apollo: {
mutate: jest.fn().mockResolvedValueOnce().mockRejectedValue({ message: 'Not Authorized!' }),
},
location: {
reload: jest.fn(),
},
}
})
describe('shallowMount', () => {
Wrapper = () => {
return shallowMount(ReleaseModal, {
propsData,
mocks,
localVue,
})
}
describe('given a user', () => {
beforeEach(() => {
propsData = {
type: 'user',
id: 'u2',
name: 'Bob Ross',
}
})
it('mentions user name', () => {
Wrapper()
const calls = mocks.$t.mock.calls
const expected = [
[
'release.user.message',
{
name: 'Bob Ross',
},
],
]
expect(calls).toEqual(expect.arrayContaining(expected))
})
})
describe('given a contribution', () => {
beforeEach(() => {
propsData = {
type: 'contribution',
id: 'c3',
name: 'This is some post title.',
}
})
it('mentions contribution title', () => {
Wrapper()
const calls = mocks.$t.mock.calls
const expected = [
[
'release.contribution.message',
{
name: 'This is some post title.',
},
],
]
expect(calls).toEqual(expect.arrayContaining(expected))
})
})
})
describe('mount', () => {
Wrapper = () => {
return mount(ReleaseModal, {
propsData,
mocks,
localVue,
})
}
beforeEach(() => {
jest.useFakeTimers()
})
describe('given id', () => {
beforeEach(() => {
propsData = {
type: 'user',
id: 'u4711',
}
})
describe('click cancel button', () => {
beforeEach(() => {
wrapper = Wrapper()
wrapper.find('button.cancel').trigger('click')
})
it('does not emit "close" yet', () => {
expect(wrapper.emitted().close).toBeFalsy()
})
it('fades away', () => {
expect(wrapper.vm.isOpen).toBe(false)
})
describe('after timeout', () => {
beforeEach(jest.runAllTimers)
it('does not call mutation', () => {
expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
})
it('emits close', () => {
expect(wrapper.emitted().close).toBeTruthy()
})
})
})
describe('click confirm button', () => {
beforeEach(() => {
wrapper = Wrapper()
wrapper.find('button.confirm').trigger('click')
})
afterEach(() => {
jest.clearAllMocks()
})
it('calls mutation', () => {
expect(mocks.$apollo.mutate).toHaveBeenCalled()
})
it('passes parameters to mutation', () => {
const calls = mocks.$apollo.mutate.mock.calls
const [[{ variables }]] = calls
expect(variables).toMatchObject({
resourceId: 'u4711',
disable: false,
closed: false,
})
})
it('fades away', () => {
expect(wrapper.vm.isOpen).toBe(false)
})
describe('after timeout', () => {
beforeEach(jest.runAllTimers)
it('emits close', () => {
expect(wrapper.emitted().close).toBeTruthy()
})
})
describe('handles errors', () => {
beforeEach(() => {
wrapper = Wrapper()
// second submission causes mutation to reject
wrapper.find('button.confirm').trigger('click')
})
it('shows an error toaster when mutation rejects', async () => {
await expect(mocks.$toast.error).toHaveBeenCalledWith('Not Authorized!')
})
})
})
})
})
})

View File

@ -1,99 +0,0 @@
<template>
<ds-modal :title="title" :is-open="isOpen" @cancel="cancel">
<!-- eslint-disable-next-line vue/no-v-html -->
<p v-html="message" />
<template #footer>
<os-button variant="primary" appearance="outline" class="cancel" @click="cancel">
{{ $t('release.cancel') }}
</os-button>
<os-button
variant="danger"
appearance="filled"
class="confirm"
:loading="loading"
@click="confirm"
>
<template #icon>
<os-icon :icon="icons.exclamationCircle" />
</template>
{{ $t('release.submit') }}
</os-button>
</template>
</ds-modal>
</template>
<script>
import { OsButton, OsIcon } from '@ocelot-social/ui'
import { iconRegistry } from '~/utils/iconRegistry'
import gql from 'graphql-tag'
export default {
name: 'ReleaseModal',
components: { OsButton, OsIcon },
props: {
name: { type: String, default: '' },
type: { type: String, required: true },
id: { type: String, required: true },
},
data() {
return {
isOpen: true,
success: false,
loading: false,
}
},
computed: {
title() {
return this.$t(`release.${this.type}.title`)
},
message() {
const name = this.$filters.truncate(this.name, 30)
return this.$t(`release.${this.type}.message`, { name })
},
},
created() {
this.icons = iconRegistry
},
methods: {
cancel() {
// TODO: Use the "modalData" structure introduced in "ConfirmModal" and refactor this here. Be aware that all the Jest tests have to be refactored as well !!!
// await this.modalData.buttons.cancel.callback()
this.isOpen = false
setTimeout(() => {
this.$emit('close')
}, 1000)
},
async confirm() {
this.loading = true
try {
// TODO: Use the "modalData" structure introduced in "ConfirmModal" and refactor this here. Be aware that all the Jest tests have to be refactored as well !!!
// await this.modalData.buttons.confirm.callback()
await this.$apollo.mutate({
mutation: gql`
mutation ($resourceId: ID!, $disable: Boolean, $closed: Boolean) {
review(resourceId: $resourceId, disable: $disable, closed: $closed) {
disable
}
}
`,
variables: { resourceId: this.id, disable: false, closed: false },
})
this.$toast.success(this.$t('release.success'))
this.isOpen = false
setTimeout(() => {
this.$emit('close')
}, 1000)
} catch (err) {
this.$toast.error(err.message)
this.isOpen = false
setTimeout(() => {
this.$emit('close')
}, 1000)
} finally {
this.loading = false
}
},
},
}
</script>

View File

@ -12,29 +12,15 @@ Object.assign(navigator, {
},
})
const mutations = {
'modal/SET_OPEN': jest.fn(),
}
describe('Invitation.vue', () => {
let wrapper
beforeEach(() => {
mutations['modal/SET_OPEN'].mockClear()
navigator.clipboard.writeText.mockClear()
})
const Wrapper = ({ wasRedeemed = false, withCopymessage = false }) => {
const store = new Vuex.Store({
modules: {
modal: {
namespaced: true,
mutations: {
SET_OPEN: mutations['modal/SET_OPEN'],
},
},
},
})
const store = new Vuex.Store({})
const propsData = {
inviteCode: {
code: 'test-invite-code',
@ -47,6 +33,9 @@ describe('Invitation.vue', () => {
localVue,
store,
propsData,
stubs: {
'confirm-modal': { template: '<div data-test="confirm-modal" />' },
},
mocks: {
$t: jest.fn((v) => v),
$toast: {
@ -120,32 +109,11 @@ describe('Invitation.vue', () => {
wrapper = Wrapper({ wasRedeemed: false })
})
it('opens the delete modal with correct payload', async () => {
it('emits open-delete-modal with modal data', async () => {
const deleteButton = screen.getByLabelText('invite-codes.invalidate')
await fireEvent.click(deleteButton)
expect(mutations['modal/SET_OPEN']).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
name: 'confirm',
data: expect.objectContaining({
modalData: expect.objectContaining({
titleIdent: 'invite-codes.delete-modal.title',
messageIdent: 'invite-codes.delete-modal.message',
buttons: expect.objectContaining({
confirm: expect.objectContaining({
danger: true,
textIdent: 'actions.delete',
callback: expect.any(Function),
}),
cancel: expect.objectContaining({
textIdent: 'actions.cancel',
callback: expect.any(Function),
}),
}),
}),
}),
}),
)
expect(wrapper.emitted()['open-delete-modal']).toBeTruthy()
expect(wrapper.emitted()['open-delete-modal'][0][0]).toHaveProperty('buttons')
})
})
})

View File

@ -48,8 +48,6 @@
<script>
import { OsButton, OsIcon } from '@ocelot-social/ui'
import { iconRegistry } from '~/utils/iconRegistry'
import { mapMutations } from 'vuex'
export default {
name: 'Invitation',
components: {
@ -67,6 +65,27 @@ export default {
},
},
computed: {
deleteModalData() {
return {
titleIdent: 'invite-codes.delete-modal.title',
messageIdent: 'invite-codes.delete-modal.message',
buttons: {
confirm: {
danger: true,
icon: this.icons.trash,
textIdent: 'actions.delete',
callback: () => {
this.$emit('invalidate-invite-code', this.inviteCode.code)
},
},
cancel: {
icon: this.icons.close,
textIdent: 'actions.cancel',
callback: () => {},
},
},
}
},
inviteLink() {
return `${window.location.origin}/registration?method=invite-code&inviteCode=${this.inviteCode.code}`
},
@ -84,40 +103,12 @@ export default {
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: this.icons.trash,
textIdent: 'actions.delete',
callback: () => {
this.$emit('invalidate-invite-code', this.inviteCode.code)
},
},
cancel: {
icon: this.icons.close,
textIdent: 'actions.cancel',
callback: () => {},
},
},
},
},
})
this.$emit('open-delete-modal', this.deleteModalData)
},
},
}

View File

@ -8,6 +8,7 @@
:invite-code="inviteCode"
:copy-message="copyMessage"
@invalidate-invite-code="invalidateInviteCode"
@open-delete-modal="$emit('open-delete-modal', $event)"
/>
</client-only>
</ul>

View File

@ -11,7 +11,6 @@ describe('MySomethingList.vue', () => {
let propsData
let data
let mocks
let mutations
beforeEach(() => {
propsData = {
@ -43,9 +42,6 @@ describe('MySomethingList.vue', () => {
success: jest.fn(),
},
}
mutations = {
'modal/SET_OPEN': jest.fn().mockResolvedValueOnce(),
}
})
describe('mount', () => {
@ -55,9 +51,7 @@ describe('MySomethingList.vue', () => {
'list-item': '<div class="list-item"></div>',
'edit-item': '<div class="edit-item"></div>',
}
const store = new Vuex.Store({
mutations,
})
const store = new Vuex.Store({})
return mount(MySomethingList, {
propsData,
data,
@ -65,6 +59,9 @@ describe('MySomethingList.vue', () => {
localVue,
slots,
store,
stubs: {
'confirm-modal': { template: '<div class="confirm-modal-stub" />' },
},
})
}
@ -134,42 +131,11 @@ describe('MySomethingList.vue', () => {
)
})
it('calls delete by committing "modal/SET_OPEN"', async () => {
it('shows ConfirmModal when delete is clicked', async () => {
const deleteButton = wrapper.find('button[data-test="delete-button"]')
deleteButton.trigger('click')
await Vue.nextTick()
const expectedModalData = expect.objectContaining({
name: 'confirm',
data: {
type: '',
resource: { id: '' },
modalData: {
titleIdent: 'delete-modal.title',
messageIdent: 'delete-modal.message',
messageParams: {
name: 'dummy',
},
buttons: {
confirm: {
danger: true,
icon: ocelotIcons.trash,
textIdent: 'delete-modal.confirm-button',
callback: expect.any(Function),
},
cancel: {
icon: ocelotIcons.close,
textIdent: 'actions.cancel',
callback: expect.any(Function),
},
},
},
},
})
expect(mutations['modal/SET_OPEN']).toHaveBeenCalledTimes(1)
expect(mutations['modal/SET_OPEN']).toHaveBeenCalledWith(
expect.any(Object),
expectedModalData,
)
expect(wrapper.find('.confirm-modal-stub').exists()).toBe(true)
})
})
})

View File

@ -76,17 +76,18 @@
{{ $t('actions.cancel') }}
</os-button>
</div>
<confirm-modal v-if="showConfirmModal" :modalData="currentModalData" @close="closeModal" />
</ds-form>
</template>
<script>
import { OsButton, OsIcon } from '@ocelot-social/ui'
import { iconRegistry } from '~/utils/iconRegistry'
import { mapMutations } from 'vuex'
import ConfirmModal from '~/components/Modal/ConfirmModal'
export default {
name: 'MySomethingList',
components: { OsButton, OsIcon },
components: { ConfirmModal, OsButton, OsIcon },
props: {
useFormData: { type: Object, default: () => ({}) },
useFormSchema: { type: Object, default: () => ({}) },
@ -116,6 +117,8 @@ export default {
disabled: true,
loading: false,
editingItem: null,
showConfirmModal: false,
currentModalData: null,
}
},
computed: {
@ -136,9 +139,6 @@ export default {
this.icons = iconRegistry
},
methods: {
...mapMutations({
commitModalData: 'modal/SET_OPEN',
}),
handleInput(data) {
this.callbacks.handleInput(this, data)
this.disabled = true
@ -170,35 +170,31 @@ export default {
this.openModal(item)
},
openModal(item) {
this.commitModalData(this.modalData(item))
this.currentModalData = this.modalData(item)
this.showConfirmModal = true
},
closeModal() {
this.showConfirmModal = false
this.currentModalData = null
},
modalData(item) {
return {
name: 'confirm',
data: {
type: '',
resource: { id: '' },
modalData: {
titleIdent: this.texts.deleteModal.titleIdent,
messageIdent: this.texts.deleteModal.messageIdent,
messageParams: {
name: item[this.namePropertyKey],
},
buttons: {
confirm: {
danger: true,
icon: this.texts.deleteModal.confirm.icon,
textIdent: this.texts.deleteModal.confirm.buttonTextIdent,
callback: () => {
this.callbacks.delete(this, item)
},
},
cancel: {
icon: this.icons.close,
textIdent: 'actions.cancel',
callback: () => {},
},
},
titleIdent: this.texts.deleteModal.titleIdent,
messageIdent: this.texts.deleteModal.messageIdent,
messageParams: {
name: item[this.namePropertyKey],
},
buttons: {
confirm: {
danger: true,
icon: this.texts.deleteModal.confirm.icon,
textIdent: this.texts.deleteModal.confirm.buttonTextIdent,
callback: () => this.callbacks.delete(this, item),
},
cancel: {
icon: this.icons.close,
textIdent: 'actions.cancel',
callback: () => {},
},
},
}

View File

@ -389,7 +389,7 @@ export default {
},
searchGroups: {
query() {
return searchGroups(this.i18n)
return searchGroups(this.$i18n)
},
variables() {
const { firstGroups, groupsOffset, search } = this

View File

@ -10,10 +10,11 @@ const localVue = global.localVue
const stubs = {
'client-only': true,
'nuxt-link': true,
'confirm-modal': { template: '<div class="confirm-modal-stub" />' },
}
describe('ReportList', () => {
let mocks, mutations, getters, wrapper
let mocks, getters, wrapper
beforeEach(() => {
mocks = {
@ -30,9 +31,6 @@ describe('ReportList', () => {
error: jest.fn((message) => message),
},
}
mutations = {
'modal/SET_OPEN': jest.fn().mockResolvedValueOnce(),
}
getters = {
'auth/user': () => {
return { slug: 'awesome-user' }
@ -44,7 +42,6 @@ describe('ReportList', () => {
describe('mount', () => {
const Wrapper = () => {
const store = new Vuex.Store({
mutations,
getters,
})
return mount(ReportList, { mocks, localVue, store, stubs })
@ -71,8 +68,8 @@ describe('ReportList', () => {
wrapper.findComponent(ReportsTable).vm.$emit('confirm', reports[0])
})
it('calls modal/SET_OPEN', () => {
expect(mutations['modal/SET_OPEN']).toHaveBeenCalledTimes(1)
it('shows ConfirmModal', () => {
expect(wrapper.find('.confirm-modal-stub').exists()).toBe(true)
})
})
})

View File

@ -8,13 +8,21 @@
</div>
<reports-table :reports="reports" @confirm="openModal" />
<pagination-buttons :hasNext="hasNext" :hasPrevious="hasPrevious" @back="back" @next="next" />
<confirm-modal
v-if="showConfirmModal"
:modalData="currentModalData"
@close="
showConfirmModal = false
currentModalData = null
"
/>
</os-card>
</template>
<script>
import { OsCard } from '@ocelot-social/ui'
import { iconRegistry } from '~/utils/iconRegistry'
import { mapMutations } from 'vuex'
import ConfirmModal from '~/components/Modal/ConfirmModal'
import DropdownFilter from '~/components/DropdownFilter/DropdownFilter'
import ReportsTable from '~/components/features/ReportsTable/ReportsTable'
import { reportsListQuery, reviewMutation } from '~/graphql/Moderation.js'
@ -22,6 +30,7 @@ import PaginationButtons from '~/components/_new/generic/PaginationButtons/Pagin
export default {
components: {
ConfirmModal,
OsCard,
DropdownFilter,
ReportsTable,
@ -42,6 +51,8 @@ export default {
closed: null,
hasNext: false,
selected: this.$t('moderation.reports.filterLabel.all'),
showConfirmModal: false,
currentModalData: null,
}
},
computed: {
@ -73,37 +84,25 @@ export default {
'.' +
(report.resource.disabled ? 'disable' : 'enable')
return {
name: 'confirm',
data: {
type: report.resource.__typename,
resource: report.resource,
modalData: {
titleIdent: identStart + '.title',
messageIdent: identStart + '.message',
messageParams: {
name:
report.resource.name ||
this.$filters.truncate(report.resource.title, 30) ||
this.$filters.truncate(
this.$filters.removeHtml(report.resource.contentExcerpt),
30,
),
},
buttons: {
confirm: {
danger: true,
icon: report.resource.disabled ? this.icons.eyeSlash : this.icons.eye,
textIdent: 'moderation.reports.decideModal.submit',
callback: () => {
this.confirmCallback(report.resource)
},
},
cancel: {
icon: this.icons.close,
textIdent: 'moderation.reports.decideModal.cancel',
callback: () => {},
},
},
titleIdent: identStart + '.title',
messageIdent: identStart + '.message',
messageParams: {
name:
report.resource.name ||
this.$filters.truncate(report.resource.title, 30) ||
this.$filters.truncate(this.$filters.removeHtml(report.resource.contentExcerpt), 30),
},
buttons: {
confirm: {
danger: true,
icon: report.resource.disabled ? this.icons.eyeSlash : this.icons.eye,
textIdent: 'moderation.reports.decideModal.submit',
callback: () => this.confirmCallback(report.resource),
},
cancel: {
icon: this.icons.close,
textIdent: 'moderation.reports.decideModal.cancel',
callback: () => {},
},
},
}
@ -117,9 +116,6 @@ export default {
this.icons = iconRegistry
},
methods: {
...mapMutations({
commitModalData: 'modal/SET_OPEN',
}),
filter(option) {
this.selected = option.label
this.offset = 0
@ -128,19 +124,21 @@ export default {
},
async confirmCallback(resource) {
const { disabled: disable, id: resourceId } = resource
this.$apollo
.mutate({
try {
await this.$apollo.mutate({
mutation: reviewMutation(),
variables: { disable, resourceId, closed: true },
})
.then(() => {
this.$toast.success(this.$t('moderation.reports.DecisionSuccess'))
this.$apollo.queries.reportsList.refetch()
})
.catch((error) => this.$toast.error(error.message))
this.$toast.success(this.$t('moderation.reports.DecisionSuccess'))
await this.$apollo.queries.reportsList.refetch()
} catch (error) {
this.$toast.error(error.message)
throw error
}
},
openModal(report) {
this.commitModalData(this.modalData(report))
this.currentModalData = this.modalData(report)
this.showConfirmModal = true
},
back() {
this.offset = Math.max(this.offset - this.pageSize, 0)

View File

@ -1,8 +1,7 @@
import gql from 'graphql-tag'
import { imageUrls } from './fragments/imageUrls'
export default (i18n) => {
const lang = i18n.locale().toUpperCase()
export default () => {
return {
CreateComment: gql`
${imageUrls}
@ -68,7 +67,7 @@ export default (i18n) => {
DeleteComment: gql`
${imageUrls}
mutation($id: ID!) {
mutation ($id: ID!) {
DeleteComment(id: $id) {
id
contentExcerpt
@ -90,10 +89,7 @@ export default (i18n) => {
commentedCount
followedByCount
followedByCurrentUser
location {
name: name${lang}
}
badges {
badgeTrophies {
id
icon
}

View File

@ -1,8 +1,7 @@
import gql from 'graphql-tag'
import { imageUrls } from './fragments/imageUrls'
export default (app) => {
const lang = app.$i18n.locale().toUpperCase()
export default () => {
return gql`
${imageUrls}
@ -25,10 +24,7 @@ export default (app) => {
commentedCount
followedByCount
followedByCurrentUser
location {
name: name${lang}
}
badges {
badgeTrophies {
id
icon
}

View File

@ -97,7 +97,7 @@ export const searchGroups = (i18n) => {
slug
name
icon
}
}
avatar {
...imageUrls
}

View File

@ -10,9 +10,6 @@
</div>
<page-footer class="desktop-footer" />
<div id="overlay" />
<client-only>
<modal />
</client-only>
<div v-if="getShowChat.showChat" class="chat-modul">
<client-only>
<chat singleRoom :roomId="getShowChat.roomID" @close-single-room="closeSingleRoom" />
@ -26,14 +23,12 @@ import { mapGetters, mapMutations } from 'vuex'
import seo from '~/mixins/seo'
import mobile from '~/mixins/mobile'
import HeaderMenu from '~/components/HeaderMenu/HeaderMenu'
import Modal from '~/components/Modal'
import PageFooter from '~/components/PageFooter/PageFooter'
import Chat from '~/components/Chat/Chat.vue'
export default {
components: {
HeaderMenu,
Modal,
PageFooter,
Chat,
},

View File

@ -541,6 +541,7 @@
"regional": "Regional"
},
"actionRadius": "Aktionsradius der Gruppe",
"addMember": "Mitglied hinzufügen",
"addMemberToGroup": "Zur Gruppe hinzufügen",
"addMemberToGroupSuccess": "„{name}“ wurde der Gruppe mit der Rolle „{role}“ hinzugefügt!",
"addUser": "Nutzer hinzufügen",
@ -612,6 +613,8 @@
"radius": "Radius",
"removeMember": "Mitglied aus der Gruppe entfernen?",
"removeMemberButton": "Entfernen",
"removeMemberConfirmText": "Nutzer \"{name}\" aus der Gruppe entfernen?",
"removeMemberTitle": "Mitglied entfernen",
"role": "Deine Rolle in der Gruppe",
"roles": {
"admin": "Administrator",
@ -976,7 +979,7 @@
"title": "Beitrag freigeben",
"type": "Beitrag"
},
"submit": "freigeben",
"submit": "Freigeben",
"success": "Erfolgreich freigegeben!",
"user": {
"error": "Den Nutzer hast du schon gemeldet!",

View File

@ -541,6 +541,7 @@
"regional": "Regional"
},
"actionRadius": "Action radius of the group",
"addMember": "Add Member",
"addMemberToGroup": "Add to group",
"addMemberToGroupSuccess": "“{name}” was added to the group with the role “{role}”!",
"addUser": "Add User",
@ -612,6 +613,8 @@
"radius": "Radius",
"removeMember": "Remove member",
"removeMemberButton": "Remove",
"removeMemberConfirmText": "Remove user \"{name}\" from the group?",
"removeMemberTitle": "Remove Member",
"role": "Your role in the group",
"roles": {
"admin": "Administrator",

View File

@ -541,6 +541,7 @@
"regional": "Regional"
},
"actionRadius": "Radio de acción del grupo",
"addMember": "Añadir miembro",
"addMemberToGroup": "Añadir al grupo",
"addMemberToGroupSuccess": "¡“{name}” fue añadido al grupo con el rol “{role}”!",
"addUser": "Añadir usuario",
@ -612,6 +613,8 @@
"radius": "Radio",
"removeMember": "Eliminar miembro",
"removeMemberButton": "Eliminar",
"removeMemberConfirmText": "¿Eliminar al usuario \"{name}\" del grupo?",
"removeMemberTitle": "Eliminar miembro",
"role": "Tu rol en el grupo",
"roles": {
"admin": "Administrador",

View File

@ -541,6 +541,7 @@
"regional": "Régional"
},
"actionRadius": "Rayon d'action du groupe",
"addMember": "Ajouter un membre",
"addMemberToGroup": "Ajouter au groupe",
"addMemberToGroupSuccess": "“{name}” a été ajouté au groupe avec le rôle “{role}” !",
"addUser": "Ajouter un utilisateur",
@ -612,6 +613,8 @@
"radius": "Rayon",
"removeMember": "Retirer le membre",
"removeMemberButton": "Retirer",
"removeMemberConfirmText": "Retirer l'utilisateur « {name} » du groupe ?",
"removeMemberTitle": "Retirer le membre",
"role": "Ton rôle dans le groupe",
"roles": {
"admin": "Administrateur",

View File

@ -541,6 +541,7 @@
"regional": "Regionale"
},
"actionRadius": "Raggio d'azione del gruppo",
"addMember": "Aggiungi membro",
"addMemberToGroup": "Aggiungi al gruppo",
"addMemberToGroupSuccess": "“{name}” è stato aggiunto al gruppo con il ruolo “{role}”!",
"addUser": "Aggiungi utente",
@ -612,6 +613,8 @@
"radius": "Raggio",
"removeMember": "Rimuovi membro",
"removeMemberButton": "Rimuovi",
"removeMemberConfirmText": "Rimuovere l'utente \"{name}\" dal gruppo?",
"removeMemberTitle": "Rimuovi membro",
"role": "Il tuo ruolo nel gruppo",
"roles": {
"admin": "Amministratore",

View File

@ -541,6 +541,7 @@
"regional": "Regionaal"
},
"actionRadius": "Actieradius van de groep",
"addMember": "Lid toevoegen",
"addMemberToGroup": "Toevoegen aan groep",
"addMemberToGroupSuccess": "“{name}” is toegevoegd aan de groep met de rol “{role}”!",
"addUser": "Gebruiker toevoegen",
@ -612,6 +613,8 @@
"radius": "Radius",
"removeMember": "Lid verwijderen",
"removeMemberButton": "Verwijderen",
"removeMemberConfirmText": "Gebruiker \"{name}\" uit de groep verwijderen?",
"removeMemberTitle": "Lid verwijderen",
"role": "Jouw rol in de groep",
"roles": {
"admin": "Beheerder",

View File

@ -541,6 +541,7 @@
"regional": "Regionalny"
},
"actionRadius": "Zasięg działania grupy",
"addMember": "Dodaj członka",
"addMemberToGroup": "Dodaj do grupy",
"addMemberToGroupSuccess": "“{name}” został/a dodany/a do grupy z rolą “{role}”!",
"addUser": "Dodaj użytkownika",
@ -612,6 +613,8 @@
"radius": "Zasięg",
"removeMember": "Usuń członka",
"removeMemberButton": "Usuń",
"removeMemberConfirmText": "Usunąć użytkownika \"{name}\" z grupy?",
"removeMemberTitle": "Usuń członka",
"role": "Twoja rola w grupie",
"roles": {
"admin": "Administrator",

View File

@ -541,6 +541,7 @@
"regional": "Regional"
},
"actionRadius": "Raio de ação do grupo",
"addMember": "Adicionar membro",
"addMemberToGroup": "Adicionar ao grupo",
"addMemberToGroupSuccess": "“{name}” foi adicionado ao grupo com o papel “{role}”!",
"addUser": "Adicionar usuário",
@ -612,6 +613,8 @@
"radius": "Raio",
"removeMember": "Remover membro",
"removeMemberButton": "Remover",
"removeMemberConfirmText": "Remover o usuário \"{name}\" do grupo?",
"removeMemberTitle": "Remover membro",
"role": "Seu papel no grupo",
"roles": {
"admin": "Administrador",

View File

@ -541,6 +541,7 @@
"regional": "Региональный"
},
"actionRadius": "Радиус действия группы",
"addMember": "Добавить участника",
"addMemberToGroup": "Добавить в группу",
"addMemberToGroupSuccess": "“{name}” добавлен в группу с ролью “{role}”!",
"addUser": "Добавить пользователя",
@ -612,6 +613,8 @@
"radius": "Радиус",
"removeMember": "Удалить участника",
"removeMemberButton": "Удалить",
"removeMemberConfirmText": "Удалить пользователя «{name}» из группы?",
"removeMemberTitle": "Удалить участника",
"role": "Твоя роль в группе",
"roles": {
"admin": "Администратор",

View File

@ -541,6 +541,7 @@
"regional": "Rajonal"
},
"actionRadius": "Rrezja e veprimit të grupit",
"addMember": "Shto anëtar",
"addMemberToGroup": "Shto në grup",
"addMemberToGroupSuccess": "“{name}” u shtua në grup me rolin “{role}”!",
"addUser": "Shto përdorues",
@ -612,6 +613,8 @@
"radius": "Rrezja",
"removeMember": "Hiq anëtarin",
"removeMemberButton": "Hiq",
"removeMemberConfirmText": "Hiq përdoruesin \"{name}\" nga grupi?",
"removeMemberTitle": "Hiq anëtarin",
"role": "Roli yt në grup",
"roles": {
"admin": "Administrator",

View File

@ -541,6 +541,7 @@
"regional": "Регіональний"
},
"actionRadius": "Радіус дії групи",
"addMember": "Додати учасника",
"addMemberToGroup": "Додати до групи",
"addMemberToGroupSuccess": "«{name}» додано до групи з роллю «{role}»!",
"addUser": "Додати користувача",
@ -612,6 +613,8 @@
"radius": "Радіус",
"removeMember": "Видалити учасника",
"removeMemberButton": "Видалити",
"removeMemberConfirmText": "Видалити користувача «{name}» з групи?",
"removeMemberTitle": "Видалити учасника",
"role": "Ваша роль у групі",
"roles": {
"admin": "Адміністратор",

File diff suppressed because it is too large Load Diff

View File

@ -569,12 +569,14 @@ export default {
// this.user.followedByCurrentUser = followedByCurrentUser
// this.user.followedBy = followedBy
// },
updateJoinLeave() {
this.$apollo.queries.Group.refetch()
if (this.isAllowedSeeingGroupMembers) {
async updateJoinLeave() {
this.loadGroupMembers = false
this.GroupMembers = []
await this.$apollo.queries.Group.refetch()
await this.$nextTick()
this.loadGroupMembers = this.isAllowedSeeingGroupMembers
if (this.loadGroupMembers) {
this.$apollo.queries.GroupMembers.refetch()
} else {
this.GroupMembers = []
}
},
fetchAllMembers() {

View File

@ -207,6 +207,8 @@ exports[`invites.vue renders 1`] = `
</div>
</div>
</div>
<!---->
</div>
</div>
`;

View File

@ -6,6 +6,7 @@
<invitation-list
@generate-invite-code="generateGroupInviteCode"
@invalidate-invite-code="invalidateInviteCode"
@open-delete-modal="openDeleteModal"
:inviteCodes="group.inviteCodes"
:copy-message="
group.groupType === 'hidden'
@ -19,19 +20,32 @@
"
/>
</os-card>
<confirm-modal
v-if="showConfirmModal"
:modalData="currentModalData"
@close="showConfirmModal = false"
/>
</div>
</template>
<script>
import { OsCard } from '@ocelot-social/ui'
import ConfirmModal from '~/components/Modal/ConfirmModal'
import InvitationList from '~/components/_new/features/Invitations/InvitationList.vue'
import { generateGroupInviteCode, invalidateInviteCode } from '~/graphql/InviteCode'
export default {
components: {
ConfirmModal,
OsCard,
InvitationList,
},
data() {
return {
showConfirmModal: false,
currentModalData: null,
}
},
props: {
group: {
type: Object,
@ -39,6 +53,10 @@ export default {
},
},
methods: {
openDeleteModal(modalData) {
this.currentModalData = modalData
this.showConfirmModal = true
},
async generateGroupInviteCode(comment) {
try {
await this.$apollo.mutate({

File diff suppressed because it is too large Load Diff

View File

@ -205,12 +205,63 @@
</client-only>
</div>
</div>
<os-modal
v-if="deleteUserData"
:open="showDeleteModal"
:title="$t('settings.deleteUserAccount.accountWarningIsAdmin')"
@cancel="cancelDeleteUser"
>
<p>{{ $t('settings.deleteUserAccount.infoAdmin') }}</p>
<div class="ds-flex" style="margin-top: 0.75rem">
<div style="flex: 0 0 50%; width: 50%">
<user-teaser :user="deleteUserData" :link-to-profile="false" :show-popover="false" />
</div>
<div style="flex: 0 0 20%; width: 20%">
<p class="ds-text ds-text-size-small">
<strong>{{ $t('modals.deleteUser.created') }}</strong>
<br />
<date-time :date-time="deleteUserData.createdAt" />
</p>
</div>
<div style="flex: 0 0 15%; width: 15%">
<p class="ds-text ds-text-size-small">
<strong>{{ $t('common.post', null, deleteUserData.contributionsCount) }}</strong>
<br />
{{ deleteUserData.contributionsCount }}
</p>
</div>
<div style="flex: 0 0 15%; width: 15%">
<p class="ds-text ds-text-size-small">
<strong>{{ $t('common.comment', null, deleteUserData.commentedCount) }}</strong>
<br />
{{ deleteUserData.commentedCount }}
</p>
</div>
</div>
<template #footer="{ cancel }">
<os-button variant="primary" appearance="outline" @click="cancel">
{{ $t('actions.cancel') }}
</os-button>
<os-button
variant="danger"
appearance="filled"
:loading="deleteLoading"
@click="confirmDeleteUser"
>
<template #icon><os-icon :icon="icons.exclamationCircle" /></template>
{{ $t('settings.deleteUserAccount.name') }}
</os-button>
</template>
</os-modal>
</div>
</template>
<script>
import { OsButton, OsCard, OsIcon, OsNumber, OsSpinner } from '@ocelot-social/ui'
import { OsButton, OsCard, OsIcon, OsModal, OsNumber, OsSpinner } from '@ocelot-social/ui'
import { iconRegistry } from '~/utils/iconRegistry'
import gql from 'graphql-tag'
import uniqBy from 'lodash/uniqBy'
import { mapGetters, mapMutations } from 'vuex'
import postListActions from '~/mixins/postListActions'
@ -231,6 +282,8 @@ import { muteUser, unmuteUser } from '~/graphql/settings/MutedUsers'
import { blockUser, unblockUser } from '~/graphql/settings/BlockedUsers'
import UpdateQuery from '~/components/utils/UpdateQuery'
import SocialMedia from '~/components/SocialMedia/SocialMedia'
import DateTime from '~/components/DateTime'
import UserTeaser from '~/components/UserTeaser/UserTeaser'
import LocationInfo from '~/components/LocationInfo/LocationInfo.vue'
const tabToFilterMapping = ({ tab, id }) => {
@ -246,6 +299,7 @@ export default {
OsCard,
OsButton,
OsIcon,
OsModal,
OsNumber,
OsSpinner,
SocialMedia,
@ -256,6 +310,8 @@ export default {
ProfileAvatar,
ContentMenu,
AvatarUploader,
DateTime,
UserTeaser,
MasonryGrid,
MasonryGridItem,
FollowList,
@ -288,6 +344,9 @@ export default {
followedByCount: followListVisibleCount,
followingCount: followListVisibleCount,
updateUserMutation,
showDeleteModal: false,
deleteUserData: null,
deleteLoading: false,
}
},
computed: {
@ -337,7 +396,6 @@ export default {
},
methods: {
...mapMutations({
commitModalData: 'modal/SET_OPEN',
showChat: 'chat/SET_OPEN_CHAT',
}),
handleTab(tab) {
@ -414,13 +472,36 @@ export default {
this.$apollo.queries.User.refetch()
}
},
async deleteUser(userdata) {
this.commitModalData({
name: 'delete',
data: {
userdata: userdata,
},
})
deleteUser(userdata) {
this.deleteUserData = userdata
this.showDeleteModal = true
},
cancelDeleteUser() {
this.showDeleteModal = false
this.deleteUserData = null
},
async confirmDeleteUser() {
this.deleteLoading = true
try {
await this.$apollo.mutate({
mutation: gql`
mutation ($id: ID!, $resource: [Deletable]) {
DeleteUser(id: $id, resource: $resource) {
id
}
}
`,
variables: { id: this.deleteUserData.id, resource: ['Post', 'Comment'] },
})
this.$toast.success(this.$t('settings.deleteUserAccount.success'))
this.showDeleteModal = false
this.$router.replace('/')
} catch (err) {
this.$toast.error(err.message)
this.showDeleteModal = false
} finally {
this.deleteLoading = false
}
},
optimisticFollow({ followedByCurrentUser }) {
const currentUser = this.$store.getters['auth/user']

View File

@ -3,6 +3,7 @@ import flushPromises from 'flush-promises'
import MySocialMedia from './my-social-media.vue'
import Vuex from 'vuex'
import Vue from 'vue'
import MySomethingList from '~/components/_new/features/MySomethingList/MySomethingList'
const localVue = global.localVue
@ -10,7 +11,6 @@ describe('my-social-media.vue', () => {
let wrapper
let mocks
let getters
let mutations
const socialMediaUrl = 'https://freeradical.zone/@mattwr18'
const newSocialMediaUrl = 'https://twitter.com/mattwr18'
const faviconUrl = 'https://freeradical.zone/favicon.ico'
@ -31,9 +31,6 @@ describe('my-social-media.vue', () => {
return {}
},
}
mutations = {
'modal/SET_OPEN': jest.fn(),
}
})
describe('mount', () => {
@ -41,9 +38,15 @@ describe('my-social-media.vue', () => {
const Wrapper = () => {
const store = new Vuex.Store({
getters,
mutations,
})
return mount(MySocialMedia, { store, mocks, localVue })
return mount(MySocialMedia, {
store,
mocks,
localVue,
stubs: {
'confirm-modal': { template: '<div class="confirm-modal-stub" />' },
},
})
}
describe('adding social media link', () => {
@ -158,13 +161,7 @@ describe('my-social-media.vue', () => {
})
it('opens the confirmation modal', () => {
expect(mutations['modal/SET_OPEN']).toHaveBeenCalledTimes(1)
expect(mutations['modal/SET_OPEN']).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
name: 'confirm',
}),
)
expect(wrapper.find('.confirm-modal-stub').exists()).toBe(true)
})
describe('when user confirms deletion', () => {
@ -172,8 +169,8 @@ describe('my-social-media.vue', () => {
mocks.$apollo.mutate.mockResolvedValue({
data: { DeleteSocialMedia: { id: 's1' } },
})
const modalCall = mutations['modal/SET_OPEN'].mock.calls[0][1]
modalCall.data.modalData.buttons.confirm.callback()
const mySomethingList = wrapper.findComponent(MySomethingList)
mySomethingList.vm.currentModalData.buttons.confirm.callback()
await flushPromises()
})
@ -193,8 +190,8 @@ describe('my-social-media.vue', () => {
describe('when deletion fails', () => {
beforeEach(async () => {
mocks.$apollo.mutate.mockRejectedValue(new Error('Network error'))
const modalCall = mutations['modal/SET_OPEN'].mock.calls[0][1]
modalCall.data.modalData.buttons.confirm.callback()
const mySomethingList = wrapper.findComponent(MySomethingList)
mySomethingList.vm.currentModalData.buttons.confirm.callback()
await flushPromises()
})

View File

@ -1,22 +0,0 @@
export const state = () => {
return {
open: null,
data: {},
}
}
export const mutations = {
SET_OPEN(state, ctx) {
state.open = ctx.name || null
state.data = ctx.data || {}
},
}
export const getters = {
open(state) {
return state.open
},
data(state) {
return state.data
},
}