mirror of
https://github.com/IT4Change/Ocelot-Social.git
synced 2025-12-13 07:45:56 +00:00
Merge pull request #3257 from Human-Connection/migrate-styleguide-flexbox
refactor: FilterMenu as a first step to remove ds-flex
This commit is contained in:
commit
e5b39ab83f
@ -19,3 +19,10 @@ h6,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@ -248,7 +248,9 @@ $size-ribbon: 6px;
|
||||
* @presenter Spacing
|
||||
*/
|
||||
|
||||
$size-width-filter-sidebar: 85px;
|
||||
$size-width-paginate: 100px;
|
||||
$size-max-width-filter-menu: 1026px;
|
||||
|
||||
/**
|
||||
* @tokens Size Avatar
|
||||
|
||||
80
webapp/components/FilterMenu/CategoriesFilter.spec.js
Normal file
80
webapp/components/FilterMenu/CategoriesFilter.spec.js
Normal file
@ -0,0 +1,80 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import Vuex from 'vuex'
|
||||
import CategoriesFilter from './CategoriesFilter'
|
||||
|
||||
const localVue = global.localVue
|
||||
|
||||
let wrapper, environmentAndNatureButton, democracyAndPoliticsButton
|
||||
|
||||
describe('CategoriesFilter.vue', () => {
|
||||
const mutations = {
|
||||
'posts/TOGGLE_CATEGORY': jest.fn(),
|
||||
'posts/RESET_CATEGORIES': jest.fn(),
|
||||
}
|
||||
const getters = {
|
||||
'posts/filteredCategoryIds': jest.fn(() => []),
|
||||
}
|
||||
|
||||
const mocks = {
|
||||
$t: jest.fn((string) => string),
|
||||
}
|
||||
|
||||
const Wrapper = () => {
|
||||
const store = new Vuex.Store({ mutations, getters })
|
||||
const wrapper = mount(CategoriesFilter, { mocks, localVue, store })
|
||||
wrapper.setData({
|
||||
categories: [
|
||||
{ id: 'cat4', name: 'Environment & Nature', icon: 'tree', slug: 'environment-nature' },
|
||||
{
|
||||
id: 'cat15',
|
||||
name: 'Consumption & Sustainability',
|
||||
icon: 'shopping-cart',
|
||||
slug: 'consumption-sustainability',
|
||||
},
|
||||
{
|
||||
id: 'cat9',
|
||||
name: 'Democracy & Politics',
|
||||
icon: 'university',
|
||||
slug: 'democracy-politics',
|
||||
},
|
||||
],
|
||||
})
|
||||
return wrapper
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = Wrapper()
|
||||
})
|
||||
|
||||
describe('mount', () => {
|
||||
it('starts with all categories button active', () => {
|
||||
const allCategoriesButton = wrapper.find('.categories-filter .sidebar .base-button')
|
||||
expect(allCategoriesButton.attributes().class).toContain('--filled')
|
||||
})
|
||||
|
||||
it('sets category button attribute `filled` when corresponding category is filtered', async () => {
|
||||
getters['posts/filteredCategoryIds'] = jest.fn(() => ['cat9'])
|
||||
wrapper = await Wrapper()
|
||||
democracyAndPoliticsButton = wrapper.findAll('.categories-filter .item .base-button').at(2)
|
||||
expect(democracyAndPoliticsButton.attributes().class).toContain('--filled')
|
||||
})
|
||||
|
||||
describe('click on an "catetories-buttons" button', () => {
|
||||
it('calls TOGGLE_CATEGORY when clicked', () => {
|
||||
environmentAndNatureButton = wrapper.findAll('.categories-filter .item .base-button').at(0)
|
||||
environmentAndNatureButton.trigger('click')
|
||||
expect(mutations['posts/TOGGLE_CATEGORY']).toHaveBeenCalledWith({}, 'cat4')
|
||||
})
|
||||
})
|
||||
|
||||
describe('clears filter', () => {
|
||||
it('when all button is clicked', async () => {
|
||||
getters['posts/filteredCategoryIds'] = jest.fn(() => ['cat9'])
|
||||
wrapper = await Wrapper()
|
||||
const allCategoriesButton = wrapper.find('.categories-filter .sidebar .base-button')
|
||||
allCategoriesButton.trigger('click')
|
||||
expect(mutations['posts/RESET_CATEGORIES']).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
64
webapp/components/FilterMenu/CategoriesFilter.vue
Normal file
64
webapp/components/FilterMenu/CategoriesFilter.vue
Normal file
@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<filter-menu-section :title="$t('filter-menu.categories')" class="categories-filter">
|
||||
<template #sidebar>
|
||||
<labeled-button
|
||||
:filled="!filteredCategoryIds.length"
|
||||
:label="$t('filter-menu.all')"
|
||||
icon="check"
|
||||
@click="resetCategories"
|
||||
/>
|
||||
</template>
|
||||
<template #filter-list>
|
||||
<li v-for="category in categories" :key="category.id" class="item">
|
||||
<labeled-button
|
||||
:icon="category.icon"
|
||||
:filled="filteredCategoryIds.includes(category.id)"
|
||||
:label="$t(`contribution.category.name.${category.slug}`)"
|
||||
@click="toggleCategory(category.id)"
|
||||
/>
|
||||
</li>
|
||||
</template>
|
||||
</filter-menu-section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters, mapMutations } from 'vuex'
|
||||
import CategoryQuery from '~/graphql/CategoryQuery.js'
|
||||
import FilterMenuSection from '~/components/FilterMenu/FilterMenuSection'
|
||||
import LabeledButton from '~/components/_new/generic/LabeledButton/LabeledButton'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
FilterMenuSection,
|
||||
LabeledButton,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
categories: [],
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
filteredCategoryIds: 'posts/filteredCategoryIds',
|
||||
}),
|
||||
},
|
||||
methods: {
|
||||
...mapMutations({
|
||||
resetCategories: 'posts/RESET_CATEGORIES',
|
||||
toggleCategory: 'posts/TOGGLE_CATEGORY',
|
||||
}),
|
||||
},
|
||||
apollo: {
|
||||
Category: {
|
||||
query() {
|
||||
return CategoryQuery()
|
||||
},
|
||||
update({ Category }) {
|
||||
if (!Category) return []
|
||||
this.categories = Category
|
||||
},
|
||||
fetchPolicy: 'cache-and-network',
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
64
webapp/components/FilterMenu/EmotionsFilter.spec.js
Normal file
64
webapp/components/FilterMenu/EmotionsFilter.spec.js
Normal file
@ -0,0 +1,64 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import Vuex from 'vuex'
|
||||
import EmotionsFilter from './EmotionsFilter'
|
||||
|
||||
const localVue = global.localVue
|
||||
|
||||
let wrapper, happyEmotionButton
|
||||
|
||||
describe('EmotionsFilter', () => {
|
||||
const mutations = {
|
||||
'posts/TOGGLE_EMOTION': jest.fn(),
|
||||
'posts/RESET_EMOTIONS': jest.fn(),
|
||||
}
|
||||
const getters = {
|
||||
'posts/filteredByEmotions': jest.fn(() => []),
|
||||
}
|
||||
|
||||
const mocks = {
|
||||
$t: jest.fn((string) => string),
|
||||
}
|
||||
|
||||
const Wrapper = () => {
|
||||
const store = new Vuex.Store({ mutations, getters })
|
||||
return mount(EmotionsFilter, { mocks, localVue, store })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = Wrapper()
|
||||
})
|
||||
|
||||
describe('mount', () => {
|
||||
it('starts with all emotions button active', () => {
|
||||
const allEmotionsButton = wrapper.find('.emotions-filter .sidebar .base-button')
|
||||
expect(allEmotionsButton.attributes().class).toContain('--filled')
|
||||
})
|
||||
|
||||
describe('click on an "emotion-button" button', () => {
|
||||
it('calls TOGGLE_EMOTION when clicked', () => {
|
||||
const wrapper = Wrapper()
|
||||
happyEmotionButton = wrapper.findAll('.emotion-button > .base-button').at(1)
|
||||
happyEmotionButton.trigger('click')
|
||||
expect(mutations['posts/TOGGLE_EMOTION']).toHaveBeenCalledWith({}, 'happy')
|
||||
})
|
||||
|
||||
it('sets the attribute `src` to colorized image', () => {
|
||||
getters['posts/filteredByEmotions'] = jest.fn(() => ['happy'])
|
||||
const wrapper = Wrapper()
|
||||
happyEmotionButton = wrapper.findAll('.emotion-button > .base-button').at(1)
|
||||
const happyEmotionButtonImage = happyEmotionButton.find('img')
|
||||
expect(happyEmotionButtonImage.attributes().src).toEqual('/img/svg/emoji/happy_color.svg')
|
||||
})
|
||||
})
|
||||
|
||||
describe('clears filter', () => {
|
||||
it('when all button is clicked', async () => {
|
||||
getters['posts/filteredByEmotions'] = jest.fn(() => ['happy'])
|
||||
wrapper = await Wrapper()
|
||||
const allEmotionsButton = wrapper.find('.emotions-filter .sidebar .base-button')
|
||||
allEmotionsButton.trigger('click')
|
||||
expect(mutations['posts/RESET_EMOTIONS']).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
59
webapp/components/FilterMenu/EmotionsFilter.vue
Normal file
59
webapp/components/FilterMenu/EmotionsFilter.vue
Normal file
@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<filter-menu-section :title="$t('filter-menu.emotions')" class="emotions-filter">
|
||||
<template #sidebar>
|
||||
<labeled-button
|
||||
:filled="!filteredByEmotions.length"
|
||||
icon="check"
|
||||
:label="$t('filter-menu.all')"
|
||||
@click="resetEmotions"
|
||||
/>
|
||||
</template>
|
||||
<template #filter-list>
|
||||
<li v-for="emotion in emotionsArray" :key="emotion" class="item">
|
||||
<emotion-button
|
||||
:emojiPath="iconPath(emotion)"
|
||||
:emotion="emotion"
|
||||
@toggleEmotion="toogleFilteredByEmotions(emotion)"
|
||||
/>
|
||||
</li>
|
||||
</template>
|
||||
</filter-menu-section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters, mapMutations } from 'vuex'
|
||||
import EmotionButton from '~/components/EmotionButton/EmotionButton'
|
||||
import FilterMenuSection from '~/components/FilterMenu/FilterMenuSection'
|
||||
import LabeledButton from '~/components/_new/generic/LabeledButton/LabeledButton'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
EmotionButton,
|
||||
FilterMenuSection,
|
||||
LabeledButton,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
emotionsArray: ['funny', 'happy', 'surprised', 'cry', 'angry'],
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
filteredByEmotions: 'posts/filteredByEmotions',
|
||||
currentUser: 'auth/user',
|
||||
}),
|
||||
},
|
||||
methods: {
|
||||
...mapMutations({
|
||||
resetEmotions: 'posts/RESET_EMOTIONS',
|
||||
toogleFilteredByEmotions: 'posts/TOGGLE_EMOTION',
|
||||
}),
|
||||
iconPath(emotion) {
|
||||
if (this.filteredByEmotions.includes(emotion)) {
|
||||
return `/img/svg/emoji/${emotion}_color.svg`
|
||||
}
|
||||
return `/img/svg/emoji/${emotion}.svg`
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@ -1,43 +1,46 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import Vuex from 'vuex'
|
||||
import FilterMenu from './FilterMenu.vue'
|
||||
|
||||
const localVue = global.localVue
|
||||
let wrapper
|
||||
|
||||
describe('FilterMenu.vue', () => {
|
||||
let wrapper
|
||||
let mocks
|
||||
let propsData
|
||||
const mocks = {
|
||||
$t: jest.fn((string) => string),
|
||||
}
|
||||
|
||||
const getters = {
|
||||
'posts/isActive': () => false,
|
||||
}
|
||||
|
||||
const stubs = {
|
||||
FollowingFilter: true,
|
||||
CategoriesFilter: true,
|
||||
EmotionsFilter: true,
|
||||
LanguagesFilter: true,
|
||||
}
|
||||
|
||||
const Wrapper = () => {
|
||||
const store = new Vuex.Store({ getters })
|
||||
return mount(FilterMenu, { mocks, localVue, store, stubs })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks = { $t: () => {} }
|
||||
wrapper = Wrapper()
|
||||
})
|
||||
|
||||
describe('given a hashtag', () => {
|
||||
beforeEach(() => {
|
||||
propsData = {
|
||||
hashtag: 'Frieden',
|
||||
}
|
||||
describe('mount', () => {
|
||||
it('starts with dropdown button inactive', () => {
|
||||
const dropdownButton = wrapper.find('.filter-menu .base-button')
|
||||
expect(dropdownButton.attributes().class).toContain('--ghost')
|
||||
})
|
||||
|
||||
describe('mount', () => {
|
||||
const Wrapper = () => {
|
||||
return mount(FilterMenu, { mocks, localVue, propsData })
|
||||
}
|
||||
beforeEach(() => {
|
||||
wrapper = Wrapper()
|
||||
})
|
||||
|
||||
it('renders a card', () => {
|
||||
wrapper = Wrapper()
|
||||
expect(wrapper.is('.base-card')).toBe(true)
|
||||
})
|
||||
|
||||
describe('click clear search button', () => {
|
||||
it('emits clearSearch', () => {
|
||||
wrapper.find('.base-button').trigger('click')
|
||||
expect(wrapper.emitted().clearSearch).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
it('sets dropdwon button attribute `filled` when a filter is applied', () => {
|
||||
getters['posts/isActive'] = jest.fn(() => true)
|
||||
wrapper = Wrapper()
|
||||
const dropdownButton = wrapper.find('.filter-menu .base-button')
|
||||
expect(dropdownButton.attributes().class).toContain('--filled')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,36 +1,62 @@
|
||||
<template>
|
||||
<base-card class="filter-menu">
|
||||
<h2>{{ $t('filter-menu.hashtag-search', { hashtag }) }}</h2>
|
||||
<dropdown ref="menu" placement="top-start" :offset="8" class="filter-menu">
|
||||
<base-button
|
||||
icon="close"
|
||||
circle
|
||||
:title="this.$t('filter-menu.clearSearch')"
|
||||
@click="clearSearch"
|
||||
/>
|
||||
</base-card>
|
||||
slot="default"
|
||||
icon="filter"
|
||||
:filled="filterActive"
|
||||
:ghost="!filterActive"
|
||||
slot-scope="{ toggleMenu }"
|
||||
@click.prevent="toggleMenu()"
|
||||
>
|
||||
<base-icon class="dropdown-arrow" name="angle-down" />
|
||||
</base-button>
|
||||
<template slot="popover">
|
||||
<div class="filter-menu-options">
|
||||
<h2 class="title">{{ $t('filter-menu.filter-by') }}</h2>
|
||||
<following-filter />
|
||||
<categories-filter />
|
||||
<emotions-filter />
|
||||
<languages-filter />
|
||||
</div>
|
||||
</template>
|
||||
</dropdown>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Dropdown from '~/components/Dropdown'
|
||||
import { mapGetters } from 'vuex'
|
||||
import FollowingFilter from './FollowingFilter'
|
||||
import CategoriesFilter from './CategoriesFilter'
|
||||
import EmotionsFilter from './EmotionsFilter'
|
||||
import LanguagesFilter from './LanguagesFilter'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
hashtag: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
components: {
|
||||
Dropdown,
|
||||
FollowingFilter,
|
||||
CategoriesFilter,
|
||||
EmotionsFilter,
|
||||
LanguagesFilter,
|
||||
},
|
||||
methods: {
|
||||
clearSearch() {
|
||||
this.$emit('clearSearch')
|
||||
},
|
||||
props: {
|
||||
placement: { type: String },
|
||||
offset: { type: [String, Number] },
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
filterActive: 'posts/isActive',
|
||||
}),
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.filter-menu.base-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: $space-x-small $space-base;
|
||||
.filter-menu-options {
|
||||
max-width: $size-max-width-filter-menu;
|
||||
padding: $space-small $space-x-small;
|
||||
|
||||
> .title {
|
||||
font-size: $font-size-large;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
94
webapp/components/FilterMenu/FilterMenuSection.vue
Normal file
94
webapp/components/FilterMenu/FilterMenuSection.vue
Normal file
@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<section class="filter-menu-section">
|
||||
<h3 v-if="title" class="title">{{ title }}</h3>
|
||||
<aside class="sidebar">
|
||||
<slot name="sidebar" />
|
||||
</aside>
|
||||
<div v-if="divider" class="divider" />
|
||||
<ul class="filter-list">
|
||||
<slot name="filter-list" />
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
divider: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.filter-menu-section {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin-top: $space-small;
|
||||
|
||||
> .title {
|
||||
width: 100%;
|
||||
margin-bottom: $space-small;
|
||||
font-size: $font-size-base;
|
||||
}
|
||||
|
||||
> .sidebar {
|
||||
flex-basis: 12%;
|
||||
max-width: $size-width-filter-sidebar;
|
||||
}
|
||||
|
||||
> .divider {
|
||||
border-left: $border-size-base solid $border-color-soft;
|
||||
margin: $space-small;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
> .filter-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-basis: 80%;
|
||||
flex-grow: 1;
|
||||
|
||||
> .item {
|
||||
width: 12.5%;
|
||||
padding: 0 $space-x-small;
|
||||
margin-bottom: $space-small;
|
||||
text-align: center;
|
||||
|
||||
@media only screen and (max-width: 800px) {
|
||||
width: 16%;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 630px) {
|
||||
width: 25%;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 440px) {
|
||||
width: 50%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 630px) {
|
||||
flex-direction: column;
|
||||
|
||||
> .title {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
> .sidebar {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
> .divider {
|
||||
border-top: $border-size-base solid $border-color-soft;
|
||||
margin: $space-small;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
48
webapp/components/FilterMenu/FollowingFilter.spec.js
Normal file
48
webapp/components/FilterMenu/FollowingFilter.spec.js
Normal file
@ -0,0 +1,48 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import Vuex from 'vuex'
|
||||
import FollowingFilter from './FollowingFilter'
|
||||
|
||||
const localVue = global.localVue
|
||||
|
||||
let wrapper
|
||||
|
||||
describe('FollowingFilter', () => {
|
||||
const mutations = {
|
||||
'posts/TOGGLE_FILTER_BY_FOLLOWED': jest.fn(),
|
||||
}
|
||||
const getters = {
|
||||
'auth/user': () => {
|
||||
return { id: 'u34' }
|
||||
},
|
||||
'posts/filteredByUsersFollowed': jest.fn(),
|
||||
}
|
||||
|
||||
const mocks = {
|
||||
$t: jest.fn((string) => string),
|
||||
}
|
||||
|
||||
const Wrapper = () => {
|
||||
const store = new Vuex.Store({ mutations, getters })
|
||||
const wrapper = mount(FollowingFilter, { mocks, localVue, store })
|
||||
return wrapper
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = Wrapper()
|
||||
})
|
||||
|
||||
describe('mount', () => {
|
||||
it('sets "filter-by-followed" button attribute `filled`', () => {
|
||||
getters['posts/filteredByUsersFollowed'] = jest.fn(() => true)
|
||||
const wrapper = Wrapper()
|
||||
expect(wrapper.find('.following-filter .sidebar .base-button').classes('--filled')).toBe(true)
|
||||
})
|
||||
|
||||
describe('click "filter-by-followed" button', () => {
|
||||
it('calls TOGGLE_FILTER_BY_FOLLOWED', () => {
|
||||
wrapper.find('.following-filter .sidebar .base-button').trigger('click')
|
||||
expect(mutations['posts/TOGGLE_FILTER_BY_FOLLOWED']).toHaveBeenCalledWith({}, 'u34')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
37
webapp/components/FilterMenu/FollowingFilter.vue
Normal file
37
webapp/components/FilterMenu/FollowingFilter.vue
Normal file
@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<filter-menu-section :divider="false" class="following-filter">
|
||||
<template #sidebar>
|
||||
<labeled-button
|
||||
icon="user-plus"
|
||||
:label="$t('filter-menu.following')"
|
||||
:filled="filteredByUsersFollowed"
|
||||
:title="$t('contribution.filterFollow')"
|
||||
@click="toggleFilteredByFollowed(currentUser.id)"
|
||||
/>
|
||||
</template>
|
||||
</filter-menu-section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters, mapMutations } from 'vuex'
|
||||
import FilterMenuSection from '~/components/FilterMenu/FilterMenuSection'
|
||||
import LabeledButton from '~/components/_new/generic/LabeledButton/LabeledButton'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
FilterMenuSection,
|
||||
LabeledButton,
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
filteredByUsersFollowed: 'posts/filteredByUsersFollowed',
|
||||
currentUser: 'auth/user',
|
||||
}),
|
||||
},
|
||||
methods: {
|
||||
...mapMutations({
|
||||
toggleFilteredByFollowed: 'posts/TOGGLE_FILTER_BY_FOLLOWED',
|
||||
}),
|
||||
},
|
||||
}
|
||||
</script>
|
||||
70
webapp/components/FilterMenu/LanguagesFilter.spec.js
Normal file
70
webapp/components/FilterMenu/LanguagesFilter.spec.js
Normal file
@ -0,0 +1,70 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import Vuex from 'vuex'
|
||||
import locales from '~/locales'
|
||||
import orderBy from 'lodash/orderBy'
|
||||
import LanguagesFilter from './LanguagesFilter'
|
||||
const localVue = global.localVue
|
||||
|
||||
let wrapper, englishButton, spanishButton
|
||||
|
||||
const languages = orderBy(locales, 'name')
|
||||
|
||||
describe('LanguagesFilter.vue', () => {
|
||||
const mutations = {
|
||||
'posts/TOGGLE_LANGUAGE': jest.fn(),
|
||||
'posts/RESET_LANGUAGES': jest.fn(),
|
||||
}
|
||||
const getters = {
|
||||
'posts/filteredLanguageCodes': jest.fn(() => []),
|
||||
}
|
||||
|
||||
const mocks = {
|
||||
$t: jest.fn((string) => string),
|
||||
}
|
||||
|
||||
const Wrapper = () => {
|
||||
const store = new Vuex.Store({ mutations, getters })
|
||||
return mount(LanguagesFilter, { mocks, localVue, store })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = Wrapper()
|
||||
})
|
||||
|
||||
describe('mount', () => {
|
||||
it('starts with all categories button active', () => {
|
||||
const allLanguagesButton = wrapper.find('.languages-filter .sidebar .base-button')
|
||||
expect(allLanguagesButton.attributes().class).toContain('--filled')
|
||||
})
|
||||
|
||||
it('sets language button attribute `filled` when corresponding language is filtered', () => {
|
||||
getters['posts/filteredLanguageCodes'] = jest.fn(() => ['es'])
|
||||
const wrapper = Wrapper()
|
||||
spanishButton = wrapper
|
||||
.findAll('.languages-filter .item .base-button')
|
||||
.at(languages.findIndex((l) => l.code === 'es'))
|
||||
expect(spanishButton.attributes().class).toContain('--filled')
|
||||
})
|
||||
|
||||
describe('click on an "language-button" button', () => {
|
||||
it('calls TOGGLE_LANGUAGE when clicked', () => {
|
||||
const wrapper = Wrapper()
|
||||
englishButton = wrapper
|
||||
.findAll('.languages-filter .item .base-button')
|
||||
.at(languages.findIndex((l) => l.code === 'en'))
|
||||
englishButton.trigger('click')
|
||||
expect(mutations['posts/TOGGLE_LANGUAGE']).toHaveBeenCalledWith({}, 'en')
|
||||
})
|
||||
})
|
||||
|
||||
describe('clears filter', () => {
|
||||
it('when all button is clicked', async () => {
|
||||
getters['posts/filteredLanguageCodes'] = jest.fn(() => ['en'])
|
||||
wrapper = await Wrapper()
|
||||
const allLanguagesButton = wrapper.find('.languages-filter .sidebar .base-button')
|
||||
allLanguagesButton.trigger('click')
|
||||
expect(mutations['posts/RESET_LANGUAGES']).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
54
webapp/components/FilterMenu/LanguagesFilter.vue
Normal file
54
webapp/components/FilterMenu/LanguagesFilter.vue
Normal file
@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<filter-menu-section :title="$t('filter-menu.languages')" class="languages-filter">
|
||||
<template #sidebar>
|
||||
<labeled-button
|
||||
:filled="!filteredLanguageCodes.length"
|
||||
:label="$t('filter-menu.all')"
|
||||
icon="check"
|
||||
@click="resetLanguages"
|
||||
/>
|
||||
</template>
|
||||
<template #filter-list>
|
||||
<li v-for="language in locales" :key="language.code" class="item">
|
||||
<base-button
|
||||
:filled="filteredLanguageCodes.includes(language.code)"
|
||||
circle
|
||||
@click="toggleLanguage(language.code)"
|
||||
>
|
||||
{{ language.code.toUpperCase() }}
|
||||
</base-button>
|
||||
</li>
|
||||
</template>
|
||||
</filter-menu-section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters, mapMutations } from 'vuex'
|
||||
import orderBy from 'lodash/orderBy'
|
||||
import locales from '~/locales'
|
||||
import FilterMenuSection from '~/components/FilterMenu/FilterMenuSection'
|
||||
import LabeledButton from '~/components/_new/generic/LabeledButton/LabeledButton'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
FilterMenuSection,
|
||||
LabeledButton,
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
filteredLanguageCodes: 'posts/filteredLanguageCodes',
|
||||
}),
|
||||
},
|
||||
methods: {
|
||||
...mapMutations({
|
||||
resetLanguages: 'posts/RESET_LANGUAGES',
|
||||
toggleLanguage: 'posts/TOGGLE_LANGUAGE',
|
||||
}),
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
locales: orderBy(locales, 'name'),
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@ -1,103 +0,0 @@
|
||||
<template>
|
||||
<ds-space margin-top="large">
|
||||
<ds-flex id="filter-posts-header">
|
||||
<ds-heading tag="h4">{{ $t('filter-posts.categories.header') }}</ds-heading>
|
||||
<ds-space margin-bottom="large" />
|
||||
</ds-flex>
|
||||
<ds-flex :gutter="{ lg: 'small' }">
|
||||
<ds-flex-item
|
||||
:width="{ base: '100%', sm: '100%', md: '100%', lg: '5%' }"
|
||||
class="categories-menu-item"
|
||||
>
|
||||
<ds-flex>
|
||||
<ds-flex-item width="10%" />
|
||||
<ds-flex-item width="100%">
|
||||
<base-button
|
||||
circle
|
||||
icon="check"
|
||||
@click="resetCategories"
|
||||
:filled="!filteredCategoryIds.length"
|
||||
/>
|
||||
<ds-flex-item>
|
||||
<label class="category-labels">{{ $t('filter-posts.categories.all') }}</label>
|
||||
</ds-flex-item>
|
||||
<ds-space />
|
||||
</ds-flex-item>
|
||||
</ds-flex>
|
||||
</ds-flex-item>
|
||||
<ds-flex-item :width="{ base: '0%', sm: '0%', md: '0%', lg: '4%' }" />
|
||||
<ds-flex-item
|
||||
:width="{ base: '0%', sm: '0%', md: '0%', lg: '3%' }"
|
||||
id="categories-menu-divider"
|
||||
/>
|
||||
<ds-flex-item
|
||||
:width="{ base: '50%', sm: '50%', md: '50%', lg: '11%' }"
|
||||
v-for="index in chunk.length"
|
||||
:key="index"
|
||||
>
|
||||
<ds-flex v-for="category in chunk[index - 1]" :key="category.id" class="categories-menu">
|
||||
<ds-flex class="categories-menu">
|
||||
<ds-flex-item width="100%" class="categories-menu-item">
|
||||
<base-button
|
||||
circle
|
||||
:icon="category.icon"
|
||||
:filled="filteredCategoryIds.includes(category.id)"
|
||||
@click="toggleCategory(category.id)"
|
||||
/>
|
||||
<ds-space margin-bottom="small" />
|
||||
</ds-flex-item>
|
||||
<ds-flex>
|
||||
<ds-flex-item class="categories-menu-item">
|
||||
<label class="category-labels">
|
||||
{{ $t(`contribution.category.name.${category.slug}`) }}
|
||||
</label>
|
||||
</ds-flex-item>
|
||||
<ds-space margin-bottom="xx-large" />
|
||||
</ds-flex>
|
||||
</ds-flex>
|
||||
</ds-flex>
|
||||
</ds-flex-item>
|
||||
</ds-flex>
|
||||
</ds-space>
|
||||
</template>
|
||||
<script>
|
||||
import { mapGetters, mapMutations } from 'vuex'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
chunk: { type: Array, default: () => [] },
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
filteredCategoryIds: 'posts/filteredCategoryIds',
|
||||
}),
|
||||
},
|
||||
methods: {
|
||||
...mapMutations({
|
||||
resetCategories: 'posts/RESET_CATEGORIES',
|
||||
toggleCategory: 'posts/TOGGLE_CATEGORY',
|
||||
}),
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style lang="scss">
|
||||
.categories-menu-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.categories-menu {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.category-labels,
|
||||
.follow-label {
|
||||
font-size: $font-size-small;
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 960px) {
|
||||
#categories-menu-divider {
|
||||
border-left: 1px solid $border-color-soft;
|
||||
margin: 9px 0px 40px 0px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,167 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
import Vuex from 'vuex'
|
||||
import FilterPosts from './FilterPosts.vue'
|
||||
import locales from '~/locales'
|
||||
import orderBy from 'lodash/orderBy'
|
||||
|
||||
const localVue = global.localVue
|
||||
|
||||
let mutations
|
||||
let getters
|
||||
|
||||
const languages = orderBy(locales, 'name')
|
||||
|
||||
describe('FilterPosts.vue', () => {
|
||||
let mocks
|
||||
let propsData
|
||||
let menuToggle
|
||||
let allCategoriesButton
|
||||
let environmentAndNatureButton
|
||||
let democracyAndPoliticsButton
|
||||
let happyEmotionButton
|
||||
let englishButton
|
||||
let spanishButton
|
||||
|
||||
beforeEach(() => {
|
||||
mocks = {
|
||||
$apollo: {
|
||||
query: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
data: { Post: { title: 'Post with Category', category: [{ id: 'cat4' }] } },
|
||||
})
|
||||
.mockRejectedValue({ message: 'We were unable to filter' }),
|
||||
},
|
||||
$t: jest.fn(),
|
||||
$i18n: {
|
||||
locale: () => 'en',
|
||||
},
|
||||
$toast: {
|
||||
error: jest.fn(),
|
||||
},
|
||||
}
|
||||
propsData = {
|
||||
categories: [
|
||||
{ id: 'cat4', name: 'Environment & Nature', icon: 'tree' },
|
||||
{ id: 'cat15', name: 'Consumption & Sustainability', icon: 'shopping-cart' },
|
||||
{ id: 'cat9', name: 'Democracy & Politics', icon: 'university' },
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
describe('mount', () => {
|
||||
mutations = {
|
||||
'posts/TOGGLE_FILTER_BY_FOLLOWED': jest.fn(),
|
||||
'posts/RESET_CATEGORIES': jest.fn(),
|
||||
'posts/TOGGLE_CATEGORY': jest.fn(),
|
||||
'posts/TOGGLE_EMOTION': jest.fn(),
|
||||
'posts/TOGGLE_LANGUAGE': jest.fn(),
|
||||
'posts/RESET_LANGUAGES': jest.fn(),
|
||||
}
|
||||
getters = {
|
||||
'posts/isActive': () => false,
|
||||
'auth/isModerator': () => false,
|
||||
'auth/user': () => {
|
||||
return { id: 'u34' }
|
||||
},
|
||||
'posts/filteredCategoryIds': jest.fn(() => []),
|
||||
'posts/filteredByUsersFollowed': jest.fn(),
|
||||
'posts/filteredByEmotions': jest.fn(() => []),
|
||||
'posts/filteredLanguageCodes': jest.fn(() => []),
|
||||
}
|
||||
const openFilterPosts = () => {
|
||||
const store = new Vuex.Store({ mutations, getters })
|
||||
const wrapper = mount(FilterPosts, { mocks, localVue, propsData, store })
|
||||
menuToggle = wrapper.findAll('button').at(0)
|
||||
menuToggle.trigger('click')
|
||||
return wrapper
|
||||
}
|
||||
|
||||
it('groups the categories by pair', () => {
|
||||
const wrapper = openFilterPosts()
|
||||
expect(wrapper.vm.chunk).toEqual([
|
||||
[
|
||||
{ id: 'cat4', name: 'Environment & Nature', icon: 'tree' },
|
||||
{ id: 'cat15', name: 'Consumption & Sustainability', icon: 'shopping-cart' },
|
||||
],
|
||||
[{ id: 'cat9', name: 'Democracy & Politics', icon: 'university' }],
|
||||
])
|
||||
})
|
||||
|
||||
it('starts with all categories button active', () => {
|
||||
const wrapper = openFilterPosts()
|
||||
allCategoriesButton = wrapper.findAll('button').at(1)
|
||||
expect(allCategoriesButton.attributes().class).toContain('--filled')
|
||||
})
|
||||
|
||||
it('calls TOGGLE_CATEGORY when clicked', () => {
|
||||
const wrapper = openFilterPosts()
|
||||
environmentAndNatureButton = wrapper.findAll('button').at(2)
|
||||
environmentAndNatureButton.trigger('click')
|
||||
expect(mutations['posts/TOGGLE_CATEGORY']).toHaveBeenCalledWith({}, 'cat4')
|
||||
})
|
||||
|
||||
it('calls TOGGLE_LANGUAGE when clicked', () => {
|
||||
const wrapper = openFilterPosts()
|
||||
englishButton = wrapper
|
||||
.findAll('button.language-buttons')
|
||||
.at(languages.findIndex((l) => l.code === 'en'))
|
||||
englishButton.trigger('click')
|
||||
expect(mutations['posts/TOGGLE_LANGUAGE']).toHaveBeenCalledWith({}, 'en')
|
||||
})
|
||||
|
||||
it('sets category button attribute `filled` when corresponding category is filtered', () => {
|
||||
getters['posts/filteredCategoryIds'] = jest.fn(() => ['cat9'])
|
||||
const wrapper = openFilterPosts()
|
||||
democracyAndPoliticsButton = wrapper.findAll('button').at(4)
|
||||
expect(democracyAndPoliticsButton.attributes().class).toContain('--filled')
|
||||
})
|
||||
|
||||
it('sets language button attribute `filled` when corresponding language is filtered', () => {
|
||||
getters['posts/filteredLanguageCodes'] = jest.fn(() => ['es'])
|
||||
const wrapper = openFilterPosts()
|
||||
spanishButton = wrapper
|
||||
.findAll('button.language-buttons')
|
||||
.at(languages.findIndex((l) => l.code === 'es'))
|
||||
expect(spanishButton.attributes().class).toContain('--filled')
|
||||
})
|
||||
|
||||
it('sets "filter-by-followed" button attribute `filled`', () => {
|
||||
getters['posts/filteredByUsersFollowed'] = jest.fn(() => true)
|
||||
const wrapper = openFilterPosts()
|
||||
expect(wrapper.find('.base-button[data-test="filter-by-followed"]').classes('--filled')).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
describe('click "filter-by-followed" button', () => {
|
||||
let wrapper
|
||||
beforeEach(() => {
|
||||
wrapper = openFilterPosts()
|
||||
wrapper.find('.base-button[data-test="filter-by-followed"]').trigger('click')
|
||||
})
|
||||
|
||||
it('calls TOGGLE_FILTER_BY_FOLLOWED', () => {
|
||||
expect(mutations['posts/TOGGLE_FILTER_BY_FOLLOWED']).toHaveBeenCalledWith({}, 'u34')
|
||||
})
|
||||
})
|
||||
|
||||
describe('click on an "emotions-buttons" button', () => {
|
||||
it('calls TOGGLE_EMOTION when clicked', () => {
|
||||
const wrapper = openFilterPosts()
|
||||
happyEmotionButton = wrapper.findAll('.emotion-button .base-button').at(1)
|
||||
happyEmotionButton.trigger('click')
|
||||
expect(mutations['posts/TOGGLE_EMOTION']).toHaveBeenCalledWith({}, 'happy')
|
||||
})
|
||||
|
||||
it('sets the attribute `src` to colorized image', () => {
|
||||
getters['posts/filteredByEmotions'] = jest.fn(() => ['happy'])
|
||||
const wrapper = openFilterPosts()
|
||||
happyEmotionButton = wrapper.findAll('.emotion-button .base-button').at(1)
|
||||
const happyEmotionButtonImage = happyEmotionButton.find('img')
|
||||
expect(happyEmotionButtonImage.attributes().src).toEqual('/img/svg/emoji/happy_color.svg')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,52 +0,0 @@
|
||||
<template>
|
||||
<dropdown ref="menu" :placement="placement" :offset="offset">
|
||||
<template #default="{ toggleMenu }">
|
||||
<base-button
|
||||
icon="filter"
|
||||
:filled="filterActive"
|
||||
:ghost="!filterActive"
|
||||
@click.prevent="toggleMenu()"
|
||||
>
|
||||
<base-icon class="dropdown-arrow" name="angle-down" />
|
||||
</base-button>
|
||||
</template>
|
||||
<template slot="popover">
|
||||
<ds-container>
|
||||
<categories-filter-menu-items :chunk="chunk" />
|
||||
<general-filter-menu-items :user="currentUser" />
|
||||
<language-filter-menu-items :user="currentUser" />
|
||||
</ds-container>
|
||||
</template>
|
||||
</dropdown>
|
||||
</template>
|
||||
<script>
|
||||
import { chunk } from 'lodash'
|
||||
import Dropdown from '~/components/Dropdown'
|
||||
import { mapGetters } from 'vuex'
|
||||
import CategoriesFilterMenuItems from './CategoriesFilterMenuItems'
|
||||
import GeneralFilterMenuItems from './GeneralFilterMenuItems'
|
||||
import LanguageFilterMenuItems from './LanguageFilterMenuItems'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Dropdown,
|
||||
CategoriesFilterMenuItems,
|
||||
GeneralFilterMenuItems,
|
||||
LanguageFilterMenuItems,
|
||||
},
|
||||
props: {
|
||||
placement: { type: String },
|
||||
offset: { type: [String, Number] },
|
||||
categories: { type: Array, default: () => [] },
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
currentUser: 'auth/user',
|
||||
filterActive: 'posts/isActive',
|
||||
}),
|
||||
chunk() {
|
||||
return chunk(this.categories, 2)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@ -1,103 +0,0 @@
|
||||
<template>
|
||||
<ds-space>
|
||||
<ds-flex id="filter-posts-by-followers-header">
|
||||
<ds-heading tag="h4">{{ $t('filter-posts.general.header') }}</ds-heading>
|
||||
<ds-space margin-bottom="large" />
|
||||
</ds-flex>
|
||||
<ds-flex :gutter="{ lg: 'large' }">
|
||||
<ds-flex-item
|
||||
:width="{ base: '100%', sm: '100%', md: '10%', lg: '10%' }"
|
||||
class="follow-filter"
|
||||
>
|
||||
<base-button
|
||||
data-test="filter-by-followed"
|
||||
icon="user-plus"
|
||||
circle
|
||||
:filled="filteredByUsersFollowed"
|
||||
@click="toggleFilteredByFollowed(user.id)"
|
||||
v-tooltip="{
|
||||
content: this.$t('contribution.filterFollow'),
|
||||
placement: 'left',
|
||||
delay: { show: 500 },
|
||||
}"
|
||||
/>
|
||||
<label class="follow-label">{{ $t('filter-posts.followers.label') }}</label>
|
||||
</ds-flex-item>
|
||||
<emotion-button
|
||||
v-for="emotion in emotionsArray"
|
||||
:key="emotion"
|
||||
:emojiPath="iconPath(emotion)"
|
||||
:emotion="emotion"
|
||||
@toggleEmotion="toogleFilteredByEmotions(emotion)"
|
||||
/>
|
||||
<ds-space margin-bottom="large" />
|
||||
</ds-flex>
|
||||
</ds-space>
|
||||
</template>
|
||||
<script>
|
||||
import { mapGetters, mapMutations } from 'vuex'
|
||||
import EmotionButton from '~/components/EmotionButton/EmotionButton'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
EmotionButton,
|
||||
},
|
||||
props: {
|
||||
user: { type: Object, required: true },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
emotionsArray: ['funny', 'happy', 'surprised', 'cry', 'angry'],
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
filteredByUsersFollowed: 'posts/filteredByUsersFollowed',
|
||||
filteredByEmotions: 'posts/filteredByEmotions',
|
||||
}),
|
||||
},
|
||||
methods: {
|
||||
...mapMutations({
|
||||
toggleFilteredByFollowed: 'posts/TOGGLE_FILTER_BY_FOLLOWED',
|
||||
toogleFilteredByEmotions: 'posts/TOGGLE_EMOTION',
|
||||
}),
|
||||
iconPath(emotion) {
|
||||
if (this.filteredByEmotions.includes(emotion)) {
|
||||
return `/img/svg/emoji/${emotion}_color.svg`
|
||||
}
|
||||
return `/img/svg/emoji/${emotion}.svg`
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style lang="scss">
|
||||
#filter-posts-header {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#filter-posts-by-followers-header {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.follow-filter.ds-flex-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: $space-base;
|
||||
|
||||
> .follow-label {
|
||||
margin-top: $space-x-small;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 960px) {
|
||||
#filter-posts-header {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@ -1,106 +0,0 @@
|
||||
<template>
|
||||
<ds-space margin-top="large">
|
||||
<ds-flex id="filter-posts-header">
|
||||
<ds-heading tag="h4">{{ $t('filter-posts.language.header') }}</ds-heading>
|
||||
<ds-space margin-bottom="large" />
|
||||
</ds-flex>
|
||||
<ds-flex :gutter="{ lg: 'small' }">
|
||||
<ds-flex-item
|
||||
:width="{ base: '100%', sm: '100%', md: '100%', lg: '5%' }"
|
||||
class="language-menu-item"
|
||||
>
|
||||
<ds-flex>
|
||||
<ds-flex-item width="10%" />
|
||||
<ds-flex-item width="100%">
|
||||
<base-button
|
||||
icon="check"
|
||||
circle
|
||||
:filled="!filteredLanguageCodes.length"
|
||||
@click="resetLanguages"
|
||||
/>
|
||||
<ds-flex-item>
|
||||
<label class="language-labels">{{ $t('filter-posts.language.all') }}</label>
|
||||
</ds-flex-item>
|
||||
<ds-space />
|
||||
</ds-flex-item>
|
||||
</ds-flex>
|
||||
</ds-flex-item>
|
||||
<ds-flex-item :width="{ base: '0%', sm: '0%', md: '0%', lg: '4%' }" />
|
||||
<ds-flex-item
|
||||
:width="{ base: '0%', sm: '0%', md: '0%', lg: '3%' }"
|
||||
id="languages-menu-divider"
|
||||
/>
|
||||
<ds-flex v-for="language in locales" :key="language.code" class="languages-menu">
|
||||
<ds-flex class="languages-menu">
|
||||
<ds-flex-item width="100%" class="language-menu-item">
|
||||
<base-button
|
||||
class="language-buttons"
|
||||
circle
|
||||
:filled="filteredLanguageCodes.includes(language.code)"
|
||||
@click="toggleLanguage(language.code)"
|
||||
>
|
||||
{{ language.code.toUpperCase() }}
|
||||
</base-button>
|
||||
<ds-space margin-bottom="small" />
|
||||
</ds-flex-item>
|
||||
<ds-flex>
|
||||
<ds-flex-item class="language-menu-item">
|
||||
<label class="language-labels">
|
||||
{{ language.name }}
|
||||
</label>
|
||||
</ds-flex-item>
|
||||
<ds-space margin-bottom="xx-large" />
|
||||
</ds-flex>
|
||||
</ds-flex>
|
||||
</ds-flex>
|
||||
</ds-flex>
|
||||
</ds-space>
|
||||
</template>
|
||||
<script>
|
||||
import locales from '~/locales'
|
||||
import orderBy from 'lodash/orderBy'
|
||||
import { mapGetters, mapMutations } from 'vuex'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
chunk: { type: Array, default: () => [] },
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
filteredLanguageCodes: 'posts/filteredLanguageCodes',
|
||||
}),
|
||||
},
|
||||
methods: {
|
||||
...mapMutations({
|
||||
resetLanguages: 'posts/RESET_LANGUAGES',
|
||||
toggleLanguage: 'posts/TOGGLE_LANGUAGE',
|
||||
}),
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
locales: orderBy(locales, 'name'),
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style lang="scss">
|
||||
.language-menu-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.languages-menu {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.language-labels,
|
||||
.follow-label {
|
||||
font-size: $font-size-small;
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 960px) {
|
||||
#languages-menu-divider {
|
||||
border-left: 1px solid $border-color-soft;
|
||||
margin: 9px 0px 40px 0px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
43
webapp/components/HashtagsFilter/HashtagsFilter.spec.js
Normal file
43
webapp/components/HashtagsFilter/HashtagsFilter.spec.js
Normal file
@ -0,0 +1,43 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import HashtagsFilter from './HashtagsFilter.vue'
|
||||
|
||||
const localVue = global.localVue
|
||||
|
||||
describe('HashtagsFilter.vue', () => {
|
||||
let wrapper
|
||||
let mocks
|
||||
let propsData
|
||||
|
||||
beforeEach(() => {
|
||||
mocks = { $t: () => {} }
|
||||
})
|
||||
|
||||
describe('given a hashtag', () => {
|
||||
beforeEach(() => {
|
||||
propsData = {
|
||||
hashtag: 'Frieden',
|
||||
}
|
||||
})
|
||||
|
||||
describe('mount', () => {
|
||||
const Wrapper = () => {
|
||||
return mount(HashtagsFilter, { mocks, localVue, propsData })
|
||||
}
|
||||
beforeEach(() => {
|
||||
wrapper = Wrapper()
|
||||
})
|
||||
|
||||
it('renders a card', () => {
|
||||
wrapper = Wrapper()
|
||||
expect(wrapper.is('.base-card')).toBe(true)
|
||||
})
|
||||
|
||||
describe('click clear search button', () => {
|
||||
it('emits clearSearch', () => {
|
||||
wrapper.find('.base-button').trigger('click')
|
||||
expect(wrapper.emitted().clearSearch).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
36
webapp/components/HashtagsFilter/HashtagsFilter.vue
Normal file
36
webapp/components/HashtagsFilter/HashtagsFilter.vue
Normal file
@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<base-card class="hashtags-filter">
|
||||
<h2>{{ $t('hashtags-filter.hashtag-search', { hashtag }) }}</h2>
|
||||
<base-button
|
||||
icon="close"
|
||||
circle
|
||||
:title="this.$t('hashtags-filter.clearSearch')"
|
||||
@click="clearSearch"
|
||||
/>
|
||||
</base-card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
hashtag: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
clearSearch() {
|
||||
this.$emit('clearSearch')
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.hashtags-filter.base-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: $space-x-small $space-base;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,22 @@
|
||||
import { storiesOf } from '@storybook/vue'
|
||||
import helpers from '~/storybook/helpers'
|
||||
import LabeledButton from './LabeledButton.vue'
|
||||
|
||||
helpers.init()
|
||||
|
||||
storiesOf('Generic/LabeledButton', module)
|
||||
.addDecorator(helpers.layout)
|
||||
.add('default', () => ({
|
||||
components: { LabeledButton },
|
||||
data: () => ({
|
||||
filled: false,
|
||||
}),
|
||||
template: `
|
||||
<labeled-button
|
||||
icon="check"
|
||||
:filled="filled"
|
||||
label="Toggle Me!!"
|
||||
@click="filled = !filled"
|
||||
/>
|
||||
`,
|
||||
}))
|
||||
@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<div class="labeled-button">
|
||||
<base-button circle :icon="icon" :filled="filled" @click="(event) => $emit('click', event)" />
|
||||
<label class="label">{{ label }}</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
filled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.labeled-button {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
> .label {
|
||||
margin-top: $space-x-small;
|
||||
font-size: $font-size-small;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -30,12 +30,7 @@
|
||||
style="flex-grow: 0; flex-basis: auto;"
|
||||
>
|
||||
<client-only>
|
||||
<filter-posts
|
||||
v-show="showFilterPostsDropdown"
|
||||
placement="top-start"
|
||||
offset="8"
|
||||
:categories="categories"
|
||||
/>
|
||||
<filter-menu v-show="showFilterMenuDropdown" />
|
||||
</client-only>
|
||||
</ds-flex-item>
|
||||
<ds-flex-item
|
||||
@ -85,8 +80,7 @@ import SearchField from '~/components/features/SearchField/SearchField.vue'
|
||||
import Modal from '~/components/Modal'
|
||||
import NotificationMenu from '~/components/NotificationMenu/NotificationMenu'
|
||||
import seo from '~/mixins/seo'
|
||||
import FilterPosts from '~/components/FilterPosts/FilterPosts.vue'
|
||||
import CategoryQuery from '~/graphql/CategoryQuery.js'
|
||||
import FilterMenu from '~/components/FilterMenu/FilterMenu.vue'
|
||||
import PageFooter from '~/components/PageFooter/PageFooter'
|
||||
import AvatarMenu from '~/components/AvatarMenu/AvatarMenu'
|
||||
|
||||
@ -97,7 +91,7 @@ export default {
|
||||
Modal,
|
||||
NotificationMenu,
|
||||
AvatarMenu,
|
||||
FilterPosts,
|
||||
FilterMenu,
|
||||
PageFooter,
|
||||
},
|
||||
mixins: [seo],
|
||||
@ -105,36 +99,22 @@ export default {
|
||||
return {
|
||||
mobileSearchVisible: false,
|
||||
toggleMobileMenu: false,
|
||||
categories: [],
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
isLoggedIn: 'auth/isLoggedIn',
|
||||
}),
|
||||
showFilterPostsDropdown() {
|
||||
showFilterMenuDropdown() {
|
||||
const [firstRoute] = this.$route.matched
|
||||
return firstRoute && firstRoute.name === 'index'
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
Category(category) {
|
||||
this.categories = category || []
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
toggleMobileMenuView() {
|
||||
this.toggleMobileMenu = !this.toggleMobileMenu
|
||||
},
|
||||
},
|
||||
apollo: {
|
||||
Category: {
|
||||
query() {
|
||||
return CategoryQuery()
|
||||
},
|
||||
fetchPolicy: 'cache-and-network',
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@ -332,30 +332,22 @@
|
||||
"profile-not-found": "Dieses Profil konnte nicht gefunden werden"
|
||||
},
|
||||
"filter-menu": {
|
||||
"clearSearch": "Suche löschen",
|
||||
"hashtag-search": "Suche nach #{hashtag}",
|
||||
"title": "Deine Filterblase"
|
||||
},
|
||||
"filter-posts": {
|
||||
"categories": {
|
||||
"all": "Alle",
|
||||
"header": "Themenkategorien"
|
||||
},
|
||||
"followers": {
|
||||
"label": "Benutzern, denen ich folge"
|
||||
},
|
||||
"general": {
|
||||
"header": "Filtern nach …"
|
||||
},
|
||||
"language": {
|
||||
"all": "Alle",
|
||||
"header": "Sprachen"
|
||||
}
|
||||
"all": "Alle",
|
||||
"categories": "Themenkategorien",
|
||||
"emotions": "Emotionen",
|
||||
"filter-by": "Filtern nach...",
|
||||
"following": "Benutzern, denen ich folge",
|
||||
"languages": "Sprachen"
|
||||
},
|
||||
"followButton": {
|
||||
"follow": "Folgen",
|
||||
"following": "Folge Ich"
|
||||
},
|
||||
"hashtags-filter": {
|
||||
"clearSearch": "Suche löschen",
|
||||
"hashtag-search": "Suche nach #{hashtag}",
|
||||
"title": "Deine Filterblase"
|
||||
},
|
||||
"index": {
|
||||
"change-filter-settings": "Verändere die Filter-Einstellungen, um mehr Ergebnisse zu erhalten.",
|
||||
"no-results": "Keine Beiträge gefunden."
|
||||
|
||||
@ -332,30 +332,22 @@
|
||||
"profile-not-found": "This profile could not be found"
|
||||
},
|
||||
"filter-menu": {
|
||||
"clearSearch": "Clear search",
|
||||
"hashtag-search": "Searching for #{hashtag}",
|
||||
"title": "Your filter bubble"
|
||||
},
|
||||
"filter-posts": {
|
||||
"categories": {
|
||||
"all": "All",
|
||||
"header": "Categories of Content"
|
||||
},
|
||||
"followers": {
|
||||
"label": "Users I follow"
|
||||
},
|
||||
"general": {
|
||||
"header": "Filter by …"
|
||||
},
|
||||
"language": {
|
||||
"all": "All",
|
||||
"header": "Languages"
|
||||
}
|
||||
"all": "All",
|
||||
"categories": "Categories of Content",
|
||||
"emotions": "Emotions",
|
||||
"filter-by": "Filter by...",
|
||||
"following": "Users I follow",
|
||||
"languages": "Languages"
|
||||
},
|
||||
"followButton": {
|
||||
"follow": "Follow",
|
||||
"following": "Following"
|
||||
},
|
||||
"hashtags-filter": {
|
||||
"clearSearch": "Clear search",
|
||||
"hashtag-search": "Searching for #{hashtag}",
|
||||
"title": "Your filter bubble"
|
||||
},
|
||||
"index": {
|
||||
"change-filter-settings": "Change your filter settings to get more results.",
|
||||
"no-results": "No contributions found."
|
||||
|
||||
@ -330,30 +330,22 @@
|
||||
"profile-not-found": "Este perfil no se pudo encontrar"
|
||||
},
|
||||
"filter-menu": {
|
||||
"clearSearch": "Borrar búsqueda",
|
||||
"hashtag-search": "Buscando a #{hashtag}",
|
||||
"title": "Su burbuja de filtro"
|
||||
},
|
||||
"filter-posts": {
|
||||
"categories": {
|
||||
"all": "Todas",
|
||||
"header": "Categorías de contenido"
|
||||
},
|
||||
"followers": {
|
||||
"label": "Usuarios que sigo"
|
||||
},
|
||||
"general": {
|
||||
"header": "Filtrar por...."
|
||||
},
|
||||
"language": {
|
||||
"all": "Todos",
|
||||
"header": "Idiomas"
|
||||
}
|
||||
"all": "Todas",
|
||||
"categories": "Categorías de contenido",
|
||||
"emotions": "Emociones",
|
||||
"filter-by": "Filtrar por...",
|
||||
"following": "Usuarios que sigo",
|
||||
"languages": "Idiomas"
|
||||
},
|
||||
"followButton": {
|
||||
"follow": "Seguir",
|
||||
"following": "Siguiendo"
|
||||
},
|
||||
"hashtags-filter": {
|
||||
"clearSearch": "Borrar búsqueda",
|
||||
"hashtag-search": "Buscando a #{hashtag}",
|
||||
"title": "Su burbuja de filtro"
|
||||
},
|
||||
"index": {
|
||||
"change-filter-settings": "Cambie la configuración de sus filtros para obtener más resultados.",
|
||||
"no-results": "No se han encontrado contribuciones."
|
||||
|
||||
@ -319,30 +319,22 @@
|
||||
"placeholder": "Écrivez quelque chose d'inspirant..."
|
||||
},
|
||||
"filter-menu": {
|
||||
"clearSearch": "Réinitialiser la recherche",
|
||||
"hashtag-search": "Recherche de #{hashtag}",
|
||||
"title": "Votre bulle de filtre"
|
||||
},
|
||||
"filter-posts": {
|
||||
"categories": {
|
||||
"all": "Toutes",
|
||||
"header": "Catégories de contenu"
|
||||
},
|
||||
"followers": {
|
||||
"label": "Utilisateurs que je suis"
|
||||
},
|
||||
"general": {
|
||||
"header": "Filtrer par ...."
|
||||
},
|
||||
"language": {
|
||||
"all": "Tous",
|
||||
"header": "Langues"
|
||||
}
|
||||
"all": "Toutes",
|
||||
"categories": "Catégories de contenu",
|
||||
"emotions": "Émotions",
|
||||
"filter-by": "Filtrer par...",
|
||||
"following": "Utilisateurs que je suis",
|
||||
"languages": "Langues"
|
||||
},
|
||||
"followButton": {
|
||||
"follow": "Suivre",
|
||||
"following": "Je suis les"
|
||||
},
|
||||
"hashtags-filter": {
|
||||
"clearSearch": "Réinitialiser la recherche",
|
||||
"hashtag-search": "Recherche de #{hashtag}",
|
||||
"title": "Votre bulle de filtre"
|
||||
},
|
||||
"index": {
|
||||
"change-filter-settings": "Modifiez les paramètres de filtrage pour obtenir plus de résultats.",
|
||||
"no-results": "Pas de contribution trouvée."
|
||||
|
||||
@ -324,30 +324,22 @@
|
||||
"placeholder": ""
|
||||
},
|
||||
"filter-menu": {
|
||||
"clearSearch": "",
|
||||
"hashtag-search": "",
|
||||
"title": ""
|
||||
},
|
||||
"filter-posts": {
|
||||
"categories": {
|
||||
"all": "",
|
||||
"header": ""
|
||||
},
|
||||
"followers": {
|
||||
"label": ""
|
||||
},
|
||||
"general": {
|
||||
"header": ""
|
||||
},
|
||||
"language": {
|
||||
"all": "",
|
||||
"header": ""
|
||||
}
|
||||
"all": "",
|
||||
"categories": "",
|
||||
"emotions": "",
|
||||
"filter-by": "",
|
||||
"following": "",
|
||||
"languages": ""
|
||||
},
|
||||
"followButton": {
|
||||
"follow": "",
|
||||
"following": ""
|
||||
},
|
||||
"hashtags-filter": {
|
||||
"clearSearch": "",
|
||||
"hashtag-search": "",
|
||||
"title": ""
|
||||
},
|
||||
"index": {
|
||||
"change-filter-settings": "",
|
||||
"no-results": ""
|
||||
|
||||
@ -160,13 +160,13 @@
|
||||
"editor": {
|
||||
"placeholder": "Napisz coś inspirującego..."
|
||||
},
|
||||
"filter-menu": {
|
||||
"title": "Twoja bańka filtrująca"
|
||||
},
|
||||
"followButton": {
|
||||
"follow": "naśladować",
|
||||
"following": "w skutek"
|
||||
},
|
||||
"hashtags-filter": {
|
||||
"title": "Twoja bańka filtrująca"
|
||||
},
|
||||
"login": {
|
||||
"copy": "Jeśli masz już konto Human Connection, zaloguj się tutaj.",
|
||||
"email": "Twój adres e-mail",
|
||||
|
||||
@ -315,30 +315,22 @@
|
||||
"placeholder": " Escreva algo inspirador…"
|
||||
},
|
||||
"filter-menu": {
|
||||
"clearSearch": "Limpar pesquisa",
|
||||
"hashtag-search": "Procurando por #{hashtag}",
|
||||
"title": "Sua bolha de filtro"
|
||||
},
|
||||
"filter-posts": {
|
||||
"categories": {
|
||||
"all": "Todos",
|
||||
"header": "Categorias de Conteúdo"
|
||||
},
|
||||
"followers": {
|
||||
"label": "Usuários que eu sigo"
|
||||
},
|
||||
"general": {
|
||||
"header": "Filtrar por …"
|
||||
},
|
||||
"language": {
|
||||
"all": "Todos",
|
||||
"header": "Idiomas"
|
||||
}
|
||||
"all": "Todos",
|
||||
"categories": "Categorias de Conteúdo",
|
||||
"emotions": "Emoções",
|
||||
"filter-by": "Filtrar por...",
|
||||
"following": "Usuários que eu sigo",
|
||||
"languages": "Idiomas"
|
||||
},
|
||||
"followButton": {
|
||||
"follow": "Seguir",
|
||||
"following": "Seguindo"
|
||||
},
|
||||
"hashtags-filter": {
|
||||
"clearSearch": "Limpar pesquisa",
|
||||
"hashtag-search": "Procurando por #{hashtag}",
|
||||
"title": "Sua bolha de filtro"
|
||||
},
|
||||
"index": {
|
||||
"change-filter-settings": "Altere suas configurações de filtro para obter mais resultados.",
|
||||
"no-results": "Nenhuma contribuição encontrada."
|
||||
|
||||
@ -330,30 +330,22 @@
|
||||
"profile-not-found": "Этот профиль не удалось найти"
|
||||
},
|
||||
"filter-menu": {
|
||||
"clearSearch": "Очистить поиск",
|
||||
"hashtag-search": "Поиск по #{hashtag}",
|
||||
"title": "Ваш фильтр пузыря"
|
||||
},
|
||||
"filter-posts": {
|
||||
"categories": {
|
||||
"all": "Все",
|
||||
"header": "Категории"
|
||||
},
|
||||
"followers": {
|
||||
"label": "Мои подписки"
|
||||
},
|
||||
"general": {
|
||||
"header": "Другие фильтры"
|
||||
},
|
||||
"language": {
|
||||
"all": "Все",
|
||||
"header": "Языки"
|
||||
}
|
||||
"all": "Все",
|
||||
"categories": "Категории",
|
||||
"emotions": "",
|
||||
"filter-by": "Другие фильтры",
|
||||
"following": "Мои подписки",
|
||||
"languages": "Языки"
|
||||
},
|
||||
"followButton": {
|
||||
"follow": "Подписаться",
|
||||
"following": "Вы подписаны"
|
||||
},
|
||||
"hashtags-filter": {
|
||||
"clearSearch": "Очистить поиск",
|
||||
"hashtag-search": "Поиск по #{hashtag}",
|
||||
"title": "Ваш фильтр пузыря"
|
||||
},
|
||||
"index": {
|
||||
"change-filter-settings": "Измените настройки фильтра, чтобы получить больше результатов.",
|
||||
"no-results": "Посты не найдены."
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { config, shallowMount, mount } from '@vue/test-utils'
|
||||
import PostIndex from './index.vue'
|
||||
import Vuex from 'vuex'
|
||||
import FilterMenu from '~/components/FilterMenu/FilterMenu'
|
||||
import HashtagsFilter from '~/components/HashtagsFilter/HashtagsFilter'
|
||||
|
||||
const localVue = global.localVue
|
||||
|
||||
@ -106,7 +106,7 @@ describe('PostIndex', () => {
|
||||
it('clears the search when the filter menu emits clearSearch', () => {
|
||||
mocks.$route.query.hashtag = '#samplehashtag'
|
||||
wrapper = Wrapper()
|
||||
wrapper.find(FilterMenu).vm.$emit('clearSearch')
|
||||
wrapper.find(HashtagsFilter).vm.$emit('clearSearch')
|
||||
expect(wrapper.vm.hashtag).toBeNull()
|
||||
})
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
<div>
|
||||
<masonry-grid>
|
||||
<ds-grid-item v-if="hashtag" :row-span="2" column-span="fullWidth">
|
||||
<filter-menu :hashtag="hashtag" @clearSearch="clearSearch" />
|
||||
<hashtags-filter :hashtag="hashtag" @clearSearch="clearSearch" />
|
||||
</ds-grid-item>
|
||||
<ds-grid-item :row-span="2" column-span="fullWidth" class="top-info-bar">
|
||||
<!--<donation-info /> -->
|
||||
@ -65,7 +65,7 @@
|
||||
|
||||
<script>
|
||||
// import DonationInfo from '~/components/DonationInfo/DonationInfo.vue'
|
||||
import FilterMenu from '~/components/FilterMenu/FilterMenu.vue'
|
||||
import HashtagsFilter from '~/components/HashtagsFilter/HashtagsFilter.vue'
|
||||
import HcEmpty from '~/components/Empty/Empty'
|
||||
import PostTeaser from '~/components/PostTeaser/PostTeaser.vue'
|
||||
import MasonryGrid from '~/components/MasonryGrid/MasonryGrid.vue'
|
||||
@ -78,7 +78,7 @@ import UpdateQuery from '~/components/utils/UpdateQuery'
|
||||
export default {
|
||||
components: {
|
||||
// DonationInfo,
|
||||
FilterMenu,
|
||||
HashtagsFilter,
|
||||
PostTeaser,
|
||||
HcEmpty,
|
||||
MasonryGrid,
|
||||
|
||||
@ -48,6 +48,11 @@ export const mutations = {
|
||||
delete filter.categories_some
|
||||
state.filter = filter
|
||||
},
|
||||
RESET_EMOTIONS(state) {
|
||||
const filter = clone(state.filter)
|
||||
delete filter.emotions_some
|
||||
state.filter = filter
|
||||
},
|
||||
RESET_LANGUAGES(state) {
|
||||
const filter = clone(state.filter)
|
||||
delete filter.language_in
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user