diff --git a/backend/src/db/migrations/20230329150329-article-label-for-posts.js b/backend/src/db/migrations/20230329150329-article-label-for-posts.js new file mode 100644 index 000000000..3cf435203 --- /dev/null +++ b/backend/src/db/migrations/20230329150329-article-label-for-posts.js @@ -0,0 +1,53 @@ +import { getDriver } from '../../db/neo4j' + +export const description = 'Add to all existing posts the Article label' + +export async function up(next) { + const driver = getDriver() + const session = driver.session() + const transaction = session.beginTransaction() + + try { + await transaction.run(` + MATCH (post:Post) + SET post:Article + RETURN post + `) + await transaction.commit() + next() + } catch (error) { + // eslint-disable-next-line no-console + console.log(error) + await transaction.rollback() + // eslint-disable-next-line no-console + console.log('rolled back') + throw new Error(error) + } finally { + session.close() + } +} + +export async function down(next) { + const driver = getDriver() + const session = driver.session() + const transaction = session.beginTransaction() + + try { + await transaction.run(` + MATCH (post:Post) + REMOVE post:Article + RETURN post + `) + await transaction.commit() + next() + } catch (error) { + // eslint-disable-next-line no-console + console.log(error) + await transaction.rollback() + // eslint-disable-next-line no-console + console.log('rolled back') + throw new Error(error) + } finally { + session.close() + } +} diff --git a/backend/src/graphql/posts.js b/backend/src/graphql/posts.js index 2669d6f24..d1dc3ee45 100644 --- a/backend/src/graphql/posts.js +++ b/backend/src/graphql/posts.js @@ -11,6 +11,8 @@ export const createPostMutation = () => { $content: String! $categoryIds: [ID] $groupId: ID + $postType: PostType + $eventInput: _EventInput ) { CreatePost( id: $id @@ -19,11 +21,31 @@ export const createPostMutation = () => { content: $content categoryIds: $categoryIds groupId: $groupId + postType: $postType + eventInput: $eventInput ) { id slug title content + disabled + deleted + postType + author { + name + } + categories { + id + } + eventStart + eventEnd + eventLocationName + eventVenue + eventIsOnline + eventLocation { + lng + lat + } } } ` @@ -50,6 +72,7 @@ export const filterPosts = () => { id title content + eventStart } } ` diff --git a/backend/src/schema/resolvers/filter-posts.spec.js b/backend/src/schema/resolvers/filter-posts.spec.js new file mode 100644 index 000000000..0b96e001f --- /dev/null +++ b/backend/src/schema/resolvers/filter-posts.spec.js @@ -0,0 +1,230 @@ +import { createTestClient } from 'apollo-server-testing' +import Factory, { cleanDatabase } from '../../db/factories' +import { getNeode, getDriver } from '../../db/neo4j' +import createServer from '../../server' +import CONFIG from '../../config' +import { filterPosts, createPostMutation } from '../../graphql/posts' + +CONFIG.CATEGORIES_ACTIVE = false + +const driver = getDriver() +const neode = getNeode() + +let query +let mutate +let authenticatedUser +let user + +beforeAll(async () => { + await cleanDatabase() + + const { server } = createServer({ + context: () => { + return { + driver, + neode, + user: authenticatedUser, + } + }, + }) + query = createTestClient(server).query + mutate = createTestClient(server).mutate +}) + +afterAll(async () => { + await cleanDatabase() + driver.close() +}) + +describe('Filter Posts', () => { + const now = new Date() + + beforeAll(async () => { + user = await Factory.build('user', { + id: 'user', + name: 'User', + about: 'I am a user.', + }) + authenticatedUser = await user.toJson() + await mutate({ + mutation: createPostMutation(), + variables: { + id: 'a1', + title: 'I am an article', + content: 'I am an article written by user.', + }, + }) + await mutate({ + mutation: createPostMutation(), + variables: { + id: 'a2', + title: 'I am anonther article', + content: 'I am another article written by user.', + }, + }) + await mutate({ + mutation: createPostMutation(), + variables: { + id: 'e1', + title: 'Illegaler Kindergeburtstag', + content: 'Elli wird fünf. Wir feiern ihren Geburtstag.', + postType: 'Event', + eventInput: { + eventStart: new Date(now.getFullYear(), now.getMonth() + 1).toISOString(), + eventVenue: 'Garten der Familie Maier', + }, + }, + }) + await mutate({ + mutation: createPostMutation(), + variables: { + id: 'e2', + title: 'Räuber-Treffen', + content: 'Planung der nächsten Räuberereien', + postType: 'Event', + eventInput: { + eventStart: new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1).toISOString(), + eventVenue: 'Wirtshaus im Spessart', + }, + }, + }) + }) + + describe('no filters set', () => { + it('finds all posts', async () => { + const { + data: { Post: result }, + } = await query({ query: filterPosts() }) + expect(result).toHaveLength(4) + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: 'a1' }), + expect.objectContaining({ id: 'a2' }), + expect.objectContaining({ id: 'e1' }), + expect.objectContaining({ id: 'e2' }), + ]), + ) + }) + }) + + describe('post type filter set to ["Article"]', () => { + it('finds the articles', async () => { + const { + data: { Post: result }, + } = await query({ query: filterPosts(), variables: { filter: { postType_in: ['Article'] } } }) + expect(result).toHaveLength(2) + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: 'a1' }), + expect.objectContaining({ id: 'a2' }), + ]), + ) + }) + }) + + describe('post type filter set to ["Event"]', () => { + it('finds the articles', async () => { + const { + data: { Post: result }, + } = await query({ query: filterPosts(), variables: { filter: { postType_in: ['Event'] } } }) + expect(result).toHaveLength(2) + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: 'e1' }), + expect.objectContaining({ id: 'e2' }), + ]), + ) + }) + }) + + describe('post type filter set to ["Article", "Event"]', () => { + it('finds all posts', async () => { + const { + data: { Post: result }, + } = await query({ + query: filterPosts(), + variables: { filter: { postType_in: ['Article', 'Event'] } }, + }) + expect(result).toHaveLength(4) + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: 'a1' }), + expect.objectContaining({ id: 'a2' }), + expect.objectContaining({ id: 'e1' }), + expect.objectContaining({ id: 'e2' }), + ]), + ) + }) + }) + + describe('order events by event start descending', () => { + it('finds the events orderd accordingly', async () => { + const { + data: { Post: result }, + } = await query({ + query: filterPosts(), + variables: { filter: { postType_in: ['Event'] }, orderBy: ['eventStart_desc'] }, + }) + expect(result).toHaveLength(2) + expect(result).toEqual([ + expect.objectContaining({ + id: 'e1', + eventStart: new Date(now.getFullYear(), now.getMonth() + 1).toISOString(), + }), + expect.objectContaining({ + id: 'e2', + eventStart: new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1).toISOString(), + }), + ]) + }) + }) + + describe('order events by event start ascending', () => { + it('finds the events orderd accordingly', async () => { + const { + data: { Post: result }, + } = await query({ + query: filterPosts(), + variables: { filter: { postType_in: ['Event'] }, orderBy: ['eventStart_asc'] }, + }) + expect(result).toHaveLength(2) + expect(result).toEqual([ + expect.objectContaining({ + id: 'e2', + eventStart: new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1).toISOString(), + }), + expect.objectContaining({ + id: 'e1', + eventStart: new Date(now.getFullYear(), now.getMonth() + 1).toISOString(), + }), + ]) + }) + }) + + describe('filter events by event start date', () => { + it('finds only events after given date', async () => { + const { + data: { Post: result }, + } = await query({ + query: filterPosts(), + variables: { + filter: { + postType_in: ['Event'], + eventStart_gte: new Date( + now.getFullYear(), + now.getMonth(), + now.getDate() + 2, + ).toISOString(), + }, + }, + }) + expect(result).toHaveLength(1) + expect(result).toEqual([ + expect.objectContaining({ + id: 'e1', + eventStart: new Date(now.getFullYear(), now.getMonth() + 1).toISOString(), + }), + ]) + }) + }) +}) diff --git a/backend/src/schema/resolvers/helpers/events.js b/backend/src/schema/resolvers/helpers/events.js new file mode 100644 index 000000000..84e64299d --- /dev/null +++ b/backend/src/schema/resolvers/helpers/events.js @@ -0,0 +1,47 @@ +import { UserInputError } from 'apollo-server' + +export const validateEventParams = (params) => { + if (params.postType && params.postType === 'Event') { + const { eventInput } = params + validateEventDate(eventInput.eventStart) + params.eventStart = eventInput.eventStart + if (eventInput.eventEnd) { + validateEventEnd(eventInput.eventStart, eventInput.eventEnd) + params.eventEnd = eventInput.eventEnd + } + if (eventInput.eventLocationName && !eventInput.eventVenue) { + throw new UserInputError('Event venue must be present if event location is given!') + } + params.eventVenue = eventInput.eventVenue + params.eventLocationName = eventInput.eventLocationName + params.eventIsOnline = !!eventInput.eventIsOnline + } + delete params.eventInput + let locationName + if (params.eventLocationName) { + locationName = params.eventLocationName + } else { + params.eventLocationName = null + locationName = null + } + return locationName +} + +const validateEventDate = (dateString) => { + const date = new Date(dateString) + if (date.toString() === 'Invalid Date') + throw new UserInputError('Event start date must be a valid date!') + const now = new Date() + if (date.getTime() < now.getTime()) { + throw new UserInputError('Event start date must be in the future!') + } +} + +const validateEventEnd = (start, end) => { + const endDate = new Date(end) + if (endDate.toString() === 'Invalid Date') + throw new UserInputError('Event end date must be a valid date!') + const startDate = new Date(start) + if (endDate < startDate) + throw new UserInputError('Event end date must be a after event start date!') +} diff --git a/backend/src/schema/resolvers/notifications.js b/backend/src/schema/resolvers/notifications.js index 117b9b530..c3b882146 100644 --- a/backend/src/schema/resolvers/notifications.js +++ b/backend/src/schema/resolvers/notifications.js @@ -51,7 +51,7 @@ export default { OPTIONAL MATCH (resource)<-[membership:MEMBER_OF]-(relatedUser) WITH user, notification, resource, membership, relatedUser, [(resource)<-[:WROTE]-(author:User) | author {.*}] AS authors, - [(resource)-[:COMMENTS]->(post:Post)<-[:WROTE]-(author:User) | post {.*, author: properties(author)} ] AS posts + [(resource)-[:COMMENTS]->(post:Post)<-[:WROTE]-(author:User) | post {.*, author: properties(author), postType: filter(l IN labels(post) WHERE NOT l = "Post")} ] AS posts WITH resource, user, notification, authors, posts, relatedUser, membership, resource {.*, __typename: labels(resource)[0], @@ -90,7 +90,7 @@ export default { SET notification.read = TRUE WITH user, notification, resource, [(resource)<-[:WROTE]-(author:User) | author {.*}] AS authors, - [(resource)-[:COMMENTS]->(post:Post)<-[:WROTE]-(author:User) | post{.*, author: properties(author)} ] AS posts + [(resource)-[:COMMENTS]->(post:Post)<-[:WROTE]-(author:User) | post{.*, author: properties(author), postType: filter(l IN labels(post) WHERE NOT l = "Post")} ] AS posts OPTIONAL MATCH (resource)<-[membership:MEMBER_OF]-(user) WITH resource, user, notification, authors, posts, membership, resource {.*, __typename: labels(resource)[0], author: authors[0], post: posts[0], myRole: membership.role } AS finalResource @@ -120,7 +120,7 @@ export default { SET notification.read = TRUE WITH user, notification, resource, [(resource)<-[:WROTE]-(author:User) | author {.*}] AS authors, - [(resource)-[:COMMENTS]->(post:Post)<-[:WROTE]-(author:User) | post{.*, author: properties(author)} ] AS posts + [(resource)-[:COMMENTS]->(post:Post)<-[:WROTE]-(author:User) | post{.*, author: properties(author), postType: filter(l IN labels(post) WHERE NOT l = "Post")} ] AS posts OPTIONAL MATCH (resource)<-[membership:MEMBER_OF]-(user) WITH resource, user, notification, authors, posts, membership, resource {.*, __typename: labels(resource)[0], author: authors[0], post: posts[0], myRole: membership.role} AS finalResource diff --git a/backend/src/schema/resolvers/posts.js b/backend/src/schema/resolvers/posts.js index 063b2210d..c7d2eebdb 100644 --- a/backend/src/schema/resolvers/posts.js +++ b/backend/src/schema/resolvers/posts.js @@ -7,6 +7,8 @@ import Resolver from './helpers/Resolver' import { filterForMutedUsers } from './helpers/filterForMutedUsers' import { filterInvisiblePosts } from './helpers/filterInvisiblePosts' import { filterPostsOfMyGroups } from './helpers/filterPostsOfMyGroups' +import { validateEventParams } from './helpers/events' +import { createOrUpdateLocations } from './users/location' import CONFIG from '../../config' const maintainPinnedPosts = (params) => { @@ -81,6 +83,9 @@ export default { CreatePost: async (_parent, params, context, _resolveInfo) => { const { categoryIds, groupId } = params const { image: imageInput } = params + + const locationName = validateEventParams(params) + delete params.categoryIds delete params.image delete params.groupId @@ -125,12 +130,13 @@ export default { SET post.updatedAt = toString(datetime()) SET post.clickedCount = 0 SET post.viewedTeaserCount = 0 + SET post:${params.postType} WITH post MATCH (author:User {id: $userId}) MERGE (post)<-[:WROTE]-(author) ${categoriesCypher} ${groupCypher} - RETURN post {.*} + RETURN post {.*, postType: filter(l IN labels(post) WHERE NOT l = "Post") } `, { userId: context.user.id, categoryIds, groupId, params }, ) @@ -142,6 +148,9 @@ export default { }) try { const post = await writeTxResultPromise + if (locationName) { + await createOrUpdateLocations('Post', post.id, locationName, session) + } return post } catch (e) { if (e.code === 'Neo.ClientError.Schema.ConstraintValidationFailed') @@ -154,6 +163,9 @@ export default { UpdatePost: async (_parent, params, context, _resolveInfo) => { const { categoryIds } = params const { image: imageInput } = params + + const locationName = validateEventParams(params) + delete params.categoryIds delete params.image const session = context.driver.session() @@ -183,7 +195,16 @@ export default { ` } - updatePostCypher += `RETURN post {.*}` + if (params.postType) { + updatePostCypher += ` + REMOVE post:Article + REMOVE post:Event + SET post:${params.postType} + WITH post + ` + } + + updatePostCypher += `RETURN post {.*, postType: filter(l IN labels(post) WHERE NOT l = "Post")}` const updatePostVariables = { categoryIds, params } try { const writeTxResultPromise = session.writeTransaction(async (transaction) => { @@ -196,6 +217,9 @@ export default { return post }) const post = await writeTxResultPromise + if (locationName) { + await createOrUpdateLocations('Post', post.id, locationName, session) + } return post } finally { session.close() @@ -385,7 +409,19 @@ export default { }, Post: { ...Resolver('Post', { - undefinedToNull: ['activityId', 'objectId', 'language', 'pinnedAt', 'pinned'], + undefinedToNull: [ + 'activityId', + 'objectId', + 'language', + 'pinnedAt', + 'pinned', + 'eventVenue', + 'eventLocation', + 'eventLocationName', + 'eventStart', + 'eventEnd', + 'eventIsOnline', + ], hasMany: { tags: '-[:TAGGED]->(related:Tag)', categories: '-[:CATEGORIZED]->(related:Category)', @@ -398,6 +434,7 @@ export default { pinnedBy: '<-[:PINNED]-(related:User)', image: '-[:HERO_IMAGE]->(related:Image)', group: '-[:IN]->(related:Group)', + eventLocation: '-[:IS_IN]->(related:Location)', }, count: { commentsCount: diff --git a/backend/src/schema/resolvers/posts.spec.js b/backend/src/schema/resolvers/posts.spec.js index 9335c1313..87d09e262 100644 --- a/backend/src/schema/resolvers/posts.spec.js +++ b/backend/src/schema/resolvers/posts.spec.js @@ -3,6 +3,10 @@ import Factory, { cleanDatabase } from '../../db/factories' import gql from 'graphql-tag' import { getNeode, getDriver } from '../../db/neo4j' import createServer from '../../server' +import { createPostMutation } from '../../graphql/posts' +import CONFIG from '../../config' + +CONFIG.CATEGORIES_ACTIVE = true const driver = getDriver() const neode = getNeode() @@ -15,29 +19,6 @@ let user const categoryIds = ['cat9', 'cat4', 'cat15'] let variables -const createPostMutation = gql` - mutation ($id: ID, $title: String!, $content: String!, $language: String, $categoryIds: [ID]) { - CreatePost( - id: $id - title: $title - content: $content - language: $language - categoryIds: $categoryIds - ) { - id - title - content - slug - disabled - deleted - language - author { - name - } - } - } -` - beforeAll(async () => { await cleanDatabase() @@ -281,7 +262,7 @@ describe('CreatePost', () => { describe('unauthenticated', () => { it('throws authorization error', async () => { - const { errors } = await mutate({ mutation: createPostMutation, variables }) + const { errors } = await mutate({ mutation: createPostMutation(), variables }) expect(errors[0]).toHaveProperty('message', 'Not Authorized!') }) }) @@ -296,7 +277,7 @@ describe('CreatePost', () => { data: { CreatePost: { title: 'I am a title', content: 'Some content' } }, errors: undefined, } - await expect(mutate({ mutation: createPostMutation, variables })).resolves.toMatchObject( + await expect(mutate({ mutation: createPostMutation(), variables })).resolves.toMatchObject( expected, ) }) @@ -313,25 +294,327 @@ describe('CreatePost', () => { }, errors: undefined, } - await expect(mutate({ mutation: createPostMutation, variables })).resolves.toMatchObject( + await expect(mutate({ mutation: createPostMutation(), variables })).resolves.toMatchObject( expected, ) }) it('`disabled` and `deleted` default to `false`', async () => { const expected = { data: { CreatePost: { disabled: false, deleted: false } } } - await expect(mutate({ mutation: createPostMutation, variables })).resolves.toMatchObject( + await expect(mutate({ mutation: createPostMutation(), variables })).resolves.toMatchObject( expected, ) }) + + it('has label "Article" as default', async () => { + await expect(mutate({ mutation: createPostMutation(), variables })).resolves.toMatchObject({ + data: { CreatePost: { postType: ['Article'] } }, + }) + }) + + describe('with invalid post type', () => { + it('throws an error', async () => { + await expect( + mutate({ + mutation: createPostMutation(), + variables: { ...variables, postType: 'not-valid' }, + }), + ).resolves.toMatchObject({ + errors: [ + { + message: + 'Variable "$postType" got invalid value "not-valid"; Expected type PostType.', + }, + ], + }) + }) + }) + + describe('with post type "Event"', () => { + describe('without event start date', () => { + it('throws an error', async () => { + await expect( + mutate({ + mutation: createPostMutation(), + variables: { + ...variables, + postType: 'Event', + }, + }), + ).resolves.toMatchObject({ + errors: [ + { + message: "Cannot read properties of undefined (reading 'eventStart')", + }, + ], + }) + }) + }) + + describe('with invalid event start date', () => { + it('throws an error', async () => { + await expect( + mutate({ + mutation: createPostMutation(), + variables: { + ...variables, + postType: 'Event', + eventInput: { + eventStart: 'no date', + }, + }, + }), + ).resolves.toMatchObject({ + errors: [ + { + message: 'Event start date must be a valid date!', + }, + ], + }) + }) + }) + + describe('with event start date in the past', () => { + it('throws an error', async () => { + const now = new Date() + await expect( + mutate({ + mutation: createPostMutation(), + variables: { + ...variables, + postType: 'Event', + eventInput: { + eventStart: new Date(now.getFullYear(), now.getMonth() - 1).toISOString(), + }, + }, + }), + ).resolves.toMatchObject({ + errors: [ + { + message: 'Event start date must be in the future!', + }, + ], + }) + }) + }) + + describe('with valid start date and invalid end date', () => { + it('throws an error', async () => { + const now = new Date() + await expect( + mutate({ + mutation: createPostMutation(), + variables: { + ...variables, + postType: 'Event', + eventInput: { + eventStart: new Date(now.getFullYear(), now.getMonth() + 1).toISOString(), + eventEnd: 'not-valid', + }, + }, + }), + ).resolves.toMatchObject({ + errors: [ + { + message: 'Event end date must be a valid date!', + }, + ], + }) + }) + }) + + describe('with valid start date and end date before start date', () => { + it('throws an error', async () => { + const now = new Date() + await expect( + mutate({ + mutation: createPostMutation(), + variables: { + ...variables, + postType: 'Event', + eventInput: { + eventStart: new Date(now.getFullYear(), now.getMonth() + 2).toISOString(), + eventEnd: new Date(now.getFullYear(), now.getMonth() + 1).toISOString(), + }, + }, + }), + ).resolves.toMatchObject({ + errors: [ + { + message: 'Event end date must be a after event start date!', + }, + ], + }) + }) + }) + + describe('with valid start date and valid end date', () => { + it('creates the event', async () => { + const now = new Date() + await expect( + mutate({ + mutation: createPostMutation(), + variables: { + ...variables, + postType: 'Event', + eventInput: { + eventStart: new Date(now.getFullYear(), now.getMonth() + 1).toISOString(), + eventEnd: new Date(now.getFullYear(), now.getMonth() + 2).toISOString(), + }, + }, + }), + ).resolves.toMatchObject({ + data: { + CreatePost: { + postType: ['Event'], + eventStart: new Date(now.getFullYear(), now.getMonth() + 1).toISOString(), + eventEnd: new Date(now.getFullYear(), now.getMonth() + 2).toISOString(), + eventIsOnline: false, + }, + }, + errors: undefined, + }) + }) + }) + + describe('with valid start date and event is online', () => { + it('creates the event', async () => { + const now = new Date() + await expect( + mutate({ + mutation: createPostMutation(), + variables: { + ...variables, + postType: 'Event', + eventInput: { + eventStart: new Date(now.getFullYear(), now.getMonth() + 1).toISOString(), + eventIsOnline: true, + }, + }, + }), + ).resolves.toMatchObject({ + data: { + CreatePost: { + postType: ['Event'], + eventStart: new Date(now.getFullYear(), now.getMonth() + 1).toISOString(), + eventIsOnline: true, + }, + }, + errors: undefined, + }) + }) + }) + + describe('event location name is given but event venue is missing', () => { + it('throws an error', async () => { + const now = new Date() + await expect( + mutate({ + mutation: createPostMutation(), + variables: { + ...variables, + postType: 'Event', + eventInput: { + eventStart: new Date(now.getFullYear(), now.getMonth() + 1).toISOString(), + eventLocationName: 'Berlin', + }, + }, + }), + ).resolves.toMatchObject({ + errors: [ + { + message: 'Event venue must be present if event location is given!', + }, + ], + }) + }) + }) + + describe('valid event input without location', () => { + it('has label "Event" set', async () => { + const now = new Date() + await expect( + mutate({ + mutation: createPostMutation(), + variables: { + ...variables, + postType: 'Event', + eventInput: { + eventStart: new Date(now.getFullYear(), now.getMonth() + 1).toISOString(), + }, + }, + }), + ).resolves.toMatchObject({ + data: { + CreatePost: { + postType: ['Event'], + eventStart: new Date(now.getFullYear(), now.getMonth() + 1).toISOString(), + eventIsOnline: false, + }, + }, + errors: undefined, + }) + }) + }) + + describe('valid event input with location name', () => { + it('has label "Event" set', async () => { + const now = new Date() + await expect( + mutate({ + mutation: createPostMutation(), + variables: { + ...variables, + postType: 'Event', + eventInput: { + eventStart: new Date(now.getFullYear(), now.getMonth() + 1).toISOString(), + eventLocationName: 'Leipzig', + eventVenue: 'Connewitzer Kreuz', + }, + }, + }), + ).resolves.toMatchObject({ + data: { + CreatePost: { + postType: ['Event'], + eventStart: new Date(now.getFullYear(), now.getMonth() + 1).toISOString(), + eventLocationName: 'Leipzig', + eventVenue: 'Connewitzer Kreuz', + eventLocation: { + lng: 12.374733, + lat: 51.340632, + }, + }, + }, + errors: undefined, + }) + }) + }) + }) }) }) describe('UpdatePost', () => { let author, newlyCreatedPost const updatePostMutation = gql` - mutation ($id: ID!, $title: String!, $content: String!, $image: ImageInput) { - UpdatePost(id: $id, title: $title, content: $content, image: $image) { + mutation ( + $id: ID! + $title: String! + $content: String! + $image: ImageInput + $categoryIds: [ID] + $postType: PostType + $eventInput: _EventInput + ) { + UpdatePost( + id: $id + title: $title + content: $content + image: $image + categoryIds: $categoryIds + postType: $postType + eventInput: $eventInput + ) { id title content @@ -341,26 +624,34 @@ describe('UpdatePost', () => { } createdAt updatedAt + categories { + id + } + postType + eventStart + eventLocationName + eventVenue + eventLocation { + lng + lat + } } } ` beforeEach(async () => { author = await Factory.build('user', { slug: 'the-author' }) - newlyCreatedPost = await Factory.build( - 'post', - { - id: 'p9876', + authenticatedUser = await author.toJson() + const { data } = await mutate({ + mutation: createPostMutation(), + variables: { title: 'Old title', content: 'Old content', - }, - { - author, categoryIds, }, - ) - + }) + newlyCreatedPost = data.CreatePost variables = { - id: 'p9876', + id: newlyCreatedPost.id, title: 'New title', content: 'New content', } @@ -394,7 +685,7 @@ describe('UpdatePost', () => { it('updates a post', async () => { const expected = { - data: { UpdatePost: { id: 'p9876', content: 'New content' } }, + data: { UpdatePost: { id: newlyCreatedPost.id, content: 'New content' } }, errors: undefined, } await expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject( @@ -405,7 +696,11 @@ describe('UpdatePost', () => { it('updates a post, but maintains non-updated attributes', async () => { const expected = { data: { - UpdatePost: { id: 'p9876', content: 'New content', createdAt: expect.any(String) }, + UpdatePost: { + id: newlyCreatedPost.id, + content: 'New content', + createdAt: expect.any(String), + }, }, errors: undefined, } @@ -415,23 +710,20 @@ describe('UpdatePost', () => { }) it('updates the updatedAt attribute', async () => { - newlyCreatedPost = await newlyCreatedPost.toJson() const { data: { UpdatePost }, } = await mutate({ mutation: updatePostMutation, variables }) - expect(newlyCreatedPost.updatedAt).toBeTruthy() - expect(Date.parse(newlyCreatedPost.updatedAt)).toEqual(expect.any(Number)) expect(UpdatePost.updatedAt).toBeTruthy() expect(Date.parse(UpdatePost.updatedAt)).toEqual(expect.any(Number)) expect(newlyCreatedPost.updatedAt).not.toEqual(UpdatePost.updatedAt) }) - /* describe('no new category ids provided for update', () => { + describe('no new category ids provided for update', () => { it('resolves and keeps current categories', async () => { const expected = { data: { UpdatePost: { - id: 'p9876', + id: newlyCreatedPost.id, categories: expect.arrayContaining([{ id: 'cat9' }, { id: 'cat4' }, { id: 'cat15' }]), }, }, @@ -441,9 +733,9 @@ describe('UpdatePost', () => { expected, ) }) - }) */ + }) - /* describe('given category ids', () => { + describe('given category ids', () => { beforeEach(() => { variables = { ...variables, categoryIds: ['cat27'] } }) @@ -452,7 +744,7 @@ describe('UpdatePost', () => { const expected = { data: { UpdatePost: { - id: 'p9876', + id: newlyCreatedPost.id, categories: expect.arrayContaining([{ id: 'cat27' }]), }, }, @@ -462,9 +754,160 @@ describe('UpdatePost', () => { expected, ) }) - }) */ + }) - describe('params.image', () => { + describe('change post type to event', () => { + describe('with missing event start date', () => { + it('throws an error', async () => { + await expect( + mutate({ + mutation: updatePostMutation, + variables: { ...variables, postType: 'Event' }, + }), + ).resolves.toMatchObject({ + errors: [ + { + message: "Cannot read properties of undefined (reading 'eventStart')", + }, + ], + }) + }) + }) + + describe('with invalid event start date', () => { + it('throws an error', async () => { + await expect( + mutate({ + mutation: updatePostMutation, + variables: { + ...variables, + postType: 'Event', + eventInput: { + eventStart: 'no-date', + }, + }, + }), + ).resolves.toMatchObject({ + errors: [ + { + message: 'Event start date must be a valid date!', + }, + ], + }) + }) + }) + + describe('with event start date in the past', () => { + it('throws an error', async () => { + const now = new Date() + await expect( + mutate({ + mutation: updatePostMutation, + variables: { + ...variables, + postType: 'Event', + eventInput: { + eventStart: new Date(now.getFullYear(), now.getMonth() - 1).toISOString(), + }, + }, + }), + ).resolves.toMatchObject({ + errors: [ + { + message: 'Event start date must be in the future!', + }, + ], + }) + }) + }) + + describe('event location name is given but event venue is missing', () => { + it('throws an error', async () => { + const now = new Date() + await expect( + mutate({ + mutation: updatePostMutation, + variables: { + ...variables, + postType: 'Event', + eventInput: { + eventStart: new Date(now.getFullYear(), now.getMonth() + 1).toISOString(), + eventLocationName: 'Berlin', + }, + }, + }), + ).resolves.toMatchObject({ + errors: [ + { + message: 'Event venue must be present if event location is given!', + }, + ], + }) + }) + }) + + describe('valid event input without location name', () => { + it('has label "Event" set', async () => { + const now = new Date() + await expect( + mutate({ + mutation: updatePostMutation, + variables: { + ...variables, + postType: 'Event', + eventInput: { + eventStart: new Date(now.getFullYear(), now.getMonth() + 1).toISOString(), + }, + }, + }), + ).resolves.toMatchObject({ + data: { + UpdatePost: { + postType: ['Event'], + eventStart: new Date(now.getFullYear(), now.getMonth() + 1).toISOString(), + }, + }, + errors: undefined, + }) + }) + }) + + describe('valid event input with location name', () => { + it('has label "Event" set', async () => { + const now = new Date() + await expect( + mutate({ + mutation: updatePostMutation, + variables: { + ...variables, + postType: 'Event', + eventInput: { + eventStart: new Date(now.getFullYear(), now.getMonth() + 1).toISOString(), + eventLocationName: 'Leipzig', + eventVenue: 'Connewitzer Kreuz', + }, + }, + }), + ).resolves.toMatchObject({ + data: { + UpdatePost: { + postType: ['Event'], + eventStart: new Date(now.getFullYear(), now.getMonth() + 1).toISOString(), + eventLocationName: 'Leipzig', + eventVenue: 'Connewitzer Kreuz', + eventLocation: { + lng: 12.374733, + lat: 51.340632, + }, + }, + }, + errors: undefined, + }) + }) + }) + }) + + describe.skip('params.image', () => { describe('is object', () => { beforeEach(() => { variables = { ...variables, image: { sensitive: true } } diff --git a/backend/src/schema/resolvers/postsInGroups.spec.js b/backend/src/schema/resolvers/postsInGroups.spec.js index 86a278207..ba9041090 100644 --- a/backend/src/schema/resolvers/postsInGroups.spec.js +++ b/backend/src/schema/resolvers/postsInGroups.spec.js @@ -818,11 +818,13 @@ describe('Posts in Groups', () => { id: 'post-to-public-group', title: 'A post to a public group', content: 'I am posting into a public group as a member of the group', + eventStart: null, }, { id: 'post-without-group', title: 'A post without a group', content: 'I am a user who does not belong to a group yet.', + eventStart: null, }, ]), }, @@ -846,11 +848,13 @@ describe('Posts in Groups', () => { id: 'post-to-public-group', title: 'A post to a public group', content: 'I am posting into a public group as a member of the group', + eventStart: null, }, { id: 'post-without-group', title: 'A post without a group', content: 'I am a user who does not belong to a group yet.', + eventStart: null, }, ]), }, @@ -874,11 +878,13 @@ describe('Posts in Groups', () => { id: 'post-to-public-group', title: 'A post to a public group', content: 'I am posting into a public group as a member of the group', + eventStart: null, }, { id: 'post-without-group', title: 'A post without a group', content: 'I am a user who does not belong to a group yet.', + eventStart: null, }, ]), }, @@ -902,11 +908,13 @@ describe('Posts in Groups', () => { id: 'post-to-public-group', title: 'A post to a public group', content: 'I am posting into a public group as a member of the group', + eventStart: null, }, { id: 'post-without-group', title: 'A post without a group', content: 'I am a user who does not belong to a group yet.', + eventStart: null, }, ]), }, @@ -930,21 +938,25 @@ describe('Posts in Groups', () => { id: 'post-to-public-group', title: 'A post to a public group', content: 'I am posting into a public group as a member of the group', + eventStart: null, }, { id: 'post-without-group', title: 'A post without a group', content: 'I am a user who does not belong to a group yet.', + eventStart: null, }, { id: 'post-to-closed-group', title: 'A post to a closed group', content: 'I am posting into a closed group as a member of the group', + eventStart: null, }, { id: 'post-to-hidden-group', title: 'A post to a hidden group', content: 'I am posting into a hidden group as a member of the group', + eventStart: null, }, ]), }, @@ -1319,16 +1331,19 @@ describe('Posts in Groups', () => { id: 'post-to-public-group', title: 'A post to a public group', content: 'I am posting into a public group as a member of the group', + eventStart: null, }, { id: 'post-without-group', title: 'A post without a group', content: 'I am a user who does not belong to a group yet.', + eventStart: null, }, { id: 'post-to-closed-group', title: 'A post to a closed group', content: 'I am posting into a closed group as a member of the group', + eventStart: null, }, ]), }, @@ -1361,21 +1376,25 @@ describe('Posts in Groups', () => { id: 'post-to-public-group', title: 'A post to a public group', content: 'I am posting into a public group as a member of the group', + eventStart: null, }, { id: 'post-without-group', title: 'A post without a group', content: 'I am a user who does not belong to a group yet.', + eventStart: null, }, { id: 'post-to-closed-group', title: 'A post to a closed group', content: 'I am posting into a closed group as a member of the group', + eventStart: null, }, { id: 'post-to-hidden-group', title: 'A post to a hidden group', content: 'I am posting into a hidden group as a member of the group', + eventStart: null, }, ]), }, @@ -1410,16 +1429,19 @@ describe('Posts in Groups', () => { id: 'post-to-public-group', title: 'A post to a public group', content: 'I am posting into a public group as a member of the group', + eventStart: null, }, { id: 'post-without-group', title: 'A post without a group', content: 'I am a user who does not belong to a group yet.', + eventStart: null, }, { id: 'post-to-hidden-group', title: 'A post to a hidden group', content: 'I am posting into a hidden group as a member of the group', + eventStart: null, }, ]), }, @@ -1452,11 +1474,13 @@ describe('Posts in Groups', () => { id: 'post-to-public-group', title: 'A post to a public group', content: 'I am posting into a public group as a member of the group', + eventStart: null, }, { id: 'post-without-group', title: 'A post without a group', content: 'I am a user who does not belong to a group yet.', + eventStart: null, }, ]), }, @@ -1489,21 +1513,25 @@ describe('Posts in Groups', () => { id: 'post-to-public-group', title: 'A post to a public group', content: 'I am posting into a public group as a member of the group', + eventStart: null, }, { id: 'post-without-group', title: 'A post without a group', content: 'I am a user who does not belong to a group yet.', + eventStart: null, }, { id: 'post-to-closed-group', title: 'A post to a closed group', content: 'I am posting into a closed group as a member of the group', + eventStart: null, }, { id: 'post-to-hidden-group', title: 'A post to a hidden group', content: 'I am posting into a hidden group as a member of the group', + eventStart: null, }, ]), }, @@ -1534,21 +1562,25 @@ describe('Posts in Groups', () => { id: 'post-to-public-group', title: 'A post to a public group', content: 'I am posting into a public group as a member of the group', + eventStart: null, }, { id: 'post-without-group', title: 'A post without a group', content: 'I am a user who does not belong to a group yet.', + eventStart: null, }, { id: 'post-to-closed-group', title: 'A post to a closed group', content: 'I am posting into a closed group as a member of the group', + eventStart: null, }, { id: 'post-to-hidden-group', title: 'A post to a hidden group', content: 'I am posting into a hidden group as a member of the group', + eventStart: null, }, ]), }, @@ -1579,21 +1611,25 @@ describe('Posts in Groups', () => { id: 'post-to-public-group', title: 'A post to a public group', content: 'I am posting into a public group as a member of the group', + eventStart: null, }, { id: 'post-without-group', title: 'A post without a group', content: 'I am a user who does not belong to a group yet.', + eventStart: null, }, { id: 'post-to-closed-group', title: 'A post to a closed group', content: 'I am posting into a closed group as a member of the group', + eventStart: null, }, { id: 'post-to-hidden-group', title: 'A post to a hidden group', content: 'I am posting into a hidden group as a member of the group', + eventStart: null, }, ]), }, @@ -1628,21 +1664,25 @@ describe('Posts in Groups', () => { id: 'post-to-public-group', title: 'A post to a public group', content: 'I am posting into a public group as a member of the group', + eventStart: null, }, { id: 'post-without-group', title: 'A post without a group', content: 'I am a user who does not belong to a group yet.', + eventStart: null, }, { id: 'post-to-closed-group', title: 'A post to a closed group', content: 'I am posting into a closed group as a member of the group', + eventStart: null, }, { id: 'post-to-hidden-group', title: 'A post to a hidden group', content: 'I am posting into a hidden group as a member of the group', + eventStart: null, }, ]), }, @@ -1675,21 +1715,25 @@ describe('Posts in Groups', () => { id: 'post-to-public-group', title: 'A post to a public group', content: 'I am posting into a public group as a member of the group', + eventStart: null, }, { id: 'post-without-group', title: 'A post without a group', content: 'I am a user who does not belong to a group yet.', + eventStart: null, }, { id: 'post-to-closed-group', title: 'A post to a closed group', content: 'I am posting into a closed group as a member of the group', + eventStart: null, }, { id: 'post-to-hidden-group', title: 'A post to a hidden group', content: 'I am posting into a hidden group as a member of the group', + eventStart: null, }, ]), }, @@ -1739,11 +1783,13 @@ describe('Posts in Groups', () => { id: 'post-to-closed-group', title: 'A post to a closed group', content: 'I am posting into a closed group as a member of the group', + eventStart: null, }, { id: 'post-to-hidden-group', title: 'A post to a hidden group', content: 'I am posting into a hidden group as a member of the group', + eventStart: null, }, ]), }, diff --git a/backend/src/schema/resolvers/users/location.js b/backend/src/schema/resolvers/users/location.js index 9d8a11f89..54d73560b 100644 --- a/backend/src/schema/resolvers/users/location.js +++ b/backend/src/schema/resolvers/users/location.js @@ -22,7 +22,7 @@ const locales = ['en', 'de', 'fr', 'nl', 'it', 'es', 'pt', 'pl', 'ru'] const createLocation = async (session, mapboxData) => { const data = { - id: mapboxData.id, + id: mapboxData.id + (mapboxData.address ? `-${mapboxData.address}` : ''), nameEN: mapboxData.text_en, nameDE: mapboxData.text_de, nameFR: mapboxData.text_fr, @@ -33,6 +33,7 @@ const createLocation = async (session, mapboxData) => { namePL: mapboxData.text_pl, nameRU: mapboxData.text_ru, type: mapboxData.id.split('.')[0].toLowerCase(), + address: mapboxData.address, lng: mapboxData.center && mapboxData.center.length ? mapboxData.center[0] : null, lat: mapboxData.center && mapboxData.center.length ? mapboxData.center[1] : null, } @@ -54,6 +55,10 @@ const createLocation = async (session, mapboxData) => { if (data.lat && data.lng) { mutation += ', l.lat = $lat, l.lng = $lng' } + if (data.address) { + mutation += ', l.address = $address' + } + mutation += ' RETURN l.id' await session.writeTransaction((transaction) => { @@ -72,7 +77,7 @@ export const createOrUpdateLocations = async (nodeLabel, nodeId, locationName, s locationName, )}.json?access_token=${ CONFIG.MAPBOX_TOKEN - }&types=region,place,country&language=${locales.join(',')}`, + }&types=region,place,country,address&language=${locales.join(',')}`, ) debug(res) @@ -103,6 +108,10 @@ export const createOrUpdateLocations = async (nodeLabel, nodeId, locationName, s let parent = data + if (parent.address) { + parent.id += `-${parent.address}` + } + if (data.context) { await asyncForEach(data.context, async (ctx) => { await createLocation(session, ctx) diff --git a/backend/src/schema/types/enum/PostType.gql b/backend/src/schema/types/enum/PostType.gql new file mode 100644 index 000000000..eef80d6ba --- /dev/null +++ b/backend/src/schema/types/enum/PostType.gql @@ -0,0 +1,4 @@ +enum PostType { + Article + Event +} diff --git a/backend/src/schema/types/type/Post.gql b/backend/src/schema/types/type/Post.gql index 6fc7a3215..5a5d57b9a 100644 --- a/backend/src/schema/types/type/Post.gql +++ b/backend/src/schema/types/type/Post.gql @@ -83,6 +83,8 @@ input _PostFilter { emotions_every: _PostEMOTEDFilter group: _GroupFilter postsInMyGroups: Boolean + postType_in: [PostType] + eventStart_gte: String } enum _PostOrdering { @@ -104,6 +106,8 @@ enum _PostOrdering { language_desc pinned_asc pinned_desc + eventStart_asc + eventStart_desc } @@ -171,12 +175,30 @@ type Post { @cypher(statement: "MATCH (this)<-[emoted:EMOTED]-(:User) RETURN COUNT(DISTINCT emoted)") group: Group @relation(name: "IN", direction: "OUT") + + postType: [PostType] + @cypher(statement: "RETURN filter(l IN labels(this) WHERE NOT l = 'Post')") + + eventLocationName: String + eventLocation: Location @cypher(statement: "MATCH (this)-[:IS_IN]->(l:Location) RETURN l") + eventVenue: String + eventStart: String + eventEnd: String + eventIsOnline: Boolean } input _PostInput { id: ID! } +input _EventInput { + eventStart: String! + eventEnd: String + eventVenue: String + eventLocationName: String + eventIsOnline: Boolean +} + type Mutation { CreatePost( id: ID @@ -189,6 +211,8 @@ type Mutation { categoryIds: [ID] contentExcerpt: String groupId: ID + postType: PostType = Article + eventInput: _EventInput ): Post UpdatePost( id: ID! @@ -200,6 +224,8 @@ type Mutation { visibility: Visibility language: String categoryIds: [ID] + postType: PostType + eventInput: _EventInput ): Post DeletePost(id: ID!): Post AddPostEmotions(to: _PostInput!, data: _EMOTEDInput!): EMOTED diff --git a/webapp/.eslintignore b/webapp/.eslintignore index be90fc8e3..a90a66efa 100644 --- a/webapp/.eslintignore +++ b/webapp/.eslintignore @@ -1,5 +1,5 @@ node_modules -build +dist .nuxt styleguide/ **/*.min.js diff --git a/webapp/assets/_new/icons/svgs/book.svg b/webapp/assets/_new/icons/svgs/book.svg new file mode 100644 index 000000000..305e367ac --- /dev/null +++ b/webapp/assets/_new/icons/svgs/book.svg @@ -0,0 +1,5 @@ + + +book + + diff --git a/webapp/assets/_new/icons/svgs/calendar.svg b/webapp/assets/_new/icons/svgs/calendar.svg new file mode 100644 index 000000000..5a67a8299 --- /dev/null +++ b/webapp/assets/_new/icons/svgs/calendar.svg @@ -0,0 +1,5 @@ + + +calendar + + diff --git a/webapp/components/ContributionForm/ContributionForm.spec.js b/webapp/components/ContributionForm/ContributionForm.spec.js index ef3b47c37..6b9db448b 100644 --- a/webapp/components/ContributionForm/ContributionForm.spec.js +++ b/webapp/components/ContributionForm/ContributionForm.spec.js @@ -15,6 +15,7 @@ const stubs = { 'client-only': true, 'nuxt-link': true, 'v-popover': true, + 'date-picker': true, } describe('ContributionForm.vue', () => { @@ -45,6 +46,7 @@ describe('ContributionForm.vue', () => { slug: 'this-is-a-title-for-a-post', content: postContent, contentExcerpt: postContent, + postType: ['Article'], }, }, }), @@ -142,6 +144,7 @@ describe('ContributionForm.vue', () => { id: null, image: null, groupId: null, + postType: 'Article', }, } postTitleInput = wrapper.find('.ds-input') @@ -268,6 +271,7 @@ describe('ContributionForm.vue', () => { image: { sensitive: false, }, + postType: 'Article', }, } }) diff --git a/webapp/components/ContributionForm/ContributionForm.vue b/webapp/components/ContributionForm/ContributionForm.vue index f740e1f05..997a25341 100644 --- a/webapp/components/ContributionForm/ContributionForm.vue +++ b/webapp/components/ContributionForm/ContributionForm.vue @@ -30,6 +30,7 @@ + + + +
+
+ + + + +
+ +
+
+ + + +
+
+ + + + + +
+ + + +
+ + {{ formData.eventVenue.length }}/{{ formSchema.eventVenue.max }} + + +
+
+ + +
+ + {{ formData.eventLocationName.length }}/{{ formSchema.eventLocationName.max }} + + +
+
+
+ +
+ + {{ $t('post.viewEvent.eventIsOnline') }} +
+
+ + - diff --git a/webapp/components/FilterMenu/CategoriesFilter.spec.js b/webapp/components/FilterMenu/CategoriesFilter.spec.js index 677217585..823f0ecb0 100644 --- a/webapp/components/FilterMenu/CategoriesFilter.spec.js +++ b/webapp/components/FilterMenu/CategoriesFilter.spec.js @@ -72,9 +72,7 @@ describe('CategoriesFilter.vue', () => { describe('click on an "catetories-buttons" button', () => { it('calls TOGGLE_CATEGORY when clicked', () => { - environmentAndNatureButton = wrapper - .findAll('.categories-filter .item-category .base-button') - .at(0) + environmentAndNatureButton = wrapper.findAll('.category-filter-list .base-button').at(0) environmentAndNatureButton.trigger('click') expect(mutations['posts/TOGGLE_CATEGORY']).toHaveBeenCalledWith({}, 'cat4') }) diff --git a/webapp/components/FilterMenu/CategoriesFilter.vue b/webapp/components/FilterMenu/CategoriesFilter.vue index bcbfc7d1c..8b96245fd 100644 --- a/webapp/components/FilterMenu/CategoriesFilter.vue +++ b/webapp/components/FilterMenu/CategoriesFilter.vue @@ -17,18 +17,22 @@ @@ -95,3 +99,13 @@ export default { }, } + diff --git a/webapp/components/FilterMenu/FilterMenu.spec.js b/webapp/components/FilterMenu/FilterMenu.spec.js index 6e9741e79..c3a3ca113 100644 --- a/webapp/components/FilterMenu/FilterMenu.spec.js +++ b/webapp/components/FilterMenu/FilterMenu.spec.js @@ -20,6 +20,7 @@ describe('FilterMenu.vue', () => { const stubs = { FollowingFilter: true, + PostTypeFilter: true, CategoriesFilter: true, EmotionsFilter: true, LanguagesFilter: true, diff --git a/webapp/components/FilterMenu/FilterMenuComponent.vue b/webapp/components/FilterMenu/FilterMenuComponent.vue index 7a582a7c4..708c3bc7c 100644 --- a/webapp/components/FilterMenu/FilterMenuComponent.vue +++ b/webapp/components/FilterMenu/FilterMenuComponent.vue @@ -3,6 +3,7 @@

{{ $t('filter-menu.filter-by') }}

+
@@ -13,6 +14,7 @@ diff --git a/webapp/components/PostTeaser/PostTeaser.spec.js b/webapp/components/PostTeaser/PostTeaser.spec.js index 7791c62f0..0d48729d3 100644 --- a/webapp/components/PostTeaser/PostTeaser.spec.js +++ b/webapp/components/PostTeaser/PostTeaser.spec.js @@ -28,6 +28,7 @@ describe('PostTeaser', () => { author: { id: 'u1', }, + postType: ['Article'], }, } stubs = { diff --git a/webapp/components/PostTeaser/PostTeaser.vue b/webapp/components/PostTeaser/PostTeaser.vue index 37f471bb8..604fb121f 100644 --- a/webapp/components/PostTeaser/PostTeaser.vue +++ b/webapp/components/PostTeaser/PostTeaser.vue @@ -11,6 +11,7 @@ }" :highlight="isPinned" > + @@ -19,11 +20,37 @@

{{ post.title }}

+ + + + + + + {{ $t('post.viewEvent.eventIsOnline') }} + + + {{ post.eventLocationName }} + + + + + + + {{ getEventDateString }} + + + +
@@ -91,6 +118,7 @@ import UserTeaser from '~/components/UserTeaser/UserTeaser' import { mapGetters } from 'vuex' import PostMutations from '~/graphql/PostMutations' import { postMenuModalsData, deletePostMutation } from '~/components/utils/PostHelpers' +import { format } from 'date-fns' export default { name: 'PostTeaser', @@ -152,6 +180,20 @@ export default { isPinned() { return this.post && this.post.pinned }, + ribbonText() { + if (this.post.pinned) return this.$t('post.pinned') + if (this.post.postType[0] === 'Event') return this.$t('post.event') + return this.$t('post.name') + }, + getEventDateString() { + if (this.post.eventEnd) { + const eventStart = format(new Date(this.post.eventStart), 'dd.MM.') + const eventEnd = format(new Date(this.post.eventEnd), 'dd.MM.yyyy') + return `${eventStart} - ${eventEnd}` + } else { + return format(new Date(this.post.eventStart), 'dd.MM.yyyy') + } + }, }, methods: { async deletePostCallback() { @@ -236,6 +278,12 @@ export default { margin-bottom: $space-small; } + & .event-info { + display: flex; + align-items: center; + gap: 2px; + } + > .footer { display: flex; justify-content: space-between; diff --git a/webapp/components/Ribbon/index.vue b/webapp/components/Ribbon/index.vue index f54b456e5..ea7fd273a 100644 --- a/webapp/components/Ribbon/index.vue +++ b/webapp/components/Ribbon/index.vue @@ -1,5 +1,5 @@ @@ -12,6 +12,10 @@ export default { type: String, default: '', }, + typ: { + type: String, + default: 'blue', + }, }, } @@ -43,4 +47,11 @@ export default { } } } +.eventBg { + background-color: $color-success-active; + + &::before { + border-color: $color-success-active transparent transparent $color-success-active; + } +} diff --git a/webapp/graphql/PostMutations.js b/webapp/graphql/PostMutations.js index 8880a93b0..6b71b86b9 100644 --- a/webapp/graphql/PostMutations.js +++ b/webapp/graphql/PostMutations.js @@ -4,21 +4,30 @@ export default () => { return { CreatePost: gql` mutation ( + $id: ID $title: String! + $slug: String $content: String! $categoryIds: [ID] $image: ImageInput $groupId: ID + $postType: PostType + $eventInput: _EventInput ) { CreatePost( + id: $id title: $title + slug: $slug content: $content categoryIds: $categoryIds image: $image groupId: $groupId + postType: $postType + eventInput: $eventInput ) { - title + id slug + title content contentExcerpt language @@ -26,6 +35,22 @@ export default () => { url sensitive } + disabled + deleted + postType + author { + name + } + categories { + id + } + eventStart + eventVenue + eventLocationName + eventLocation { + lng + lat + } } } `, diff --git a/webapp/graphql/PostQuery.js b/webapp/graphql/PostQuery.js index 1c6c76276..83eec2a30 100644 --- a/webapp/graphql/PostQuery.js +++ b/webapp/graphql/PostQuery.js @@ -24,6 +24,12 @@ export default (i18n) => { query Post($id: ID!) { Post(id: $id) { + postType + eventStart + eventEnd + eventVenue + eventLocationName + eventIsOnline ...post ...postCounts ...tagsCategoriesAndPinned @@ -66,6 +72,12 @@ export const filterPosts = (i18n) => { query Post($filter: _PostFilter, $first: Int, $offset: Int, $orderBy: [_PostOrdering]) { Post(filter: $filter, first: $first, offset: $offset, orderBy: $orderBy) { + postType + eventStart + eventEnd + eventVenue + eventLocationName + eventIsOnline ...post ...postCounts ...tagsCategoriesAndPinned @@ -103,6 +115,10 @@ export const profilePagePosts = (i18n) => { $orderBy: [_PostOrdering] ) { profilePagePosts(filter: $filter, first: $first, offset: $offset, orderBy: $orderBy) { + postType + eventStart + eventVenue + eventLocationName ...post ...postCounts ...tagsCategoriesAndPinned diff --git a/webapp/jest.config.js b/webapp/jest.config.js index 6b0f5e461..b38b05ecd 100644 --- a/webapp/jest.config.js +++ b/webapp/jest.config.js @@ -29,8 +29,9 @@ module.exports = { modulePathIgnorePatterns: ['/dist/'], moduleNameMapper: { '\\.(svg)$': '/test/fileMock.js', - '\\.(css|less)$': 'identity-obj-proxy', + '\\.(scss|css|less)$': 'identity-obj-proxy', '@mapbox/mapbox-gl-geocoder': 'identity-obj-proxy', + 'vue2-datepicker/locale/undefined': 'vue2-datepicker/locale/en', '^@/(.*)$': '/src/$1', '^~/(.*)$': '/$1', }, diff --git a/webapp/locales/de.json b/webapp/locales/de.json index fe39eed69..82b7c7bd4 100644 --- a/webapp/locales/de.json +++ b/webapp/locales/de.json @@ -278,6 +278,7 @@ "inappropriatePicture": "Dieses Bild kann für einige Menschen unangemessen sein.", "languageSelectLabel": "Sprache Deines Beitrags", "languageSelectText": "Sprache wählen", + "newEvent": "Erstelle einen neue Veranstaltung", "newPost": "Erstelle einen neuen Beitrag", "success": "Gespeichert!", "teaserImage": { @@ -377,9 +378,11 @@ }, "filter-menu": { "all": "Alle", + "article": "Artikel", "categories": "Themen", "deleteFilter": "Filter löschen", "emotions": "Emotionen", + "events": "Veranstaltungen", "filter-by": "Filtern nach ...", "following": "Nutzer denen ich folge", "languages": "Sprachen", @@ -395,6 +398,7 @@ } }, "order-by": "Sortieren nach ...", + "post-type": "Beitrags-Typ", "save": { "error": "Themen konnten nicht gespeichert werden!", "success": "Themen gespeichert!" @@ -684,6 +688,12 @@ "submitted": "Kommentar gesendet", "updated": "Änderungen gespeichert" }, + "createNewEvent": { + "forGroup": { + "title": "Für die Gruppe „{name}“" + }, + "title": "Erstelle ein neues Event" + }, "createNewPost": { "forGroup": { "title": "Für die Gruppe „{name}“" @@ -697,6 +707,7 @@ }, "title": "Bearbeite deinen Beitrag" }, + "event": "Veranstaltung", "menu": { "delete": "Beitrag löschen", "edit": "Beitrag bearbeiten", @@ -710,6 +721,14 @@ "takeAction": { "name": "Aktiv werden" }, + "viewEvent": { + "eventEnd": "Ende", + "eventIsOnline": "Online Veranstaltung", + "eventLocationName": "Stadt", + "eventStart": "Beginn", + "eventVenue": "Veranstaltungsort", + "title": "Veranstaltung" + }, "viewPost": { "forGroup": { "title": "In der Gruppe „{name}“" diff --git a/webapp/locales/en.json b/webapp/locales/en.json index 92f5687c2..45a241957 100644 --- a/webapp/locales/en.json +++ b/webapp/locales/en.json @@ -278,6 +278,7 @@ "inappropriatePicture": "This image may be inappropriate for some people.", "languageSelectLabel": "Language of your contribution", "languageSelectText": "Select Language", + "newEvent": "Create a new Event", "newPost": "Create a new Post", "success": "Saved!", "teaserImage": { @@ -377,9 +378,11 @@ }, "filter-menu": { "all": "All", + "article": "Article", "categories": "Topics", "deleteFilter": "Delete filter", "emotions": "Emotions", + "events": "Events", "filter-by": "Filter by ...", "following": "Users I follow", "languages": "Languages", @@ -395,6 +398,7 @@ } }, "order-by": "Order by ...", + "post-type": "Post type", "save": { "error": "Failed saving topic settings!", "success": "Topics saved!" @@ -684,6 +688,12 @@ "submitted": "Comment submitted!", "updated": "Changes saved!" }, + "createNewEvent": { + "forGroup": { + "title": "For The Group “{name}”" + }, + "title": "Create A New Event" + }, "createNewPost": { "forGroup": { "title": "For The Group “{name}”" @@ -697,6 +707,7 @@ }, "title": "Edit Your Post" }, + "event": "Event", "menu": { "delete": "Delete post", "edit": "Edit post", @@ -710,6 +721,14 @@ "takeAction": { "name": "Take action" }, + "viewEvent": { + "eventEnd": "End", + "eventIsOnline": "Online Event", + "eventLocationName": "City", + "eventStart": "Start", + "eventVenue": "Venue", + "title": "Event" + }, "viewPost": { "forGroup": { "title": "In The Group “{name}”" diff --git a/webapp/package.json b/webapp/package.json index 095cb12ee..9c0b91155 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -60,6 +60,7 @@ "vue-observe-visibility": "^1.0.0", "vue-scrollto": "^2.20.0", "vue-sweetalert-icons": "~4.3.1", + "vue2-datepicker": "^3.11.1", "vuex-i18n": "~1.13.1", "xregexp": "^4.3.0", "zxcvbn": "^4.4.2" diff --git a/webapp/pages/post/_id/_slug/index.spec.js b/webapp/pages/post/_id/_slug/index.spec.js index 4528e64ee..5340770ab 100644 --- a/webapp/pages/post/_id/_slug/index.spec.js +++ b/webapp/pages/post/_id/_slug/index.spec.js @@ -19,6 +19,7 @@ describe('PostSlug', () => { post: { id: '1', author, + postType: ['Article'], comments: [ { id: 'comment134', @@ -111,6 +112,7 @@ describe('PostSlug', () => { id: '1', author: null, comments: [], + postType: ['Article'], }, ready: true, } diff --git a/webapp/pages/post/_id/_slug/index.vue b/webapp/pages/post/_id/_slug/index.vue index 6e104bc53..743d58b54 100644 --- a/webapp/pages/post/_id/_slug/index.vue +++ b/webapp/pages/post/_id/_slug/index.vue @@ -2,7 +2,7 @@
- {{ $t('post.viewPost.title') }} + {{ heading }} {{ $t('post.viewPost.forGroup.title', { name: post.group.name }) }} @@ -54,6 +54,33 @@

{{ post.title }}

+ + + + + {{ post.eventVenue }} + + - + {{ post.eventLocationName }} + + + - + {{ $t('post.viewEvent.eventIsOnline') }} + + + + + {{ getEventDateString }} + + + + {{ getEventTimeString }} + + @@ -147,6 +174,7 @@ import { groupQuery } from '~/graphql/groups' import PostMutations from '~/graphql/PostMutations' import links from '~/constants/links.js' import SortCategories from '~/mixins/sortCategoriesMixin.js' +import { format } from 'date-fns' export default { name: 'PostSlug', @@ -218,6 +246,10 @@ export default { }, ] }, + heading() { + if (this.post?.postType[0] === 'Event') return this.$t('post.viewEvent.title') + return this.$t('post.viewPost.title') + }, menuModalsData() { return postMenuModalsData( // "this.post" may not always be defined at the beginning … @@ -256,6 +288,27 @@ export default { !this.post.group || (this.group && ['usual', 'admin', 'owner'].includes(this.group.myRole)) ) }, + getEventDateString() { + if (this.post.eventEnd) { + const eventStart = format(new Date(this.post.eventStart), 'dd.MM.') + const eventEnd = format(new Date(this.post.eventEnd), 'dd.MM.yyyy') + return `${eventStart} - ${eventEnd}` + } else { + return format(new Date(this.post.eventStart), 'dd.MM.yyyy') + } + }, + getEventTimeString() { + if (this.post.eventEnd) { + const eventStartTime = format(new Date(this.post.eventStart), 'HH:mm') + const eventEndTime = format(new Date(this.post.eventEnd), 'HH:mm') + /* assumption that if e.g. 00:00 == 00:00 is saved, + it's not realistic because they are the default values, so don't show the time info. + */ + return eventStartTime !== eventEndTime ? `${eventStartTime} - ${eventEndTime}` : '' + } else { + return format(new Date(this.post.eventStart), 'HH:mm') + } + }, }, methods: { reply(message) { @@ -374,6 +427,12 @@ export default { filter: blur($blur-radius); } + & .event-info { + display: flex; + align-items: center; + gap: 2px; + } + .blur-toggle { position: absolute; bottom: 0; diff --git a/webapp/pages/post/create.vue b/webapp/pages/post/create.vue index 60309480d..199cc14a4 100644 --- a/webapp/pages/post/create.vue +++ b/webapp/pages/post/create.vue @@ -1,15 +1,56 @@