Merge pull request #5102 from Ocelot-Social-Community/make-categories-optional

feat: Make Categories Optional
This commit is contained in:
Wolfgang Huß 2022-08-03 14:47:01 +02:00 committed by GitHub
commit c53bd68f4d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 123 additions and 15 deletions

View File

@ -28,3 +28,5 @@ AWS_BUCKET=
EMAIL_DEFAULT_SENDER="devops@ocelot.social" EMAIL_DEFAULT_SENDER="devops@ocelot.social"
EMAIL_SUPPORT="devops@ocelot.social" EMAIL_SUPPORT="devops@ocelot.social"
CATEGORIES_ACTIVE=false

View File

@ -86,6 +86,7 @@ const options = {
ORGANIZATION_URL: emails.ORGANIZATION_LINK, ORGANIZATION_URL: emails.ORGANIZATION_LINK,
PUBLIC_REGISTRATION: env.PUBLIC_REGISTRATION === 'true' || false, PUBLIC_REGISTRATION: env.PUBLIC_REGISTRATION === 'true' || false,
INVITE_REGISTRATION: env.INVITE_REGISTRATION !== 'false', // default = true INVITE_REGISTRATION: env.INVITE_REGISTRATION !== 'false', // default = true
CATEGORIES_ACTIVE: process.env.CATEGORIES_ACTIVE === 'true' || false,
} }
// Check if all required configs are present // Check if all required configs are present

View File

@ -5,6 +5,7 @@ import { UserInputError } from 'apollo-server'
import { mergeImage, deleteImage } from './images/images' import { mergeImage, deleteImage } from './images/images'
import Resolver from './helpers/Resolver' import Resolver from './helpers/Resolver'
import { filterForMutedUsers } from './helpers/filterForMutedUsers' import { filterForMutedUsers } from './helpers/filterForMutedUsers'
import CONFIG from '../../config'
const maintainPinnedPosts = (params) => { const maintainPinnedPosts = (params) => {
const pinnedPostFilter = { pinned: true } const pinnedPostFilter = { pinned: true }
@ -76,12 +77,20 @@ export default {
}, },
Mutation: { Mutation: {
CreatePost: async (_parent, params, context, _resolveInfo) => { CreatePost: async (_parent, params, context, _resolveInfo) => {
const { categoryIds } = params
const { image: imageInput } = params const { image: imageInput } = params
delete params.categoryIds delete params.categoryIds
delete params.image delete params.image
params.id = params.id || uuid() params.id = params.id || uuid()
const session = context.driver.session() const session = context.driver.session()
const writeTxResultPromise = session.writeTransaction(async (transaction) => { const writeTxResultPromise = session.writeTransaction(async (transaction) => {
const categoriesCypher =
CONFIG.CATEGORIES_ACTIVE && categoryIds
? `WITH post
UNWIND $categoryIds AS categoryId
MATCH (category:Category {id: categoryId})
MERGE (post)-[:CATEGORIZED]->(category)`
: ''
const createPostTransactionResponse = await transaction.run( const createPostTransactionResponse = await transaction.run(
` `
CREATE (post:Post) CREATE (post:Post)
@ -93,9 +102,10 @@ export default {
WITH post WITH post
MATCH (author:User {id: $userId}) MATCH (author:User {id: $userId})
MERGE (post)<-[:WROTE]-(author) MERGE (post)<-[:WROTE]-(author)
${categoriesCypher}
RETURN post {.*} RETURN post {.*}
`, `,
{ userId: context.user.id, params }, { userId: context.user.id, params, categoryIds },
) )
const [post] = createPostTransactionResponse.records.map((record) => record.get('post')) const [post] = createPostTransactionResponse.records.map((record) => record.get('post'))
if (imageInput) { if (imageInput) {
@ -127,7 +137,7 @@ export default {
WITH post WITH post
` `
if (categoryIds && categoryIds.length) { if (CONFIG.CATEGORIES_ACTIVE && categoryIds && categoryIds.length) {
const cypherDeletePreviousRelations = ` const cypherDeletePreviousRelations = `
MATCH (post:Post { id: $params.id })-[previousRelations:CATEGORIZED]->(category:Category) MATCH (post:Post { id: $params.id })-[previousRelations:CATEGORIZED]->(category:Category)
DELETE previousRelations DELETE previousRelations

View File

@ -4,3 +4,4 @@ PUBLIC_REGISTRATION=false
INVITE_REGISTRATION=true INVITE_REGISTRATION=true
WEBSOCKETS_URI=ws://localhost:3000/api/graphql WEBSOCKETS_URI=ws://localhost:3000/api/graphql
GRAPHQL_URI=http://localhost:4000/ GRAPHQL_URI=http://localhost:4000/
CATEGORIES_ACTIVE=false

View File

@ -58,6 +58,9 @@ describe('ContributionForm.vue', () => {
back: jest.fn(), back: jest.fn(),
push: jest.fn(), push: jest.fn(),
}, },
$env: {
CATEGORIES_ACTIVE: false,
},
} }
propsData = {} propsData = {}
}) })
@ -132,6 +135,7 @@ describe('ContributionForm.vue', () => {
variables: { variables: {
title: postTitle, title: postTitle,
content: postContent, content: postContent,
categoryIds: [],
id: null, id: null,
image: null, image: null,
}, },
@ -254,6 +258,7 @@ describe('ContributionForm.vue', () => {
variables: { variables: {
title: propsData.contribution.title, title: propsData.contribution.title,
content: propsData.contribution.content, content: propsData.contribution.content,
categoryIds: [],
id: propsData.contribution.id, id: propsData.contribution.id,
image: { image: {
sensitive: false, sensitive: false,

View File

@ -51,6 +51,19 @@
{{ contentLength }} {{ contentLength }}
<base-icon v-if="errors && errors.content" name="warning" /> <base-icon v-if="errors && errors.content" name="warning" />
</ds-chip> </ds-chip>
<categories-select
v-if="categoriesActive"
model="categoryIds"
:existingCategoryIds="formData.categoryIds"
/>
<ds-chip
v-if="categoriesActive"
size="base"
:color="errors && errors.categoryIds && 'danger'"
>
{{ formData.categoryIds.length }} / 3
<base-icon v-if="errors && errors.categoryIds" name="warning" />
</ds-chip>
<div class="buttons"> <div class="buttons">
<base-button data-test="cancel-button" :disabled="loading" @click="$router.back()" danger> <base-button data-test="cancel-button" :disabled="loading" @click="$router.back()" danger>
{{ $t('actions.cancel') }} {{ $t('actions.cancel') }}
@ -69,6 +82,7 @@ import gql from 'graphql-tag'
import { mapGetters } from 'vuex' import { mapGetters } from 'vuex'
import HcEditor from '~/components/Editor/Editor' import HcEditor from '~/components/Editor/Editor'
import PostMutations from '~/graphql/PostMutations.js' import PostMutations from '~/graphql/PostMutations.js'
import CategoriesSelect from '~/components/CategoriesSelect/CategoriesSelect'
import ImageUploader from '~/components/ImageUploader/ImageUploader' import ImageUploader from '~/components/ImageUploader/ImageUploader'
import links from '~/constants/links.js' import links from '~/constants/links.js'
import PageParamsLink from '~/components/_new/features/PageParamsLink/PageParamsLink.vue' import PageParamsLink from '~/components/_new/features/PageParamsLink/PageParamsLink.vue'
@ -78,6 +92,7 @@ export default {
HcEditor, HcEditor,
ImageUploader, ImageUploader,
PageParamsLink, PageParamsLink,
CategoriesSelect,
}, },
props: { props: {
contribution: { contribution: {
@ -86,7 +101,7 @@ export default {
}, },
}, },
data() { data() {
const { title, content, image } = this.contribution const { title, content, image, categories } = this.contribution
const { const {
sensitive: imageBlurred = false, sensitive: imageBlurred = false,
aspectRatio: imageAspectRatio = null, aspectRatio: imageAspectRatio = null,
@ -94,6 +109,7 @@ export default {
} = image || {} } = image || {}
return { return {
categoriesActive: this.$env.CATEGORIES_ACTIVE,
links, links,
formData: { formData: {
title: title || '', title: title || '',
@ -102,11 +118,22 @@ export default {
imageAspectRatio, imageAspectRatio,
imageType, imageType,
imageBlurred, imageBlurred,
categoryIds: categories ? categories.map((category) => category.id) : [],
}, },
formSchema: { formSchema: {
title: { required: true, min: 3, max: 100 }, title: { required: true, min: 3, max: 100 },
content: { required: true }, content: { required: true },
imageBlurred: { required: false }, imageBlurred: { required: false },
categoryIds: {
type: 'array',
required: this.categoriesActive,
validator: (_, value = []) => {
if (this.categoriesActive && (value.length === 0 || value.length > 3)) {
return [new Error(this.$t('common.validations.categories'))]
}
return []
},
},
}, },
loading: false, loading: false,
users: [], users: [],
@ -125,7 +152,7 @@ export default {
methods: { methods: {
submit() { submit() {
let image = null let image = null
const { title, content } = this.formData const { title, content, categoryIds } = this.formData
if (this.formData.image) { if (this.formData.image) {
image = { image = {
sensitive: this.formData.imageBlurred, sensitive: this.formData.imageBlurred,
@ -143,6 +170,7 @@ export default {
variables: { variables: {
title, title,
content, content,
categoryIds,
id: this.contribution.id || null, id: this.contribution.id || null,
image, image,
}, },

View File

@ -47,6 +47,9 @@ describe('PostTeaser', () => {
data: { DeletePost: { id: 'deleted-post-id' } }, data: { DeletePost: { id: 'deleted-post-id' } },
}), }),
}, },
$env: {
CATEGORIES_ACTIVE: false,
},
} }
getters = { getters = {
'auth/isModerator': () => false, 'auth/isModerator': () => false,

View File

@ -26,7 +26,19 @@
class="footer" class="footer"
v-observe-visibility="(isVisible, entry) => visibilityChanged(isVisible, entry, post.id)" v-observe-visibility="(isVisible, entry) => visibilityChanged(isVisible, entry, post.id)"
> >
<div class="categories-placeholder"></div> <div class="categories" v-if="categoriesActive">
<hc-category
v-for="category in post.categories"
:key="category.id"
v-tooltip="{
content: $t(`contribution.category.name.${category.slug}`),
placement: 'bottom-start',
delay: { show: 500 },
}"
:icon="category.icon"
/>
</div>
<div v-else class="categories-placeholder"></div>
<counter-icon <counter-icon
icon="bullhorn" icon="bullhorn"
:count="post.shoutedCount" :count="post.shoutedCount"
@ -70,6 +82,7 @@
import UserTeaser from '~/components/UserTeaser/UserTeaser' import UserTeaser from '~/components/UserTeaser/UserTeaser'
import ContentMenu from '~/components/ContentMenu/ContentMenu' import ContentMenu from '~/components/ContentMenu/ContentMenu'
import HcRibbon from '~/components/Ribbon' import HcRibbon from '~/components/Ribbon'
import HcCategory from '~/components/Category'
import CounterIcon from '~/components/_new/generic/CounterIcon/CounterIcon' import CounterIcon from '~/components/_new/generic/CounterIcon/CounterIcon'
import { mapGetters } from 'vuex' import { mapGetters } from 'vuex'
import PostMutations from '~/graphql/PostMutations' import PostMutations from '~/graphql/PostMutations'
@ -79,6 +92,7 @@ export default {
name: 'PostTeaser', name: 'PostTeaser',
components: { components: {
UserTeaser, UserTeaser,
HcCategory,
HcRibbon, HcRibbon,
ContentMenu, ContentMenu,
CounterIcon, CounterIcon,
@ -93,6 +107,11 @@ export default {
default: () => {}, default: () => {},
}, },
}, },
data() {
return {
categoriesActive: this.$env.CATEGORIES_ACTIVE,
}
},
mounted() { mounted() {
const { image } = this.post const { image } = this.post
if (!image) return if (!image) return

View File

@ -24,6 +24,9 @@ describe('SearchResults', () => {
beforeEach(() => { beforeEach(() => {
mocks = { mocks = {
$t: jest.fn(), $t: jest.fn(),
$env: {
CATEGORIES_ACTIVE: false,
},
} }
getters = { getters = {
'auth/user': () => { 'auth/user': () => {

View File

@ -33,6 +33,7 @@ const options = {
// Cookies // Cookies
COOKIE_EXPIRE_TIME: process.env.COOKIE_EXPIRE_TIME || 730, // Two years by default COOKIE_EXPIRE_TIME: process.env.COOKIE_EXPIRE_TIME || 730, // Two years by default
COOKIE_HTTPS_ONLY: process.env.COOKIE_HTTPS_ONLY || process.env.NODE_ENV === 'production', // ensure true in production if not set explicitly COOKIE_HTTPS_ONLY: process.env.COOKIE_HTTPS_ONLY || process.env.NODE_ENV === 'production', // ensure true in production if not set explicitly
CATEGORIES_ACTIVE: process.env.CATEGORIES_ACTIVE === 'true' || false,
} }
const CONFIG = { const CONFIG = {

View File

@ -78,6 +78,12 @@ export const tagsCategoriesAndPinnedFragment = gql`
tags { tags {
id id
} }
categories {
id
slug
name
icon
}
pinnedBy { pinnedBy {
id id
name name

View File

@ -18,6 +18,9 @@ describe('post/_id.vue', () => {
slug: 'my-post', slug: 'my-post',
}, },
}, },
$env: {
CATEGORIES_ACTIVE: false,
},
} }
}) })

View File

@ -72,6 +72,9 @@ describe('PostSlug', () => {
query: jest.fn().mockResolvedValue({ data: { PostEmotionsCountByEmotion: {} } }), query: jest.fn().mockResolvedValue({ data: { PostEmotionsCountByEmotion: {} } }),
}, },
$scrollTo: jest.fn(), $scrollTo: jest.fn(),
$env: {
CATEGORIES_ACTIVE: false,
},
} }
stubs = { stubs = {
HcEditor: { render: () => {}, methods: { insertReply: jest.fn(() => null) } }, HcEditor: { render: () => {}, methods: { insertReply: jest.fn(() => null) } },

View File

@ -44,6 +44,19 @@
<h2 class="title hyphenate-text">{{ post.title }}</h2> <h2 class="title hyphenate-text">{{ post.title }}</h2>
<ds-space margin-bottom="small" /> <ds-space margin-bottom="small" />
<content-viewer class="content hyphenate-text" :content="post.content" /> <content-viewer class="content hyphenate-text" :content="post.content" />
<!-- Categories -->
<div v-if="categoriesActive" class="categories">
<!-- eslint-enable vue/no-v-html -->
<ds-space margin="xx-large" />
<ds-space margin="xx-small" />
<hc-category
v-for="category in post.categories"
:key="category.id"
:icon="category.icon"
:name="$t(`contribution.category.name.${category.slug}`)"
/>
</div>
<ds-space margin-bottom="small" />
<!-- Tags --> <!-- Tags -->
<div v-if="post.tags && post.tags.length" class="tags"> <div v-if="post.tags && post.tags.length" class="tags">
<ds-space margin="xx-small" /> <ds-space margin="xx-small" />
@ -91,6 +104,7 @@
<script> <script>
import ContentViewer from '~/components/Editor/ContentViewer' import ContentViewer from '~/components/Editor/ContentViewer'
import HcCategory from '~/components/Category'
import HcHashtag from '~/components/Hashtag/Hashtag' import HcHashtag from '~/components/Hashtag/Hashtag'
import ContentMenu from '~/components/ContentMenu/ContentMenu' import ContentMenu from '~/components/ContentMenu/ContentMenu'
import UserTeaser from '~/components/UserTeaser/UserTeaser' import UserTeaser from '~/components/UserTeaser/UserTeaser'
@ -118,6 +132,7 @@ export default {
CommentForm, CommentForm,
CommentList, CommentList,
ContentViewer, ContentViewer,
HcCategory,
HcHashtag, HcHashtag,
HcShoutButton, HcShoutButton,
PageParamsLink, PageParamsLink,
@ -138,6 +153,7 @@ export default {
blurred: false, blurred: false,
blocked: null, blocked: null,
postAuthor: null, postAuthor: null,
categoriesActive: this.$env.CATEGORIES_ACTIVE,
} }
}, },
mounted() { mounted() {
@ -171,12 +187,12 @@ export default {
heroImageStyle() { heroImageStyle() {
/* Return false when image property is not present or is not a number /* Return false when image property is not present or is not a number
so no unnecessary css variables are set. so no unnecessary css variables are set.
*/ */
if (!this.post.image || typeof this.post.image.aspectRatio !== 'number') return false if (!this.post.image || typeof this.post.image.aspectRatio !== 'number') return false
/* Return the aspect ratio as a css variable. Later to be used when calculating /* Return the aspect ratio as a css variable. Later to be used when calculating
the height with respect to the width. the height with respect to the width.
*/ */
return { return {
'--hero-image-aspect-ratio': 1.0 / this.post.image.aspectRatio, '--hero-image-aspect-ratio': 1.0 / this.post.image.aspectRatio,
} }
@ -253,12 +269,12 @@ export default {
/* The padding top makes sure the correct height is set (according to the /* The padding top makes sure the correct height is set (according to the
hero image aspect ratio) before the hero image loads so hero image aspect ratio) before the hero image loads so
the autoscroll works correctly when following a comment link. the autoscroll works correctly when following a comment link.
*/ */
padding-top: calc(var(--hero-image-aspect-ratio) * (100% + 48px)); padding-top: calc(var(--hero-image-aspect-ratio) * (100% + 48px));
/* Letting the image fill the container, since the container /* Letting the image fill the container, since the container
is the one determining height is the one determining height
*/ */
> .image { > .image {
position: absolute; position: absolute;
top: 0; top: 0;

View File

@ -5,13 +5,13 @@ const localVue = global.localVue
describe('create.vue', () => { describe('create.vue', () => {
let wrapper let wrapper
let mocks
beforeEach(() => { const mocks = {
mocks = { $t: jest.fn(),
$t: jest.fn(), $env: {
} CATEGORIES_ACTIVE: false,
}) },
}
describe('mount', () => { describe('mount', () => {
const Wrapper = () => { const Wrapper = () => {

View File

@ -34,6 +34,9 @@ describe('post/_id.vue', () => {
}), }),
}, },
}, },
$env: {
CATEGORIES_ACTIVE: false,
},
} }
store = new Vuex.Store({ store = new Vuex.Store({
getters: { getters: {

View File

@ -29,6 +29,10 @@ describe('Registration', () => {
query: {}, query: {},
}, },
$env: {}, $env: {},
$toast: {
error: jest.fn(),
success: jest.fn(),
},
} }
asyncData = false asyncData = false
isLoggedIn = false isLoggedIn = false