Merge branch 'master' of https://github.com/Ocelot-Social-Community/Ocelot-Social into 4092-redesign-registration-process

This commit is contained in:
Wolfgang Huß 2021-02-01 10:55:50 +01:00
commit 3a3a274563
68 changed files with 26504 additions and 814 deletions

View File

@ -15,32 +15,78 @@ jobs:
scripts/translations/sort.sh
scripts/translations/missing-keys.sh
- name: Build neo4j image
##########################################################################
# NEO4J ##################################################################
##########################################################################
- name: Neo4J | Build `community` image
uses: docker/build-push-action@v1.1.0
with:
repository: ocelotsocialnetwork/neo4j
tags: latest
path: neo4j/
push: false
- name: Build backend base image
##########################################################################
# BACKEND ################################################################
##########################################################################
# TODO: We want to push this to dockerhub
#- name: Build backend production image
# uses: docker/build-push-action@v1.1.0
# with:
# repository: ocelotsocialnetwork/backend
# tags: production
# target: production
# path: backend/
# push: false
# Build Docker Image (build)
- name: backend | Build `build` image
uses: docker/build-push-action@v1.1.0
with:
repository: ocelotsocialnetwork/backend
tags: build-and-test
target: build-and-test
tags: build
target: build
path: backend/
push: false
- name: Build webapp base image
# Lint
- name: backend | Lint
run: docker run --rm ocelotsocialnetwork/backend:build yarn run lint
# Unit Tests
#- name: backend | Unit tests
# run: |
# docker-compose up
# docker-compose exec backend yarn test
##########################################################################
# WEBAPP #################################################################
##########################################################################
# TODO: We want to push this to dockerhub
#- name: Build webapp production image
# uses: docker/build-push-action@v1.1.0
# with:
# repository: ocelotsocialnetwork/webapp
# tags: production
# target: production
# path: webapp/
# push: false
# Build Docker Image (build)
- name: webapp | Build `build` image
uses: docker/build-push-action@v1.1.0
with:
repository: ocelotsocialnetwork/webapp
tags: build-and-test
target: build-and-test
tags: build
target: build
path: webapp/
push: false
- name: Lint backend
run: docker run --rm ocelotsocialnetwork/backend:build-and-test yarn run lint
- name: Lint webapp
run: docker run --rm ocelotsocialnetwork/webapp:build-and-test yarn run lint
# Lint
- name: webapp | Lint
run: docker run --rm ocelotsocialnetwork/webapp:build yarn run lint
# Unit Tests
- name: webapp | Unit tests
run: docker run --rm ocelotsocialnetwork/webapp:build yarn run test

100
README.md
View File

@ -1,4 +1,4 @@
# Human-Connection
# ocelot.social
[![Build Status](https://travis-ci.com/Human-Connection/Human-Connection.svg?branch=master)](https://travis-ci.com/Human-Connection/Human-Connection)
[![Codecov Coverage](https://img.shields.io/codecov/c/github/Human-Connection/Human-Connection/master.svg?style=flat-square)](https://codecov.io/gh/Human-Connection/Human-Connection/)
@ -6,22 +6,13 @@
[![Discord Channel](https://img.shields.io/discord/489522408076738561.svg)](https://discordapp.com/invite/DFSjPaX)
[![Open Source Helpers](https://www.codetriage.com/human-connection/human-connection/badges/users.svg)](https://www.codetriage.com/human-connection/human-connection)
Human Connection is a nonprofit social, action and knowledge network that connects information to action and promotes positive local and global change in all areas of life.
ocelot.social is a nonprofit social, action and knowledge network that connects information to action and promotes positive local and global change in all areas of life.
* **Social**: Interact with other people not just by commenting their posts, but by providing **Pro & Contra** arguments, give a **Versus** or ask them by integrated **Chat** or **Let's Talk**
* **Knowledge**: Read articles about interesting topics and find related posts in the **More Info** tab or by **Filtering** based on **Categories** and **Tagging** or by using the **Fulltext Search**.
* **Action**: Don't just read about how to make the world a better place, but come into **Action** by following provided suggestions on the **Action** tab provided by other people or **Organisations**.
[![Human-Connection](.gitbook/assets/lets_get_together.png)](https://human-connection.org)
**Technology Stack**
* [VueJS](https://vuejs.org/)
* [NuxtJS](https://nuxtjs.org/)
* [GraphQL](https://graphql.org/)
* [NodeJS](https://nodejs.org/en/)
* [Neo4J](https://neo4j.com/)
[![Ocelot-Social](webapp/static/img/custom/welcome.svg)](https://ocelot.social)
## Live demo
@ -35,14 +26,77 @@ Logins:
| `moderator@example.org` | 1234 | moderator |
| `admin@example.org` | 1234 | admin |
## Documentation
## Directory Layout
Learn how to set up a local development environment in our [Docs](https://docs.human-connection.org/human-connection/) :mag_right:
There are four important directories:
* [Backend](./backend) runs on the server and is a middleware between database and frontend
* [Frontend](./webapp) is a server-side-rendered and client-side-rendered web frontend
* [Deployment](./deployment) configuration for kubernetes
* [Cypress](./cypress) contains end-to-end tests and executable feature specifications
## Translations
In order to setup the application and start to develop features you have to
setup **frontend** and **backend**.
You can help translating the interface by joining us on [lokalise.co](https://lokalise.co/public/556252725c18dd752dd546.13222042/).
Thank you lokalise for providing us with a premium account :raised_hands:.
There are two approaches:
1. Local installation, which means you have to take care of dependencies yourself
2. **Or** Install everything through Docker which takes care of dependencies for you
## Installation
### Clone the Repository
Clone the repository, this will create a new folder called `Ocelot-Social`:
Using HTTPS:
```bash
$ git clone https://github.com/Ocelot-Social-Community/Ocelot-Social.git
```
Using SSH:
```bash
$ git clone git@github.com:Human-Connection/Human-Connection.git
```
Change into the new folder.
```bash
$ cd Ocelot-Social
```
### Docker Installation
Docker is a software development container tool that combines software and its dependencies into one standardized unit that contains everything needed to run it. This helps us to avoid problems with dependencies and makes installation easier.
#### General Installation of Docker
There are [sevaral ways to install Docker CE](https://docs.docker.com/install/) on your computer or server.
* [install Docker Desktop on macOS](https://docs.docker.com/docker-for-mac/install/)
* [install Docker Desktop on Windows](https://docs.docker.com/docker-for-windows/install/)
* [install Docker CE on Linux](https://docs.docker.com/install/)
Check the correct Docker installation by checking the version before proceeding. E.g. we have the following versions:
```bash
$ docker --version
Docker version 18.09.2
$ docker-compose --version
docker-compose version 1.23.2
```
#### Start Ocelot-Social via Docker-Compose
For Development:
```bash
docker-compose up
```
For Production
```bash
docker-compose -f docker-compose.yml up
```
This will start all required Docker containers
## Developer Chat
@ -50,12 +104,16 @@ Join our friendly open-source community on [Discord](https://discordapp.com/invi
Just introduce yourself at `#introduce-yourself` and mention `@@Mentor` to get you onboard :neckbeard:
Check out the [contribution guideline](./CONTRIBUTING.md), too!
[![](https://sourcerer.io/fame/roschaefer/Human-Connection/Human-Connection/images/0)](https://sourcerer.io/fame/roschaefer/Human-Connection/Human-Connection/links/0)[![](https://sourcerer.io/fame/roschaefer/Human-Connection/Human-Connection/images/1)](https://sourcerer.io/fame/roschaefer/Human-Connection/Human-Connection/links/1)[![](https://sourcerer.io/fame/roschaefer/Human-Connection/Human-Connection/images/2)](https://sourcerer.io/fame/roschaefer/Human-Connection/Human-Connection/links/2)[![](https://sourcerer.io/fame/roschaefer/Human-Connection/Human-Connection/images/3)](https://sourcerer.io/fame/roschaefer/Human-Connection/Human-Connection/links/3)[![](https://sourcerer.io/fame/roschaefer/Human-Connection/Human-Connection/images/4)](https://sourcerer.io/fame/roschaefer/Human-Connection/Human-Connection/links/4)[![](https://sourcerer.io/fame/roschaefer/Human-Connection/Human-Connection/images/5)](https://sourcerer.io/fame/roschaefer/Human-Connection/Human-Connection/links/5)[![](https://sourcerer.io/fame/roschaefer/Human-Connection/Human-Connection/images/6)](https://sourcerer.io/fame/roschaefer/Human-Connection/Human-Connection/links/6)[![](https://sourcerer.io/fame/roschaefer/Human-Connection/Human-Connection/images/7)](https://sourcerer.io/fame/roschaefer/Human-Connection/Human-Connection/links/7)
We give write permissions to every developer who asks for it. Just text us on
[Discord](https://discord.gg/6ub73U3).
## Open-Source Bounties
## Technology Stack
You can get a small financial compensation for your contribution :moneybag: See
details in our [Contribution Guidelines](./CONTRIBUTING.md#open-source-bounties).
* [VueJS](https://vuejs.org/)
* [NuxtJS](https://nuxtjs.org/)
* [GraphQL](https://graphql.org/)
* [NodeJS](https://nodejs.org/en/)
* [Neo4J](https://neo4j.com/)
## Attributions

View File

@ -2,7 +2,6 @@
* [Introduction](README.md)
* [Edit this Documentation](edit-this-documentation.md)
* [Installation](installation.md)
* [Neo4J](neo4j/README.md)
* [Backend](backend/README.md)
* [GraphQL](backend/graphql.md)

View File

@ -1,28 +1,87 @@
##################################################################################
# BASE ###########################################################################
##################################################################################
FROM node:12.19.0-alpine3.10 as base
LABEL Description="Backend of the Social Network ocelot.social" Vendor="ocelot.social Community" Version="0.0.1" Maintainer="ocelot.social Community (devops@ocelot.social)"
EXPOSE 4000
CMD ["yarn", "run", "start"]
ARG BUILD_COMMIT
ENV BUILD_COMMIT=$BUILD_COMMIT
ARG WORKDIR=/develop-backend
RUN mkdir -p $WORKDIR
WORKDIR $WORKDIR
# ENVs (available in production aswell, can be overwritten by commandline or env file)
## DOCKER_WORKDIR would be a classical ARG, but that is not multi layer persistent - shame
ENV DOCKER_WORKDIR="/app"
## We Cannot do `$(date -u +'%Y-%m-%dT%H:%M:%SZ')` here so we use unix timestamp=0
ENV BUILD_DATE="1970-01-01T00:00:00.00Z"
## We cannot do $(yarn run version) here so we default to 0.0.0
## TODO: Missing Build number - do that once we have a CI which actually generates it
ENV BUILD_VERSION="0.0.0"
## We cannot do `$(git rev-parse --short HEAD)` here so we default to 0000000
ENV BUILD_COMMIT="0000000"
## SET NODE_ENV
ENV NODE_ENV="production"
## App relevant Envs
ENV PORT="4000"
# Labels
LABEL org.label-schema.build-date="${BUILD_DATE}"
LABEL org.label-schema.name="ocelot.social:backend"
LABEL org.label-schema.description="Backend of the Social Network Software ocelot.social"
LABEL org.label-schema.usage="https://github.com/Ocelot-Social-Community/Ocelot-Social/blob/master/README.md"
LABEL org.label-schema.url="https://ocelot.social"
LABEL org.label-schema.vcs-url="https://github.com/Ocelot-Social-Community/Ocelot-Social/tree/master/backend"
LABEL org.label-schema.vcs-ref="${BUILD_COMMIT}"
LABEL org.label-schema.vendor="ocelot.social Community"
LABEL org.label-schema.version="${BUILD_VERSION}"
LABEL org.label-schema.schema-version="1.0"
LABEL maintainer="devops@ocelot.social"
# Install Additional Software
## install: git
RUN apk --no-cache add git
COPY package.json yarn.lock ./
COPY .env.template .env
# Settings
## Expose Container Port
EXPOSE ${PORT}
FROM base as build-and-test
RUN yarn install --production=false --frozen-lockfile --non-interactive
## Workdir
RUN mkdir -p ${DOCKER_WORKDIR}
WORKDIR ${DOCKER_WORKDIR}
##################################################################################
# DEVELOPMENT (Connected to the local environment, to reload on demand) ##########
##################################################################################
FROM base as development
# We don't need to copy or build anything since we gonna bind to the
# local filesystem which will need a rebuild anyway
# Run command
# (for development we need to execute yarn install since the
# node_modules are on another volume and need updating)
CMD /bin/sh -c "yarn install && yarn run dev"
##################################################################################
# BUILD (Does contain all files and is therefore bloated) ########################
##################################################################################
FROM base as build
# Copy everything
COPY . .
RUN NODE_ENV=production yarn run build
# yarn install
RUN yarn install --production=false --frozen-lockfile --non-interactive
# yarn build
RUN yarn run build
# reduce image size with a multistage build
##################################################################################
# PRODUCTION (Does contain only "binary"- and static-files to reduce image size) #
##################################################################################
FROM base as production
ENV NODE_ENV=production
COPY --from=build-and-test /develop-backend/dist ./dist
COPY ./public/img/ ./public/img/
COPY ./public/providers.json ./public/providers.json
RUN yarn install --production=true --frozen-lockfile --non-interactive --no-cache
# Copy "binary"-files from build image
COPY --from=build ${DOCKER_WORKDIR}/dist ./dist
COPY --from=build ${DOCKER_WORKDIR}/node_modules ./node_modules
# Copy static files
# TODO - externalize the uploads so we can copy the whole folder
COPY --from=build ${DOCKER_WORKDIR}/public/img/ ./public/img/
COPY --from=build ${DOCKER_WORKDIR}/public/providers.json ./public/providers.json
# Copy package.json for script definitions (lock file should not be needed)
COPY --from=build ${DOCKER_WORKDIR}/package.json ./package.json
# Run command
CMD /bin/sh -c "yarn run start"

View File

@ -178,32 +178,20 @@ database after each test, running the tests will wipe out all your data!
{% tabs %}
{% tab title="Docker" %}
Run the _**jest**_ tests:
Run the unit tests:
```bash
$ docker-compose exec backend yarn run test:jest
```
Run the _**cucumber**_ features:
```bash
$ docker-compose exec backend yarn run test:cucumber
$ docker-compose exec backend yarn run test
```
{% endtab %}
{% tab title="Without Docker" %}
Run the _**jest**_ tests:
Run the unit tests:
```bash
$ yarn run test:jest
```
Run the _**cucumber**_ features:
```bash
$ yarn run test:cucumber
$ yarn run test
```
{% endtab %}

View File

@ -15,7 +15,7 @@
"dev": "nodemon --exec babel-node src/ -e js,gql",
"dev:debug": "nodemon --exec babel-node --inspect=0.0.0.0:9229 src/ -e js,gql",
"lint": "eslint src --config .eslintrc.js",
"test": "jest --forceExit --detectOpenHandles --runInBand",
"test": "cross-env NODE_ENV=test jest --forceExit --detectOpenHandles --runInBand",
"db:clean": "babel-node src/db/clean.js",
"db:reset": "yarn run db:clean",
"db:seed": "babel-node src/db/seed.js",

View File

@ -1,6 +1,7 @@
import { handler } from './webfinger'
import Factory, { cleanDatabase } from '../../db/factories'
import { getDriver } from '../../db/neo4j'
import CONFIG from '../../config'
let resource, res, json, status, contentType
@ -98,12 +99,12 @@ describe('webfinger', () => {
expect(json).toHaveBeenCalledWith({
links: [
{
href: 'http://localhost:3000/activitypub/users/some-user',
href: `${CONFIG.CLIENT_URI}/activitypub/users/some-user`,
rel: 'self',
type: 'application/activity+json',
},
],
subject: 'acct:some-user@localhost:3000',
subject: `acct:some-user@${new URL(CONFIG.CLIENT_URI).host}`,
})
})
})

View File

@ -2,111 +2,106 @@ import dotenv from 'dotenv'
import links from './links.js'
import metadata from './metadata.js'
// Load env file
if (require.resolve) {
// are we in a nodejs environment?
try {
dotenv.config({ path: require.resolve('../../.env') })
} catch (error) {
if (error.code !== 'MODULE_NOT_FOUND') throw error
console.log('WARN: No `.env` file found in /backend') // eslint-disable-line no-console
if (error.code === 'MODULE_NOT_FOUND') {
console.log('WARN: No `.env` file found in `/app` (docker) or `/backend` (no docker)') // eslint-disable-line no-console
} else {
throw error
}
}
}
// eslint-disable-next-line no-undef
const env = typeof Cypress !== 'undefined' ? Cypress.env() : process.env
// Use Cypress env or process.env
const env = typeof Cypress !== 'undefined' ? Cypress.env() : process.env // eslint-disable-line no-undef
const {
MAPBOX_TOKEN,
JWT_SECRET,
PRIVATE_KEY_PASSPHRASE,
SMTP_IGNORE_TLS = true,
SMTP_HOST,
SMTP_PORT,
SMTP_USERNAME,
SMTP_PASSWORD,
SENTRY_DSN_BACKEND,
COMMIT,
AWS_ACCESS_KEY_ID,
AWS_SECRET_ACCESS_KEY,
AWS_ENDPOINT,
AWS_REGION,
AWS_BUCKET,
NEO4J_URI = 'bolt://localhost:7687',
NEO4J_USERNAME = 'neo4j',
NEO4J_PASSWORD = 'neo4j',
CLIENT_URI = 'http://localhost:3000',
GRAPHQL_URI = 'http://localhost:4000',
REDIS_DOMAIN,
REDIS_PORT,
REDIS_PASSWORD,
EMAIL_DEFAULT_SENDER,
} = env
export const requiredConfigs = {
MAPBOX_TOKEN,
JWT_SECRET,
PRIVATE_KEY_PASSPHRASE,
const environment = {
NODE_ENV: env.NODE_ENV || process.NODE_ENV,
DEBUG: env.NODE_ENV !== 'production' && env.DEBUG,
TEST: env.NODE_ENV === 'test',
PRODUCTION: env.NODE_ENV === 'production',
DISABLED_MIDDLEWARES: (env.NODE_ENV !== 'production' && env.DISABLED_MIDDLEWARES) || false,
}
const required = {
MAPBOX_TOKEN: env.MAPBOX_TOKEN,
JWT_SECRET: env.JWT_SECRET,
PRIVATE_KEY_PASSPHRASE: env.PRIVATE_KEY_PASSPHRASE,
}
const server = {
CLIENT_URI: env.CLIENT_URI || 'http://localhost:3000',
GRAPHQL_URI: env.GRAPHQL_URI || 'http://localhost:4000',
}
const smtp = {
SMTP_HOST: env.SMTP_HOST,
SMTP_PORT: env.SMTP_PORT,
SMTP_IGNORE_TLS: env.SMTP_IGNORE_TLS || true,
SMTP_USERNAME: env.SMTP_USERNAME,
SMTP_PASSWORD: env.SMTP_PASSWORD,
}
const neo4j = {
NEO4J_URI: env.NEO4J_URI || 'bolt://localhost:7687',
NEO4J_USERNAME: env.NEO4J_USERNAME || 'neo4j',
NEO4J_PASSWORD: env.NEO4J_PASSWORD || 'neo4j',
}
const sentry = {
SENTRY_DSN_BACKEND: env.SENTRY_DSN_BACKEND,
COMMIT: env.COMMIT,
}
const redis = {
REDIS_DOMAIN: env.REDIS_DOMAIN,
REDIS_PORT: env.REDIS_PORT,
REDIS_PASSWORD: env.REDIS_PASSWORD,
}
const s3 = {
AWS_ACCESS_KEY_ID: env.AWS_ACCESS_KEY_ID,
AWS_SECRET_ACCESS_KEY: env.AWS_SECRET_ACCESS_KEY,
AWS_ENDPOINT: env.AWS_ENDPOINT,
AWS_REGION: env.AWS_REGION,
AWS_BUCKET: env.AWS_BUCKET,
S3_CONFIGURED:
env.AWS_ACCESS_KEY_ID &&
env.AWS_SECRET_ACCESS_KEY &&
env.AWS_ENDPOINT &&
env.AWS_REGION &&
env.AWS_BUCKET,
}
const options = {
EMAIL_DEFAULT_SENDER: env.EMAIL_DEFAULT_SENDER,
SUPPORT_URL: links.SUPPORT,
APPLICATION_NAME: metadata.APPLICATION_NAME,
ORGANIZATION_URL: links.ORGANIZATION,
PUBLIC_REGISTRATION: env.PUBLIC_REGISTRATION === 'true',
}
// Check if all required configs are present
if (require.resolve) {
// are we in a nodejs environment?
Object.entries(requiredConfigs).map((entry) => {
Object.entries(required).map((entry) => {
if (!entry[1]) {
throw new Error(`ERROR: "${entry[0]}" env variable is missing.`)
}
})
}
export const smtpConfigs = {
SMTP_HOST,
SMTP_PORT,
SMTP_IGNORE_TLS,
SMTP_USERNAME,
SMTP_PASSWORD,
}
export const neo4jConfigs = { NEO4J_URI, NEO4J_USERNAME, NEO4J_PASSWORD }
export const serverConfigs = {
CLIENT_URI,
GRAPHQL_URI,
PUBLIC_REGISTRATION: process.env.PUBLIC_REGISTRATION === 'true',
}
export const developmentConfigs = {
DEBUG: process.env.NODE_ENV !== 'production' && process.env.DEBUG,
DISABLED_MIDDLEWARES:
(process.env.NODE_ENV !== 'production' && process.env.DISABLED_MIDDLEWARES) || '',
}
export const sentryConfigs = { SENTRY_DSN_BACKEND, COMMIT }
export const redisConfigs = { REDIS_DOMAIN, REDIS_PORT, REDIS_PASSWORD }
const S3_CONFIGURED =
AWS_ACCESS_KEY_ID && AWS_SECRET_ACCESS_KEY && AWS_ENDPOINT && AWS_REGION && AWS_BUCKET
export const s3Configs = {
AWS_ACCESS_KEY_ID,
AWS_SECRET_ACCESS_KEY,
AWS_ENDPOINT,
AWS_REGION,
AWS_BUCKET,
S3_CONFIGURED,
}
export const customConfigs = {
EMAIL_DEFAULT_SENDER,
SUPPORT_URL: links.SUPPORT,
APPLICATION_NAME: metadata.APPLICATION_NAME,
ORGANIZATION_URL: links.ORGANIZATION,
}
export default {
...requiredConfigs,
...smtpConfigs,
...neo4jConfigs,
...serverConfigs,
...developmentConfigs,
...sentryConfigs,
...redisConfigs,
...s3Configs,
...customConfigs,
...environment,
...server,
...required,
...smtp,
...neo4j,
...sentry,
...redis,
...s3,
...options,
}

View File

@ -1,6 +1,7 @@
import { cleanDatabase } from '../db/factories'
import CONFIG from '../config'
if (process.env.NODE_ENV === 'production') {
if (CONFIG.PRODUCTION) {
throw new Error(`You cannot clean the database in production environment!`)
}

View File

@ -941,6 +941,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
const additionalUsers = await Promise.all(
[...Array(30).keys()].map(() => Factory.build('user')),
)
await Promise.all(
additionalUsers.map(async (user) => {
await jennyRostock.relateTo(user, 'following')
@ -948,6 +949,26 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
}),
)
await Promise.all(
[...Array(30).keys()].map((index) => Factory.build('user', { name: `Jenny${index}` })),
)
await Promise.all(
[...Array(30).keys()].map(() =>
Factory.build(
'post',
{ content: `Jenny ${faker.lorem.sentence()}` },
{
categoryIds: ['cat1'],
author: jennyRostock,
image: Factory.build('image', {
url: faker.image.unsplash.objects(),
}),
},
),
),
)
await Promise.all(
[...Array(30).keys()].map(() =>
Factory.build(

View File

@ -13,7 +13,7 @@ const hasAuthData = CONFIG.SMTP_USERNAME && CONFIG.SMTP_PASSWORD
let sendMail = () => {}
if (!hasEmailConfig) {
if (process.env.NODE_ENV !== 'test') {
if (!CONFIG.TEST) {
// eslint-disable-next-line no-console
console.log('Warning: Email middleware will not try to send mails.')
}

View File

@ -29,15 +29,25 @@ const onlyYourself = rule({
const isMyOwn = rule({
cache: 'no_cache',
})(async (parent, args, context, info) => {
return context.user.id === parent.id
})(async (parent, args, { user }, info) => {
return user && user.id === parent.id
})
const isMySocialMedia = rule({
cache: 'no_cache',
})(async (_, args, { user }) => {
// We need a User
if (!user) {
return false
}
let socialMedia = await neode.find('SocialMedia', args.id)
socialMedia = await socialMedia.toJson()
// Did we find a social media node?
if (!socialMedia) {
return false
}
socialMedia = await socialMedia.toJson() // whats this for?
// Is it my social media entry?
return socialMedia.ownedBy.node.id === user.id
})
@ -86,7 +96,10 @@ export default shield(
'*': deny,
findPosts: allow,
findUsers: allow,
findResources: allow,
searchResults: allow,
searchPosts: allow,
searchUsers: allow,
searchHashtags: allow,
embed: allow,
Category: allow,
Tag: allow,

View File

@ -1,16 +1,16 @@
import { sentry } from 'graphql-middleware-sentry'
import { sentryConfigs } from '../config'
import CONFIG from '../config'
let sentryMiddleware = (resolve, root, args, context, resolveInfo) =>
resolve(root, args, context, resolveInfo)
if (sentryConfigs.SENTRY_DSN_BACKEND) {
if (CONFIG.SENTRY_DSN_BACKEND) {
sentryMiddleware = sentry({
forwardErrors: true,
config: {
dsn: sentryConfigs.SENTRY_DSN_BACKEND,
release: sentryConfigs.COMMIT,
environment: process.env.NODE_ENV,
dsn: CONFIG.SENTRY_DSN_BACKEND,
release: CONFIG.COMMIT,
environment: CONFIG.NODE_ENV,
},
withScope: (scope, error, context) => {
scope.setUser({
@ -23,7 +23,7 @@ if (sentryConfigs.SENTRY_DSN_BACKEND) {
})
} else {
// eslint-disable-next-line no-console
if (process.env.NODE_ENV !== 'test') console.log('Warning: Sentry middleware inactive.')
if (!CONFIG.TEST) console.log('Warning: Sentry middleware inactive.')
}
export default sentryMiddleware

View File

@ -5,10 +5,10 @@ import slug from 'slug'
import { existsSync, unlinkSync, createWriteStream } from 'fs'
import { UserInputError } from 'apollo-server'
import { getDriver } from '../../../db/neo4j'
import { s3Configs } from '../../../config'
import CONFIG from '../../../config'
// const widths = [34, 160, 320, 640, 1024]
const { AWS_ENDPOINT: endpoint, AWS_REGION: region, AWS_BUCKET: Bucket, S3_CONFIGURED } = s3Configs
const { AWS_ENDPOINT: endpoint, AWS_REGION: region, AWS_BUCKET: Bucket, S3_CONFIGURED } = CONFIG
export async function deleteImage(resource, relationshipType, opts = {}) {
sanitizeRelationshipType(relationshipType)

View File

@ -3,90 +3,200 @@ import { queryString } from './searches/queryString'
// see http://lucene.apache.org/core/8_3_1/queryparser/org/apache/lucene/queryparser/classic/package-summary.html#package.description
const cypherTemplate = (setup) => `
CALL db.index.fulltext.queryNodes('${setup.fulltextIndex}', $query)
YIELD node AS resource, score
${setup.match}
${setup.whereClause}
${setup.withClause}
RETURN
${setup.returnClause}
AS result
SKIP $skip
${setup.limit}
`
const simpleWhereClause =
'WHERE score >= 0.0 AND NOT (resource.deleted = true OR resource.disabled = true)'
const postWhereClause = `WHERE score >= 0.0
AND NOT (
author.deleted = true OR author.disabled = true
OR resource.deleted = true OR resource.disabled = true
OR (:User {id: $userId})-[:MUTED]->(author)
)`
const searchPostsSetup = {
fulltextIndex: 'post_fulltext_search',
match: 'MATCH (resource:Post)<-[:WROTE]-(author:User)',
whereClause: postWhereClause,
withClause: `WITH resource, author,
[(resource)<-[:COMMENTS]-(comment:Comment) | comment] AS comments,
[(resource)<-[:SHOUTED]-(user:User) | user] AS shouter`,
returnClause: `resource {
.*,
__typename: labels(resource)[0],
author: properties(author),
commentsCount: toString(size(comments)),
shoutedCount: toString(size(shouter))
}`,
limit: 'LIMIT $limit',
}
const searchUsersSetup = {
fulltextIndex: 'user_fulltext_search',
match: 'MATCH (resource:User)',
whereClause: simpleWhereClause,
withClause: '',
returnClause: 'resource {.*, __typename: labels(resource)[0]}',
limit: 'LIMIT $limit',
}
const searchHashtagsSetup = {
fulltextIndex: 'tag_fulltext_search',
match: 'MATCH (resource:Tag)',
whereClause: simpleWhereClause,
withClause: '',
returnClause: 'resource {.*, __typename: labels(resource)[0]}',
limit: 'LIMIT $limit',
}
const countSetup = {
returnClause: 'toString(size(collect(resource)))',
limit: '',
}
const countUsersSetup = {
...searchUsersSetup,
...countSetup,
}
const countPostsSetup = {
...searchPostsSetup,
...countSetup,
}
const countHashtagsSetup = {
...searchHashtagsSetup,
...countSetup,
}
const searchResultPromise = async (session, setup, params) => {
return session.readTransaction(async (transaction) => {
return transaction.run(cypherTemplate(setup), params)
})
}
const searchResultCallback = (result) => {
return result.records.map((r) => r.get('result'))
}
const countResultCallback = (result) => {
return result.records[0].get('result')
}
const getSearchResults = async (context, setup, params, resultCallback = searchResultCallback) => {
const session = context.driver.session()
try {
const results = await searchResultPromise(session, setup, params)
log(results)
return resultCallback(results)
} finally {
session.close()
}
}
const multiSearchMap = [
{ symbol: '!', setup: searchPostsSetup, resultName: 'posts' },
{ symbol: '@', setup: searchUsersSetup, resultName: 'users' },
{ symbol: '#', setup: searchHashtagsSetup, resultName: 'hashtags' },
]
export default {
Query: {
findResources: async (_parent, args, context, _resolveInfo) => {
searchPosts: async (_parent, args, context, _resolveInfo) => {
const { query, postsOffset, firstPosts } = args
const { id: userId } = context.user
return {
postCount: getSearchResults(
context,
countPostsSetup,
{
query: queryString(query),
skip: 0,
userId,
},
countResultCallback,
),
posts: getSearchResults(context, searchPostsSetup, {
query: queryString(query),
skip: postsOffset,
limit: firstPosts,
userId,
}),
}
},
searchUsers: async (_parent, args, context, _resolveInfo) => {
const { query, usersOffset, firstUsers } = args
return {
userCount: getSearchResults(
context,
countUsersSetup,
{
query: queryString(query),
skip: 0,
},
countResultCallback,
),
users: getSearchResults(context, searchUsersSetup, {
query: queryString(query),
skip: usersOffset,
limit: firstUsers,
}),
}
},
searchHashtags: async (_parent, args, context, _resolveInfo) => {
const { query, hashtagsOffset, firstHashtags } = args
return {
hashtagCount: getSearchResults(
context,
countHashtagsSetup,
{
query: queryString(query),
skip: 0,
},
countResultCallback,
),
hashtags: getSearchResults(context, searchHashtagsSetup, {
query: queryString(query),
skip: hashtagsOffset,
limit: firstHashtags,
}),
}
},
searchResults: async (_parent, args, context, _resolveInfo) => {
const { query, limit } = args
const { id: thisUserId } = context.user
const { id: userId } = context.user
const postCypher = `
CALL db.index.fulltext.queryNodes('post_fulltext_search', $query)
YIELD node as resource, score
MATCH (resource)<-[:WROTE]-(author:User)
WHERE score >= 0.0
AND NOT (
author.deleted = true OR author.disabled = true
OR resource.deleted = true OR resource.disabled = true
OR (:User {id: $thisUserId})-[:MUTED]->(author)
)
WITH resource, author,
[(resource)<-[:COMMENTS]-(comment:Comment) | comment] as comments,
[(resource)<-[:SHOUTED]-(user:User) | user] as shouter
RETURN resource {
.*,
__typename: labels(resource)[0],
author: properties(author),
commentsCount: toString(size(comments)),
shoutedCount: toString(size(shouter))
const searchType = query.replace(/^([!@#]?).*$/, '$1')
const searchString = query.replace(/^([!@#])/, '')
const params = {
query: queryString(searchString),
skip: 0,
limit,
userId,
}
LIMIT $limit
`
const userCypher = `
CALL db.index.fulltext.queryNodes('user_fulltext_search', $query)
YIELD node as resource, score
MATCH (resource)
WHERE score >= 0.0
AND NOT (resource.deleted = true OR resource.disabled = true)
RETURN resource {.*, __typename: labels(resource)[0]}
LIMIT $limit
`
const tagCypher = `
CALL db.index.fulltext.queryNodes('tag_fulltext_search', $query)
YIELD node as resource, score
MATCH (resource)
WHERE score >= 0.0
AND NOT (resource.deleted = true OR resource.disabled = true)
RETURN resource {.*, __typename: labels(resource)[0]}
LIMIT $limit
`
if (searchType === '')
return [
...(await getSearchResults(context, searchPostsSetup, params)),
...(await getSearchResults(context, searchUsersSetup, params)),
...(await getSearchResults(context, searchHashtagsSetup, params)),
]
const myQuery = queryString(query)
const session = context.driver.session()
const searchResultPromise = session.readTransaction(async (transaction) => {
const postTransactionResponse = transaction.run(postCypher, {
query: myQuery,
limit,
thisUserId,
})
const userTransactionResponse = transaction.run(userCypher, {
query: myQuery,
limit,
thisUserId,
})
const tagTransactionResponse = transaction.run(tagCypher, {
query: myQuery,
limit,
})
return Promise.all([
postTransactionResponse,
userTransactionResponse,
tagTransactionResponse,
])
})
try {
const [postResults, userResults, tagResults] = await searchResultPromise
log(postResults)
log(userResults)
log(tagResults)
return [...postResults.records, ...userResults.records, ...tagResults.records].map((r) =>
r.get('resource'),
)
} finally {
session.close()
}
params.limit = 15
const type = multiSearchMap.find((obj) => obj.symbol === searchType)
return getSearchResults(context, type.setup, params)
},
},
}

View File

@ -29,7 +29,7 @@ afterAll(async () => {
const searchQuery = gql`
query($query: String!) {
findResources(query: $query, limit: 5) {
searchResults(query: $query, limit: 5) {
__typename
... on Post {
id
@ -47,6 +47,21 @@ const searchQuery = gql`
}
}
`
const searchPostQuery = gql`
query($query: String!, $firstPosts: Int, $postsOffset: Int) {
searchPosts(query: $query, firstPosts: $firstPosts, postsOffset: $postsOffset) {
postCount
posts {
__typename
id
title
content
}
}
}
`
describe('resolvers/searches', () => {
let variables
@ -65,7 +80,7 @@ describe('resolvers/searches', () => {
variables = { query: 'John' }
await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({
data: {
findResources: [
searchResults: [
{
id: 'a-user',
name: 'John Doe',
@ -95,7 +110,7 @@ describe('resolvers/searches', () => {
variables = { query: 'beitrag' }
await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({
data: {
findResources: [
searchResults: [
{
__typename: 'Post',
id: 'a-post',
@ -114,7 +129,7 @@ describe('resolvers/searches', () => {
variables = { query: 'BEITRAG' }
await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({
data: {
findResources: [
searchResults: [
{
__typename: 'Post',
id: 'a-post',
@ -132,7 +147,7 @@ describe('resolvers/searches', () => {
it('returns empty search results', async () => {
await expect(
query({ query: searchQuery, variables: { query: 'Unfug' } }),
).resolves.toMatchObject({ data: { findResources: [] } })
).resolves.toMatchObject({ data: { searchResults: [] } })
})
})
@ -189,7 +204,7 @@ und hinter tausend Stäben keine Welt.`,
variables = { query: 'beitrag' }
await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({
data: {
findResources: expect.arrayContaining([
searchResults: expect.arrayContaining([
{
__typename: 'Post',
id: 'a-post',
@ -216,7 +231,7 @@ und hinter tausend Stäben keine Welt.`,
variables = { query: 'tee-ei' }
await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({
data: {
findResources: [
searchResults: [
{
__typename: 'Post',
id: 'g-post',
@ -235,7 +250,7 @@ und hinter tausend Stäben keine Welt.`,
variables = { query: '„teeei“' }
await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({
data: {
findResources: [
searchResults: [
{
__typename: 'Post',
id: 'g-post',
@ -256,7 +271,7 @@ und hinter tausend Stäben keine Welt.`,
variables = { query: '(a - b)²' }
await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({
data: {
findResources: [
searchResults: [
{
__typename: 'Post',
id: 'c-post',
@ -277,7 +292,7 @@ und hinter tausend Stäben keine Welt.`,
variables = { query: '(a-b)²' }
await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({
data: {
findResources: [
searchResults: [
{
__typename: 'Post',
id: 'c-post',
@ -298,7 +313,7 @@ und hinter tausend Stäben keine Welt.`,
variables = { query: '+ b² 2.' }
await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({
data: {
findResources: [
searchResults: [
{
__typename: 'Post',
id: 'c-post',
@ -321,7 +336,7 @@ und hinter tausend Stäben keine Welt.`,
variables = { query: 'der panther' }
await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({
data: {
findResources: [
searchResults: [
{
__typename: 'Post',
id: 'd-post',
@ -349,7 +364,7 @@ und hinter tausend Stäben keine Welt.`,
variables = { query: 'Vorü Subs' }
await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({
data: {
findResources: expect.arrayContaining([
searchResults: expect.arrayContaining([
{
__typename: 'Post',
id: 'd-post',
@ -395,7 +410,7 @@ und hinter tausend Stäben keine Welt.`,
variables = { query: '-maria-' }
await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({
data: {
findResources: expect.arrayContaining([
searchResults: expect.arrayContaining([
{
__typename: 'User',
id: 'c-user',
@ -416,6 +431,128 @@ und hinter tausend Stäben keine Welt.`,
})
})
describe('adding a user and a hashtag with a name that is content of a post', () => {
beforeAll(async () => {
await Promise.all([
Factory.build('user', {
id: 'f-user',
name: 'Peter Panther',
slug: 'peter-panther',
}),
await Factory.build('tag', { id: 'Panther' }),
])
})
describe('query the word that contains the post, the hashtag and the name of the user', () => {
it('finds the user, the post and the hashtag', async () => {
variables = { query: 'panther' }
await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({
data: {
searchResults: expect.arrayContaining([
{
__typename: 'User',
id: 'f-user',
name: 'Peter Panther',
slug: 'peter-panther',
},
{
__typename: 'Post',
id: 'd-post',
title: 'Der Panther',
content: `Sein Blick ist vom Vorübergehn der Stäbe<br>
so müd geworden, daß er nichts mehr hält.<br>
Ihm ist, als ob es tausend Stäbe gäbe<br>
und hinter tausend Stäben keine Welt.`,
},
{
__typename: 'Tag',
id: 'Panther',
},
]),
},
errors: undefined,
})
})
})
describe('@query the word that contains the post, the hashtag and the name of the user', () => {
it('only finds the user', async () => {
variables = { query: '@panther' }
await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({
data: {
searchResults: expect.not.arrayContaining([
{
__typename: 'Post',
id: 'd-post',
title: 'Der Panther',
content: `Sein Blick ist vom Vorübergehn der Stäbe<br>
so müd geworden, daß er nichts mehr hält.<br>
Ihm ist, als ob es tausend Stäbe gäbe<br>
und hinter tausend Stäben keine Welt.`,
},
{
__typename: 'Tag',
id: 'Panther',
},
]),
},
errors: undefined,
})
})
})
describe('!query the word that contains the post, the hashtag and the name of the user', () => {
it('only finds the post', async () => {
variables = { query: '!panther' }
await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({
data: {
searchResults: expect.not.arrayContaining([
{
__typename: 'User',
id: 'f-user',
name: 'Peter Panther',
slug: 'peter-panther',
},
{
__typename: 'Tag',
id: 'Panther',
},
]),
},
errors: undefined,
})
})
})
describe('#query the word that contains the post, the hashtag and the name of the user', () => {
it('only finds the hashtag', async () => {
variables = { query: '#panther' }
await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({
data: {
searchResults: expect.not.arrayContaining([
{
__typename: 'User',
id: 'f-user',
name: 'Peter Panther',
slug: 'peter-panther',
},
{
__typename: 'Post',
id: 'd-post',
title: 'Der Panther',
content: `Sein Blick ist vom Vorübergehn der Stäbe<br>
so müd geworden, daß er nichts mehr hält.<br>
Ihm ist, als ob es tausend Stäbe gäbe<br>
und hinter tausend Stäben keine Welt.`,
},
]),
},
errors: undefined,
})
})
})
})
describe('adding a post, written by a user who is muted by the authenticated user', () => {
beforeAll(async () => {
const mutedUser = await Factory.build('user', {
@ -440,7 +577,7 @@ und hinter tausend Stäben keine Welt.`,
variables = { query: 'beitrag' }
await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({
data: {
findResources: expect.not.arrayContaining([
searchResults: expect.not.arrayContaining([
{
__typename: 'Post',
id: 'muted-post',
@ -465,7 +602,7 @@ und hinter tausend Stäben keine Welt.`,
variables = { query: 'myha' }
await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({
data: {
findResources: [
searchResults: [
{
__typename: 'Tag',
id: 'myHashtag',
@ -477,6 +614,30 @@ und hinter tausend Stäben keine Welt.`,
})
})
})
describe('searchPostQuery', () => {
describe('query with limit 1', () => {
it('has a count greater than 1', async () => {
variables = { query: 'beitrag', firstPosts: 1, postsOffset: 0 }
await expect(query({ query: searchPostQuery, variables })).resolves.toMatchObject({
data: {
searchPosts: {
postCount: 2,
posts: [
{
__typename: 'Post',
id: 'a-post',
title: 'Beitrag',
content: 'Ein erster Beitrag',
},
],
},
},
errors: undefined,
})
})
})
})
})
})
})

View File

@ -39,7 +39,11 @@ const matchBeginningOfWords = (str) => {
}
export function normalizeWhitespace(str) {
return str.replace(/\s+/g, ' ').trim()
// delete the first character if it is !, @ or #
return str
.replace(/^([!@#])/, '')
.replace(/\s+/g, ' ')
.trim()
}
export function escapeSpecialCharacters(str) {

View File

@ -21,7 +21,7 @@ const statisticsQuery = gql`
}
}
`
beforeAll(() => {
beforeAll(async () => {
authenticatedUser = undefined
const { server } = createServer({
context: () => {
@ -33,6 +33,7 @@ beforeAll(() => {
},
})
query = createTestClient(server).query
await cleanDatabase()
})
afterEach(async () => {

View File

@ -265,9 +265,9 @@ describe('UpdateUser', () => {
})
it('supports updating location', async () => {
variables = { ...variables, locationName: 'Hamburg, New Jersey, United States of America' }
variables = { ...variables, locationName: 'Hamburg, New Jersey, United States' }
await expect(mutate({ mutation: updateUserMutation, variables })).resolves.toMatchObject({
data: { UpdateUser: { locationName: 'Hamburg, New Jersey, United States of America' } },
data: { UpdateUser: { locationName: 'Hamburg, New Jersey, United States' } },
errors: undefined,
})
})

View File

@ -1,5 +1,23 @@
union SearchResult = Post | User | Tag
type Query {
findResources(query: String!, limit: Int = 5): [SearchResult]!
type postSearchResults {
postCount: Int
posts: [Post]!
}
type userSearchResults {
userCount: Int
users: [User]!
}
type hashtagSearchResults {
hashtagCount: Int
hashtags: [Tag]!
}
type Query {
searchPosts(query: String!, firstPosts: Int, postsOffset: Int): postSearchResults!
searchUsers(query: String!, firstUsers: Int, usersOffset: Int): userSearchResults!
searchHashtags(query: String!, firstHashtags: Int, hashtagsOffset: Int): hashtagSearchResults!
searchResults(query: String!, limit: Int = 5): [SearchResult]!
}

View File

@ -37,7 +37,7 @@ Then("I should see the following posts in the select dropdown:", table => {
});
Then("I should see the following users in the select dropdown:", table => {
cy.get(".ds-heading").should("contain", "Users");
cy.get(".search-heading").should("contain", "Users");
table.hashes().forEach(({ slug }) => {
cy.get(".ds-select-dropdown").should("contain", slug);
});
@ -85,6 +85,26 @@ Then(
}
);
Then("I should see the search results page", () => {
cy.location("pathname").should(
"eq",
"/search/search-results"
);
cy.location("search").should(
"eq",
"?search=PR"
);
});
Then("I should see the following posts on the search results page",
() => {
cy.get(".post-teaser").should(
"contain",
"101 Essays that will change the way you think"
);
}
);
Then(
"I should not see posts without the searched-for term in the select dropdown",
() => {

View File

@ -8,7 +8,7 @@ Feature: Search
And we have the following posts in our database:
| id | title | content |
| p1 | 101 Essays that will change the way you think | 101 Essays, of course (PR)! |
| p2 | No searched for content | will be found in this post, I guarantee |
| p2 | No content | will be found in this post, I guarantee |
And we have the following user accounts:
| slug | name | id |
| search-for-me | Search for me | user-for-search |
@ -23,10 +23,10 @@ Feature: Search
| title |
| 101 Essays that will change the way you think |
Scenario: Press enter starts search
Scenario: Press enter opens search page
When I type "PR" and press Enter
Then I should have one item in the select dropdown
Then I should see the following posts in the select dropdown:
Then I should see the search results page
Then I should see the following posts on the search results page
| title |
| 101 Essays that will change the way you think |

View File

@ -7,13 +7,13 @@ dbInitializion: "yarn prod:migrate init"
# dbMigrations runs the database migrations in a post-upgrade hook.
dbMigrations: "yarn prod:migrate up"
# bakendImage is the docker image for the backend deployment
backendImage: ocelotsocialnetwork/develop-backend
backendImage: ocelotsocialnetwork/backend
# maintenanceImage is the docker image for the maintenance deployment
maintenanceImage: ocelotsocialnetwork/develop-maintenance
maintenanceImage: ocelotsocialnetwork/maintenance
# neo4jImage is the docker image for the neo4j deployment
neo4jImage: ocelotsocialnetwork/develop-neo4j
neo4jImage: ocelotsocialnetwork/neo4j
# webappImage is the docker image for the webapp deployment
webappImage: ocelotsocialnetwork/develop-webapp
webappImage: ocelotsocialnetwork/webapp
# image configures pullPolicy related to the docker images
image:
# pullPolicy indicates when, if ever, pods pull a new image from docker hub.

View File

@ -43,13 +43,13 @@ Then temporarily delete backend and database deployments
```bash
$ kubectl -n ocelot-social get deployments
NAME READY UP-TO-DATE AVAILABLE AGE
develop-backend 1/1 1 1 3d11h
develop-neo4j 1/1 1 1 3d11h
develop-webapp 2/2 2 2 73d
$ kubectl -n ocelot-social delete deployment develop-neo4j
deployment.extensions "develop-neo4j" deleted
$ kubectl -n ocelot-social delete deployment develop-backend
deployment.extensions "develop-backend" deleted
backend 1/1 1 1 3d11h
neo4j 1/1 1 1 3d11h
webapp 2/2 2 2 73d
$ kubectl -n ocelot-social delete deployment neo4j
deployment.extensions "neo4j" deleted
$ kubectl -n ocelot-social delete deployment backend
deployment.extensions "backend" deleted
```
Deploy one-time develop-maintenance-worker pod:

View File

@ -18,8 +18,8 @@ minikube dashboard, expose the services you want on your host system.
For example:
```text
$ minikube service develop-webapp --namespace=ocelotsocialnetwork
$ minikube service webapp --namespace=ocelotsocialnetwork
# optionally
$ minikube service develop-backend --namespace=ocelotsocialnetwork
$ minikube service backend --namespace=ocelotsocialnetwork
```

View File

@ -37,7 +37,7 @@ spec:
name: configmap
- secretRef:
name: ocelot-social
image: ocelotsocialnetwork/develop-webapp:latest
image: ocelotsocialnetwork/webapp:latest
imagePullPolicy: Always
name: web
ports:

View File

@ -1,17 +0,0 @@
version: "3.4"
services:
webapp:
environment:
- "CI=${CI}"
image: ocelotsocialnetwork/develop-webapp:build-and-test
build:
context: webapp
target: build-and-test
backend:
environment:
- "CI=${CI}"
image: ocelotsocialnetwork/develop-backend:build-and-test
build:
context: backend
target: build-and-test

View File

@ -1,57 +1,68 @@
version: "3.4"
services:
########################################################
# WEBAPP ###############################################
########################################################
webapp:
image: ocelotsocialnetwork/develop-webapp:build-and-test
image: ocelotsocialnetwork/webapp:development
build:
context: webapp
target: build-and-test
target: development
environment:
- NUXT_BUILD=/tmp/nuxt # avoid file permission issues when `rm -rf .nuxt/`
- PUBLIC_REGISTRATION=true
command: yarn run dev
- NODE_ENV="development"
# - DEBUG=true
# - NUXT_BUILD=/tmp/nuxt # avoid file permission issues when `rm -rf .nuxt/`
volumes:
- ./webapp:/develop-webapp
- webapp_node_modules:/develop-webapp/node_modules
# This makes sure the docker container has its own node modules.
# Therefore it is possible to have a different node version on the host machine
- webapp_node_modules:/app/node_modules
# bind the local folder to the docker to allow live reload
- ./webapp:/app
########################################################
# BACKEND ##############################################
########################################################
backend:
image: ocelotsocialnetwork/develop-backend:build-and-test
image: ocelotsocialnetwork/backend:development
build:
context: backend
target: build-and-test
command: yarn run dev
target: development
environment:
- SMTP_HOST=mailserver
- SMTP_PORT=25
- SMTP_IGNORE_TLS=true
- "DEBUG=${DEBUG}"
- PUBLIC_REGISTRATION=false
- NODE_ENV="development"
- DEBUG=true
volumes:
- ./backend:/develop-backend
- backend_node_modules:/develop-backend/node_modules
- uploads:/develop-backend/public/uploads
# This makes sure the docker container has its own node modules.
# Therefore it is possible to have a different node version on the host machine
- backend_node_modules:/app/node_modules
# bind the local folder to the docker to allow live reload
- ./backend:/app
########################################################
# NEO4J ################################################
########################################################
neo4j:
volumes:
- neo4j_data:/data
maintenance:
image: ocelotsocialnetwork/develop-maintenance:latest
build:
context: webapp
dockerfile: Dockerfile.maintenance
networks:
- hc-network
image: ocelotsocialnetwork/neo4j:development
ports:
- 3503:80
# Also expose the neo4j query browser
- 7474:7474
networks:
# So we can access the neo4j query browser from our host machine
- external-net
########################################################
# MAINTENANCE ##########################################
########################################################
maintenance:
image: ocelotsocialnetwork/maintenance:development
########################################################
# MAILSERVER TO FAKE SMTP ##############################
########################################################
mailserver:
image: djfarrelly/maildev
ports:
- 1080:80
networks:
- hc-network
networks:
hc-network:
- external-net
volumes:
webapp_node_modules:
backend_node_modules:
neo4j_data:
uploads:

View File

@ -1,20 +0,0 @@
version: "3.4"
services:
webapp:
build:
context: webapp
target: production
args:
- "BUILD_COMMIT=${TRAVIS_COMMIT}"
backend:
build:
context: backend
target: production
args:
- "BUILD_COMMIT=${TRAVIS_COMMIT}"
neo4j:
build:
context: neo4j
args:
- "BUILD_COMMIT=${TRAVIS_COMMIT}"

View File

@ -1,75 +1,117 @@
# This file defines the production settings. It is overwritten by docker-compose.override.yml,
# which defines the development settings. The override.yml is loaded by default. Therefore it
# is required to explicitly define if you want an production build:
# > docker-compose -f docker-compose.yml up
version: "3.4"
services:
########################################################
# WEBAPP ###############################################
########################################################
webapp:
image: ocelotsocialnetwork/develop-webapp:latest
image: ocelotsocialnetwork/webapp:latest
build:
context: webapp
context: ./webapp
target: production
args:
- "BUILD_COMMIT=${TRAVIS_COMMIT}"
ports:
- 3000:3000
- 3002:3002
networks:
- hc-network
- external-net
depends_on:
- backend
volumes:
- ./webapp:/develop-webapp
- webapp_node_modules:/develop-webapp/node_modules
ports:
- 3000:3000
# Storybook: Todo externalize, its not working anyways
# - 3002:3002
environment:
- HOST=0.0.0.0
# Envs used in Dockerfile
# - DOCKER_WORKDIR="/app"
# - PORT="3000"
- BUILD_DATE
- BUILD_VERSION
- BUILD_COMMIT
- NODE_ENV="development"
# Application only envs
- HOST=0.0.0.0 # This is nuxt specific, alternative value is HOST=webapp
- GRAPHQL_URI=http://backend:4000
- MAPBOX_TOKEN="pk.eyJ1IjoiYnVzZmFrdG9yIiwiYSI6ImNraDNiM3JxcDBhaWQydG1uczhpZWtpOW4ifQ.7TNRTO-o9aK1Y6MyW_Nd4g"
env_file:
- ./webapp/.env
########################################################
# BACKEND ##############################################
########################################################
backend:
image: ocelotsocialnetwork/develop-backend:latest
image: ocelotsocialnetwork/backend:latest
build:
context: backend
context: ./backend
target: production
args:
- "BUILD_COMMIT=${TRAVIS_COMMIT}"
networks:
- hc-network
- external-net
- internal-net
depends_on:
- neo4j
ports:
- 4000:4000
volumes:
- ./backend:/develop-backend
- backend_node_modules:/develop-backend/node_modules
- uploads:/develop-backend/public/uploads
- backend_uploads:/app/public/uploads
environment:
# Envs used in Dockerfile
# - DOCKER_WORKDIR="/app"
# - PORT="4000"
- BUILD_DATE
- BUILD_VERSION
- BUILD_COMMIT
- NODE_ENV="development"
# Application only envs
- DEBUG=false
- NEO4J_URI=bolt://neo4j:7687
- GRAPHQL_URI=http://backend:4000
- CLIENT_URI=http://localhost:3000
- JWT_SECRET=b/&&7b78BF&fv/Vd
- MAPBOX_TOKEN=pk.eyJ1IjoiYnVzZmFrdG9yIiwiYSI6ImNraDNiM3JxcDBhaWQydG1uczhpZWtpOW4ifQ.7TNRTO-o9aK1Y6MyW_Nd4g
- PRIVATE_KEY_PASSPHRASE=a7dsf78sadg87ad87sfagsadg78
- "DEBUG=${DEBUG}"
- EMAIL_DEFAULT_SENDER=devops@ocelot.social
- CLIENT_URI=http://webapp:3000
env_file:
- ./backend/.env
########################################################
# NEO4J ################################################
########################################################
neo4j:
image: ocelotsocialnetwork/develop-neo4j:latest
image: ocelotsocialnetwork/neo4j:latest
build:
context: neo4j
args:
- "BUILD_COMMIT=${TRAVIS_COMMIT}"
context: ./neo4j
# community edition 👆🏼, because we have no enterprise licence 👇🏼 at the moment
target: community
networks:
- hc-network
environment:
- NEO4J_AUTH=none
- NEO4J_dbms_security_procedures_unrestricted=algo.*,apoc.*
# decomment following line for Neo4j Enterprice version instead of Community version
# - NEO4J_ACCEPT_LICENSE_AGREEMENT=yes
- internal-net
ports:
- 7687:7687
- 7474:7474
volumes:
- neo4j_data:/data
environment:
# TODO: This sounds scary for a production environment
- NEO4J_AUTH=none
- NEO4J_dbms_security_procedures_unrestricted=algo.*,apoc.*
# Uncomment following line for Neo4j Enterprise version instead of Community version
# TODO: clarify if that is the only thing needed to unlock the Enterprise version
# - NEO4J_ACCEPT_LICENSE_AGREEMENT=yes
# TODO: Remove the playground from production
########################################################
# MAINTENANCE ##########################################
########################################################
maintenance:
image: ocelotsocialnetwork/maintenance:latest
build:
# TODO: Separate from webapp, this must be independent
context: ./webapp
dockerfile: Dockerfile.maintenance
networks:
- external-net
ports:
- 5000:80
networks:
hc-network:
external-net:
internal-net:
internal: true
volumes:
webapp_node_modules:
backend_node_modules:
backend_uploads:
neo4j_data:
uploads:

121
docu/kubernetes.drawio Normal file
View File

@ -0,0 +1,121 @@
<mxfile host="65bd71144e" modified="2021-01-18T00:39:24.755Z" agent="5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) VSCodium/1.52.1 Chrome/83.0.4103.122 Electron/9.3.5 Safari/537.36" version="13.10.0" etag="SoL2k4xa67XojvyFyRGV" type="embed">
<diagram id="l5FJ560ARYCXft7RafE-" name="Page-1">
<mxGraphModel dx="849" dy="670" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="827" pageHeight="1169" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="2" value="Namespace: default or kube-manager" style="rounded=0;whiteSpace=wrap;html=1;align=left;verticalAlign=top;" vertex="1" parent="1">
<mxGeometry x="50" y="90" width="920" height="100" as="geometry"/>
</mxCell>
<mxCell id="3" value="Namespace: wir.social" style="rounded=0;whiteSpace=wrap;html=1;align=left;verticalAlign=top;" vertex="1" parent="1">
<mxGeometry x="50" y="200" width="600" height="310" as="geometry"/>
</mxCell>
<mxCell id="4" value="Namespace: webcraft" style="rounded=0;whiteSpace=wrap;html=1;align=left;verticalAlign=top;" vertex="1" parent="1">
<mxGeometry x="670" y="200" width="300" height="310" as="geometry"/>
</mxCell>
<mxCell id="5" value="Service: Frontend" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#60a917;strokeColor=#2D7600;fontColor=#ffffff;" vertex="1" parent="1">
<mxGeometry x="60" y="230" width="130" height="60" as="geometry"/>
</mxCell>
<mxCell id="6" value="Service: Backend" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#60a917;strokeColor=#2D7600;fontColor=#ffffff;" vertex="1" parent="1">
<mxGeometry x="210" y="230" width="130" height="60" as="geometry"/>
</mxCell>
<mxCell id="7" value="Service: Neo4J" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#60a917;strokeColor=#2D7600;fontColor=#ffffff;" vertex="1" parent="1">
<mxGeometry x="360" y="230" width="130" height="60" as="geometry"/>
</mxCell>
<mxCell id="8" value="Service: Ingress" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#1ba1e2;strokeColor=#006EAF;fontColor=#ffffff;" vertex="1" parent="1">
<mxGeometry x="60" y="120" width="900" height="60" as="geometry"/>
</mxCell>
<mxCell id="9" value="Service: Maintenance" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#60a917;strokeColor=#2D7600;fontColor=#ffffff;" vertex="1" parent="1">
<mxGeometry x="510" y="230" width="130" height="60" as="geometry"/>
</mxCell>
<mxCell id="10" value="Service: Mailserver" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#60a917;strokeColor=#2D7600;fontColor=#ffffff;" vertex="1" parent="1">
<mxGeometry x="680" y="230" width="130" height="60" as="geometry"/>
</mxCell>
<mxCell id="12" value="Pod" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#76608a;strokeColor=#432D57;fontColor=#ffffff;" vertex="1" parent="1">
<mxGeometry x="60" y="300" width="60" height="60" as="geometry"/>
</mxCell>
<mxCell id="13" value="Pod" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#76608a;strokeColor=#432D57;fontColor=#ffffff;" vertex="1" parent="1">
<mxGeometry x="130" y="300" width="60" height="60" as="geometry"/>
</mxCell>
<mxCell id="14" value="Pod" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#76608a;strokeColor=#432D57;fontColor=#ffffff;" vertex="1" parent="1">
<mxGeometry x="60" y="370" width="60" height="60" as="geometry"/>
</mxCell>
<mxCell id="15" value="Pod" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#76608a;strokeColor=#432D57;fontColor=#ffffff;" vertex="1" parent="1">
<mxGeometry x="130" y="370" width="60" height="60" as="geometry"/>
</mxCell>
<mxCell id="16" value="Pod" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#76608a;strokeColor=#432D57;fontColor=#ffffff;" vertex="1" parent="1">
<mxGeometry x="210" y="300" width="60" height="60" as="geometry"/>
</mxCell>
<mxCell id="17" value="Pod" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#76608a;strokeColor=#432D57;fontColor=#ffffff;" vertex="1" parent="1">
<mxGeometry x="280" y="300" width="60" height="60" as="geometry"/>
</mxCell>
<mxCell id="18" value="Pod" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#76608a;strokeColor=#432D57;fontColor=#ffffff;" vertex="1" parent="1">
<mxGeometry x="210" y="370" width="60" height="60" as="geometry"/>
</mxCell>
<mxCell id="19" value="Pod" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#76608a;strokeColor=#432D57;fontColor=#ffffff;" vertex="1" parent="1">
<mxGeometry x="280" y="370" width="60" height="60" as="geometry"/>
</mxCell>
<mxCell id="20" value="Pod" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#76608a;strokeColor=#432D57;fontColor=#ffffff;" vertex="1" parent="1">
<mxGeometry x="360" y="300" width="60" height="60" as="geometry"/>
</mxCell>
<mxCell id="21" value="Pod" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#76608a;strokeColor=#432D57;fontColor=#ffffff;" vertex="1" parent="1">
<mxGeometry x="430" y="300" width="60" height="60" as="geometry"/>
</mxCell>
<mxCell id="22" value="Pod" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#76608a;strokeColor=#432D57;fontColor=#ffffff;" vertex="1" parent="1">
<mxGeometry x="360" y="370" width="60" height="60" as="geometry"/>
</mxCell>
<mxCell id="23" value="Pod" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#76608a;strokeColor=#432D57;fontColor=#ffffff;" vertex="1" parent="1">
<mxGeometry x="430" y="370" width="60" height="60" as="geometry"/>
</mxCell>
<mxCell id="24" value="Pod" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#76608a;strokeColor=#432D57;fontColor=#ffffff;" vertex="1" parent="1">
<mxGeometry x="510" y="300" width="60" height="60" as="geometry"/>
</mxCell>
<mxCell id="25" value="Pod" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#76608a;strokeColor=#432D57;fontColor=#ffffff;" vertex="1" parent="1">
<mxGeometry x="580" y="300" width="60" height="60" as="geometry"/>
</mxCell>
<mxCell id="26" value="Pod" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#76608a;strokeColor=#432D57;fontColor=#ffffff;" vertex="1" parent="1">
<mxGeometry x="510" y="370" width="60" height="60" as="geometry"/>
</mxCell>
<mxCell id="27" value="Pod" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#76608a;strokeColor=#432D57;fontColor=#ffffff;" vertex="1" parent="1">
<mxGeometry x="580" y="370" width="60" height="60" as="geometry"/>
</mxCell>
<mxCell id="28" value="Pod" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#76608a;strokeColor=#432D57;fontColor=#ffffff;" vertex="1" parent="1">
<mxGeometry x="680" y="300" width="60" height="60" as="geometry"/>
</mxCell>
<mxCell id="29" value="Pod" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#76608a;strokeColor=#432D57;fontColor=#ffffff;" vertex="1" parent="1">
<mxGeometry x="750" y="300" width="60" height="60" as="geometry"/>
</mxCell>
<mxCell id="30" value="Pod" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#76608a;strokeColor=#432D57;fontColor=#ffffff;" vertex="1" parent="1">
<mxGeometry x="680" y="370" width="60" height="60" as="geometry"/>
</mxCell>
<mxCell id="31" value="Pod" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#76608a;strokeColor=#432D57;fontColor=#ffffff;" vertex="1" parent="1">
<mxGeometry x="750" y="370" width="60" height="60" as="geometry"/>
</mxCell>
<mxCell id="36" value="Volume: Uploads" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#a0522d;strokeColor=#6D1F00;fontColor=#ffffff;" vertex="1" parent="1">
<mxGeometry x="210" y="440" width="130" height="60" as="geometry"/>
</mxCell>
<mxCell id="37" value="Volume: Neo4J" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#a0522d;strokeColor=#6D1F00;fontColor=#ffffff;" vertex="1" parent="1">
<mxGeometry x="360" y="440" width="130" height="60" as="geometry"/>
</mxCell>
<mxCell id="38" value="Volume: EMails" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#a0522d;strokeColor=#6D1F00;fontColor=#ffffff;" vertex="1" parent="1">
<mxGeometry x="680" y="440" width="130" height="60" as="geometry"/>
</mxCell>
<mxCell id="39" value="Service: JenkinsCI" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#60a917;strokeColor=#2D7600;fontColor=#ffffff;" vertex="1" parent="1">
<mxGeometry x="827" y="230" width="130" height="60" as="geometry"/>
</mxCell>
<mxCell id="40" value="Pod" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#76608a;strokeColor=#432D57;fontColor=#ffffff;" vertex="1" parent="1">
<mxGeometry x="827" y="300" width="60" height="60" as="geometry"/>
</mxCell>
<mxCell id="41" value="Pod" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#76608a;strokeColor=#432D57;fontColor=#ffffff;" vertex="1" parent="1">
<mxGeometry x="897" y="300" width="60" height="60" as="geometry"/>
</mxCell>
<mxCell id="42" value="Pod" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#76608a;strokeColor=#432D57;fontColor=#ffffff;" vertex="1" parent="1">
<mxGeometry x="827" y="370" width="60" height="60" as="geometry"/>
</mxCell>
<mxCell id="43" value="Pod" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#76608a;strokeColor=#432D57;fontColor=#ffffff;" vertex="1" parent="1">
<mxGeometry x="897" y="370" width="60" height="60" as="geometry"/>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>

BIN
docu/kubernetes.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View File

@ -1,82 +0,0 @@
# Installation
The repository can be found on GitHub. [https://github.com/Ocelot-Social-Community/Ocelot-Social](https://github.com/Ocelot-Social-Community/Ocelot-Social)
We give write permissions to every developer who asks for it. Just text us on
[Discord](https://discord.gg/6ub73U3).
## Clone the Repository
Clone the repository, this will create a new folder called `Human-Connection`:
{% tabs %}
{% tab title="HTTPS" %}
```bash
$ git clone https://github.com/Ocelot-Social-Community/Ocelot-Social.git
```
{% endtab %}
{% tab title="SSH" %}
```bash
$ git clone git@github.com:Human-Connection/Human-Connection.git
```
{% endtab %}
{% endtabs %}
Change into the new folder.
```bash
$ cd Human-Connection
```
## Directory Layout
There are four important directories:
* [Backend](./backend) runs on the server and is a middleware between database and frontend
* [Frontend](./webapp) is a server-side-rendered and client-side-rendered web frontend
* [Deployment](./deployment) configuration for kubernetes
* [Cypress](./cypress) contains end-to-end tests and executable feature specifications
In order to setup the application and start to develop features you have to
setup **frontend** and **backend**.
There are two approaches:
1. Local installation, which means you have to take care of dependencies yourself
2. **Or** Install everything through docker which takes care of dependencies for you
## Docker Installation
Docker is a software development container tool that combines software and its dependencies into one standardized unit that contains everything needed to run it. This helps us to avoid problems with dependencies and makes installation easier.
### General Installation of Docker
There are [sevaral ways to install Docker CE](https://docs.docker.com/install/) on your computer or server.
{% tabs %}
{% tab title="Docker Desktop macOS" %}
Follow these instructions to [install Docker Desktop on macOS](https://docs.docker.com/docker-for-mac/install/).
{% endtab %}
{% tab title="Docker Desktop Windows" %}
Follow these instructions to [install Docker Desktop on Windows](https://docs.docker.com/docker-for-windows/install/).
{% endtab %}
{% tab title="Docker CE" %}
Follow these instructions to [install Docker CE](https://docs.docker.com/install/).
This is a great option for Linux users.
{% endtab %}
{% endtabs %}
Check the correct Docker installation by checking the version before proceeding. E.g. we have the following versions:
```bash
$ docker --version
Docker version 18.09.2
$ docker-compose --version
docker-compose version 1.23.2
```

View File

@ -1,10 +1,43 @@
FROM neo4j:3.5.14
LABEL Description="Neo4J database of the Social Network ocelot.social with preinstalled database constraints and indices" Vendor="ocelot.social Community" Version="0.0.1" Maintainer="ocelot.social Community (devops@ocelot.social)"
# community edition 👆🏼, because we have no enterprise licence 👇🏼 at the moment
# FROM neo4j:3.5.14-enterprise
##################################################################################
# COMMUNITY ######################################################################
##################################################################################
FROM neo4j:3.5.14 as community
ARG BUILD_COMMIT
ENV BUILD_COMMIT=$BUILD_COMMIT
# ENVs (available in production aswell, can be overwritten by commandline or env file)
## We Cannot do `$(date -u +'%Y-%m-%dT%H:%M:%SZ')` here so we use unix timestamp=0
ENV BUILD_DATE="1970-01-01T00:00:00.00Z"
## We cannot do $(yarn run version) here so we default to 0.0.0
## TODO: Missing Build number - do that once we have a CI which actually generates it
ENV BUILD_VERSION="0.0.0"
## We cannot do `$(git rev-parse --short HEAD)` here so we default to 0000000
ENV BUILD_COMMIT="0000000"
# Labels
LABEL org.label-schema.build-date="${BUILD_DATE}"
LABEL org.label-schema.name="ocelot.social:backend"
LABEL org.label-schema.description="Neo4J database of the Social Network Software ocelot.social with preinstalled database constraints and indices"
LABEL org.label-schema.usage="https://github.com/Ocelot-Social-Community/Ocelot-Social/blob/master/README.md"
LABEL org.label-schema.url="https://ocelot.social"
LABEL org.label-schema.vcs-url="https://github.com/Ocelot-Social-Community/Ocelot-Social/tree/master/backend"
LABEL org.label-schema.vcs-ref="${BUILD_COMMIT}"
LABEL org.label-schema.vendor="ocelot.social Community"
LABEL org.label-schema.version="${BUILD_VERSION}"
LABEL org.label-schema.schema-version="1.0"
LABEL maintainer="devops@ocelot.social"
# Install Additional Software
## install: wget, htop (TODO: why do we need htop?)
RUN apt-get update && apt-get -y install wget htop
## install: apoc plugin for neo4j
RUN wget https://github.com/neo4j-contrib/neo4j-apoc-procedures/releases/download/3.5.0.4/apoc-3.5.0.4-all.jar -P plugins/
##################################################################################
# ENTERPRISE #####################################################################
##################################################################################
FROM neo4j:3.5.14-enterprise as enterprise
# Install Additional Software
## install: wget, htop (TODO: why do we need htop?)
RUN apt-get update && apt-get -y install wget htop
## install: apoc plugin for neo4j
RUN wget https://github.com/neo4j-contrib/neo4j-apoc-procedures/releases/download/3.5.0.4/apoc-3.5.0.4-all.jar -P plugins/

View File

@ -2,5 +2,5 @@
sed -i "s/<COMMIT>/${TRAVIS_COMMIT}/g" $TRAVIS_BUILD_DIR/scripts/patches/patch-deployment.yaml
sed -i "s/<COMMIT>/${TRAVIS_COMMIT}/g" $TRAVIS_BUILD_DIR/scripts/patches/patch-configmap.yaml
kubectl -n ocelot-social patch configmap develop-configmap -p "$(cat $TRAVIS_BUILD_DIR/scripts/patches/patch-configmap.yaml)"
kubectl -n ocelot-social patch deployment develop-backend -p "$(cat $TRAVIS_BUILD_DIR/scripts/patches/patch-deployment.yaml)"
kubectl -n ocelot-social patch deployment develop-webapp -p "$(cat $TRAVIS_BUILD_DIR/scripts/patches/patch-deployment.yaml)"
kubectl -n ocelot-social patch deployment backend -p "$(cat $TRAVIS_BUILD_DIR/scripts/patches/patch-deployment.yaml)"
kubectl -n ocelot-social patch deployment webapp -p "$(cat $TRAVIS_BUILD_DIR/scripts/patches/patch-deployment.yaml)"

View File

@ -4,14 +4,14 @@ ROOT_DIR=$(dirname "$0")/..
VERSION=$(jq -r '.version' $ROOT_DIR/package.json)
IFS='.' read -r major minor patch <<< $VERSION
apps=(develop-webapp develop-backend develop-neo4j develop-maintenance)
apps=(webapp backend neo4j maintenance)
tags=($major $major.$minor $major.$minor.$patch)
# These three docker images have already been built by now:
# docker build --build-arg BUILD_COMMIT=$BUILD_COMMIT --target production -t ocelotsocialnetwork/develop-backend:latest $ROOT_DIR/backend
# docker build --build-arg BUILD_COMMIT=$BUILD_COMMIT --target production -t ocelotsocialnetwork/develop-webapp:latest $ROOT_DIR/webapp
# docker build --build-arg BUILD_COMMIT=$BUILD_COMMIT -t ocelotsocialnetwork/develop-neo4j:latest $ROOT_DIR/neo4j
docker build -t ocelotsocialnetwork/develop-maintenance:latest $ROOT_DIR/webapp/ -f $ROOT_DIR/webapp/Dockerfile.maintenance
# docker build --build-arg BUILD_COMMIT=$BUILD_COMMIT --target production -t ocelotsocialnetwork/backend:latest $ROOT_DIR/backend
# docker build --build-arg BUILD_COMMIT=$BUILD_COMMIT --target production -t ocelotsocialnetwork/webapp:latest $ROOT_DIR/webapp
# docker build --build-arg BUILD_COMMIT=$BUILD_COMMIT -t ocelotsocialnetwork/neo4j:latest $ROOT_DIR/neo4j
docker build -t ocelotsocialnetwork/maintenance:latest $ROOT_DIR/webapp/ -f $ROOT_DIR/webapp/Dockerfile.maintenance
echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin

View File

@ -1,32 +1,89 @@
##################################################################################
# BASE ###########################################################################
##################################################################################
FROM node:12.19.0-alpine3.10 as base
LABEL Description="Web Frontend of the Social Network ocelot.social" Vendor="ocelot.social Community" Version="0.0.1" Maintainer="ocelot.social Community (devops@ocelot.social)"
EXPOSE 3000
CMD ["yarn", "run", "start"]
# ENVs (available in production aswell, can be overwritten by commandline or env file)
## DOCKER_WORKDIR would be a classical ARG, but that is not multi layer persistent - shame
ENV DOCKER_WORKDIR="/app"
## We Cannot do `$(date -u +'%Y-%m-%dT%H:%M:%SZ')` here so we use unix timestamp=0
ENV BUILD_DATE="1970-01-01T00:00:00.00Z"
## We cannot do $(yarn run version) here so we default to 0.0.0
## TODO: Missing Build number - do that once we have a CI which actually generates it
ENV BUILD_VERSION="0.0.0"
## We cannot do `$(git rev-parse --short HEAD)` here so we default to 0000000
ENV BUILD_COMMIT="0000000"
## SET NODE_ENV
ENV NODE_ENV="production"
## App relevant Envs
ENV PORT="3000"
# Expose the app port
ARG BUILD_COMMIT
ENV BUILD_COMMIT=$BUILD_COMMIT
ARG WORKDIR=/develop-webapp
RUN mkdir -p $WORKDIR
WORKDIR $WORKDIR
# Labels
LABEL org.label-schema.build-date="${BUILD_DATE}"
LABEL org.label-schema.name="ocelot.social:backend"
LABEL org.label-schema.description="Web Frontend of the Social Network Software ocelot.social"
LABEL org.label-schema.usage="https://github.com/Ocelot-Social-Community/Ocelot-Social/blob/master/README.md"
LABEL org.label-schema.url="https://ocelot.social"
LABEL org.label-schema.vcs-url="https://github.com/Ocelot-Social-Community/Ocelot-Social/tree/master/backend"
LABEL org.label-schema.vcs-ref="${BUILD_COMMIT}"
LABEL org.label-schema.vendor="ocelot.social Community"
LABEL org.label-schema.version="${BUILD_VERSION}"
LABEL org.label-schema.schema-version="1.0"
LABEL maintainer="devops@ocelot.social"
# See: https://github.com/nodejs/docker-node/pull/367#issuecomment-430807898
# Install Additional Software
## install: git
RUN apk --no-cache add git
COPY package.json yarn.lock ./
COPY .env.template .env
# Settings
## Expose Container Port
EXPOSE ${PORT}
## Workdir
RUN mkdir -p ${DOCKER_WORKDIR}
WORKDIR ${DOCKER_WORKDIR}
FROM base as build-and-test
RUN yarn install --production=false --frozen-lockfile --non-interactive
##################################################################################
# DEVELOPMENT (Connected to the local environment, to reload on demand) ##########
##################################################################################
FROM base as development
# We don't need to copy or build anything since we gonna bind to the
# local filesystem which will need a rebuild anyway
# Run command
# (for development we need to execute yarn install since the
# node_modules are on another volume and need updating)
CMD /bin/sh -c "yarn install && yarn run dev"
##################################################################################
# BUILD (Does contain all files and is therefore bloated) ########################
##################################################################################
FROM base as build
# Copy everything
COPY . .
RUN NODE_ENV=production yarn run build
# yarn install
RUN yarn install --production=false --frozen-lockfile --non-interactive
# yarn build
RUN yarn run build
##################################################################################
# PRODUCTION (Does contain only "binary"- and static-files to reduce image size) #
##################################################################################
FROM base as production
RUN yarn install --production=true --frozen-lockfile --non-interactive --no-cache
COPY --from=build-and-test ./develop-webapp/.nuxt ./.nuxt
COPY --from=build-and-test ./develop-webapp/constants ./constants
COPY --from=build-and-test ./develop-webapp/static ./static
COPY nuxt.config.js .
COPY locales locales
# Copy "binary"-files from build image
COPY --from=build ${DOCKER_WORKDIR}/.nuxt ./.nuxt
COPY --from=build ${DOCKER_WORKDIR}/node_modules ./node_modules
COPY --from=build ${DOCKER_WORKDIR}/nuxt.config.js ./nuxt.config.js
# Copy static files
# TODO - this should be one Folder containign all stuff needed to be copied
COPY --from=build ${DOCKER_WORKDIR}/constants ./constants
COPY --from=build ${DOCKER_WORKDIR}/static ./static
COPY --from=build ${DOCKER_WORKDIR}/locales ./locales
# Copy package.json for script definitions (lock file should not be needed)
COPY --from=build ${DOCKER_WORKDIR}/package.json ./package.json
# Run command
CMD /bin/sh -c "yarn run start"

View File

@ -27,6 +27,7 @@ COPY plugins/i18n.js plugins/v-tooltip.js plugins/styleguide.js plugins/
COPY static static
COPY constants constants
COPY nuxt.config.js nuxt.config.js
COPY config/ config/
# this will also ovewrite the existing package.json
COPY maintenance/source ./

View File

@ -16,13 +16,14 @@ h3,
h4,
h5,
h6,
p {
p,
li {
margin: 0;
}
ul,
ol {
ol,
ul {
list-style-type: none;
padding: 0;
margin: 0;
padding: 0;
}

View File

@ -251,7 +251,7 @@ $size-ribbon: 6px;
*/
$size-width-filter-sidebar: 85px;
$size-width-paginate: 100px;
$size-width-paginate: 200px;
$size-max-width-filter-menu: 1026px;
/**

View File

@ -0,0 +1,239 @@
import { config, mount } from '@vue/test-utils'
import Vuex from 'vuex'
import SearchResults from './SearchResults'
import helpers from '~/storybook/helpers'
helpers.init()
const localVue = global.localVue
localVue.directive('scrollTo', jest.fn())
config.stubs['client-only'] = '<span><slot /></span>'
config.stubs['nuxt-link'] = '<span><slot /></span>'
describe('SearchResults', () => {
let mocks, getters, propsData, wrapper
const Wrapper = () => {
const store = new Vuex.Store({
getters,
})
return mount(SearchResults, { mocks, localVue, propsData, store })
}
beforeEach(() => {
mocks = {
$t: jest.fn(),
}
getters = {
'auth/user': () => {
return { id: 'u343', name: 'Matt' }
},
'auth/isModerator': () => false,
}
propsData = {
pageSize: 12,
}
wrapper = Wrapper()
})
describe('mount', () => {
it('renders tab-navigation component', () => {
expect(wrapper.find('.tab-navigation').exists()).toBe(true)
})
describe('searchResults', () => {
describe('contains no results', () => {
it('renders hc-empty component', () => {
expect(wrapper.find('.hc-empty').exists()).toBe(true)
})
})
describe('result contains 25 posts, 8 users and 0 hashtags', () => {
// we couldn't get it running with "jest.runAllTimers()" and so we used "setTimeout"
// time is a bit more then 3000 milisec see "webapp/components/CountTo.vue"
const counterTimeout = 3000 + 10
beforeEach(async () => {
wrapper.setData({
posts: helpers.fakePost(12),
postCount: 25,
users: helpers.fakeUser(8),
userCount: 8,
activeTab: 'Post',
})
})
it('shows a total of 33 results', () => {
setTimeout(() => {
expect(wrapper.find('.total-search-results').text()).toContain('33')
}, counterTimeout)
})
it('shows tab with 25 posts found', () => {
setTimeout(() => {
expect(wrapper.find('[data-test="Post-tab"]').text()).toContain('25')
}, counterTimeout)
})
it('shows tab with 8 users found', () => {
setTimeout(() => {
expect(wrapper.find('[data-test="User-tab"]').text()).toContain('8')
}, counterTimeout)
})
it('shows tab with 0 hashtags found', () => {
setTimeout(() => {
expect(wrapper.find('[data-test="Hashtag-tab"]').text()).toContain('0')
}, counterTimeout)
})
it('has post tab as active tab', () => {
expect(wrapper.find('[data-test="Post-tab"]').classes('--active')).toBe(true)
})
it('has user tab inactive', () => {
expect(wrapper.find('[data-test="User-tab"]').classes('--active')).toBe(false)
})
it('has hashtag tab disabled', () => {
expect(wrapper.find('[data-test="Hashtag-tab"]').classes('--disabled')).toBe(true)
})
it('displays 12 (pageSize) posts', () => {
expect(wrapper.findAll('.post-teaser')).toHaveLength(12)
})
it('has post tab inactive after emitting switch-tab', async () => {
wrapper.find('.tab-navigation').vm.$emit('switch-tab', 'User') // emits direct from tab component to search results
await wrapper.vm.$nextTick()
await expect(wrapper.find('[data-test="Post-tab"]').classes('--active')).toBe(false)
})
it('has post tab inactive after clicking on user tab', async () => {
wrapper.find('[data-test="User-tab-click"]').trigger('click')
await wrapper.vm.$nextTick()
await expect(wrapper.find('[data-test="Post-tab"]').classes('--active')).toBe(false)
})
it('has user tab active after clicking on user tab', async () => {
wrapper.find('[data-test="User-tab-click"]').trigger('click')
await wrapper.vm.$nextTick()
await expect(wrapper.find('[data-test="User-tab"]').classes('--active')).toBe(true)
})
it('displays 8 users after clicking on user tab', async () => {
wrapper.find('[data-test="User-tab-click"]').trigger('click')
await wrapper.vm.$nextTick()
await expect(wrapper.findAll('.user-teaser')).toHaveLength(8)
})
it('shows the pagination buttons for posts', () => {
expect(wrapper.find('.pagination-buttons').exists()).toBe(true)
})
it('shows no pagination buttons for users', async () => {
wrapper.find('[data-test="User-tab-click"]').trigger('click')
await wrapper.vm.$nextTick()
await expect(wrapper.find('.pagination-buttons').exists()).toBe(false)
})
it('displays page 1 of 3 for the 25 posts', () => {
expect(wrapper.find('.pagination-pageCount').text().replace(/\s+/g, ' ')).toContain(
'1 / 3',
)
})
it('displays the next page button for the 25 posts', () => {
expect(wrapper.find('.next-button').exists()).toBe(true)
})
it('deactivates previous page button for the 25 posts', () => {
const previousButton = wrapper.find('[data-test="previous-button"]')
expect(previousButton.attributes().disabled).toEqual('disabled')
})
it('displays page 2 / 3 when next-button is clicked', async () => {
wrapper.find('.next-button').trigger('click')
await wrapper.vm.$nextTick()
await expect(wrapper.find('.pagination-pageCount').text().replace(/\s+/g, ' ')).toContain(
'2 / 3',
)
})
it('sets apollo searchPosts offset to 12 when next-button is clicked', async () => {
wrapper.find('.next-button').trigger('click')
await wrapper.vm.$nextTick()
await expect(
wrapper.vm.$options.apollo.searchPosts.variables.bind(wrapper.vm)(),
).toMatchObject({ query: undefined, firstPosts: 12, postsOffset: 12 })
})
it('displays the next page button when next-button is clicked', async () => {
wrapper.find('.next-button').trigger('click')
await wrapper.vm.$nextTick()
await expect(wrapper.find('.next-button').exists()).toBe(true)
})
it('displays the previous page button when next-button is clicked', async () => {
wrapper.find('.next-button').trigger('click')
await wrapper.vm.$nextTick()
await expect(wrapper.find('.previous-button').exists()).toBe(true)
})
it('displays page 3 / 3 when next-button is clicked twice', async () => {
wrapper.find('.next-button').trigger('click')
wrapper.find('.next-button').trigger('click')
await wrapper.vm.$nextTick()
await expect(wrapper.find('.pagination-pageCount').text().replace(/\s+/g, ' ')).toContain(
'3 / 3',
)
})
it('sets apollo searchPosts offset to 24 when next-button is clicked twice', async () => {
wrapper.find('.next-button').trigger('click')
wrapper.find('.next-button').trigger('click')
await wrapper.vm.$nextTick()
await expect(
wrapper.vm.$options.apollo.searchPosts.variables.bind(wrapper.vm)(),
).toMatchObject({ query: undefined, firstPosts: 12, postsOffset: 24 })
})
it('deactivates next page button when next-button is clicked twice', async () => {
const nextButton = wrapper.find('[data-test="next-button"]')
nextButton.trigger('click')
nextButton.trigger('click')
await wrapper.vm.$nextTick()
expect(nextButton.attributes().disabled).toEqual('disabled')
})
it('displays the previous page button when next-button is clicked twice', async () => {
wrapper.find('.next-button').trigger('click')
wrapper.find('.next-button').trigger('click')
await wrapper.vm.$nextTick()
await expect(wrapper.find('.previous-button').exists()).toBe(true)
})
it('displays page 1 / 3 when previous-button is clicked after next-button', async () => {
wrapper.find('.next-button').trigger('click')
await wrapper.vm.$nextTick()
wrapper.find('.previous-button').trigger('click')
await wrapper.vm.$nextTick()
await expect(wrapper.find('.pagination-pageCount').text().replace(/\s+/g, ' ')).toContain(
'1 / 3',
)
})
it('sets apollo searchPosts offset to 0 when previous-button is clicked after next-button', async () => {
wrapper.find('.next-button').trigger('click')
await wrapper.vm.$nextTick()
wrapper.find('.previous-button').trigger('click')
await wrapper.vm.$nextTick()
await expect(
wrapper.vm.$options.apollo.searchPosts.variables.bind(wrapper.vm)(),
).toMatchObject({ query: undefined, firstPosts: 12, postsOffset: 0 })
})
})
})
})
})

View File

@ -0,0 +1,374 @@
<template>
<div id="search-results" class="search-results">
<ds-flex-item :width="{ base: '100%', sm: 3, md: 5, lg: 3 }">
<masonry-grid>
<!-- search text -->
<ds-grid-item class="grid-total-search-results" :row-span="1" column-span="fullWidth">
<ds-space margin-bottom="xxx-small" margin-top="xxx-small" centered>
<ds-text class="total-search-results">
{{ $t('search.for') }}
<strong>{{ '"' + (search || '') + '"' }}</strong>
</ds-text>
</ds-space>
</ds-grid-item>
<!-- tabs -->
<tab-navigation :tabs="tabOptions" :activeTab="activeTab" @switch-tab="switchTab" />
<!-- search results -->
<template v-if="!(!activeResourceCount || searchCount === 0)">
<!-- pagination buttons -->
<ds-grid-item v-if="activeResourceCount > pageSize" :row-span="2" column-span="fullWidth">
<ds-space centered>
<pagination-buttons
:hasNext="hasNext"
:showPageCounter="true"
:hasPrevious="hasPrevious"
:activePage="activePage"
:activeResourceCount="activeResourceCount"
:key="'Top'"
:pageSize="pageSize"
@back="previousResults"
@next="nextResults"
/>
</ds-space>
</ds-grid-item>
<!-- posts -->
<template v-if="activeTab === 'Post'">
<masonry-grid-item
v-for="post in activeResources"
:key="post.id"
:imageAspectRatio="post.image && post.image.aspectRatio"
>
<post-teaser
:post="post"
:width="{ base: '100%', md: '100%', xl: '50%' }"
@removePostFromList="posts = removePostFromList(post, posts)"
@pinPost="pinPost(post, refetchPostList)"
@unpinPost="unpinPost(post, refetchPostList)"
/>
</masonry-grid-item>
</template>
<!-- users -->
<template v-if="activeTab === 'User'">
<ds-grid-item v-for="user in activeResources" :key="user.id" :row-span="2">
<base-card :wideContent="true">
<user-teaser :user="user" />
</base-card>
</ds-grid-item>
</template>
<!-- hashtags -->
<template v-if="activeTab === 'Hashtag'">
<ds-grid-item v-for="hashtag in activeResources" :key="hashtag.id" :row-span="2">
<base-card :wideContent="true">
<hc-hashtag :id="hashtag.id" />
</base-card>
</ds-grid-item>
</template>
<!-- pagination buttons -->
<ds-grid-item v-if="activeResourceCount > pageSize" :row-span="2" column-span="fullWidth">
<ds-space centered>
<pagination-buttons
:hasNext="hasNext"
:hasPrevious="hasPrevious"
:activePage="activePage"
:showPageCounter="true"
:activeResourceCount="activeResourceCount"
:key="'Bottom'"
:pageSize="pageSize"
:srollTo="'#search-results'"
@back="previousResults"
@next="nextResults"
/>
</ds-space>
</ds-grid-item>
</template>
<!-- no results -->
<ds-grid-item v-else :row-span="7" column-span="fullWidth">
<ds-space centered>
<hc-empty icon="tasks" :message="$t('search.no-results', { search })" />
</ds-space>
</ds-grid-item>
</masonry-grid>
</ds-flex-item>
</div>
</template>
<script>
import postListActions from '~/mixins/postListActions'
import { searchPosts, searchUsers, searchHashtags } from '~/graphql/Search.js'
import HcEmpty from '~/components/Empty/Empty'
import MasonryGrid from '~/components/MasonryGrid/MasonryGrid'
import MasonryGridItem from '~/components/MasonryGrid/MasonryGridItem'
import PostTeaser from '~/components/PostTeaser/PostTeaser'
import TabNavigation from '~/components/_new/generic/TabNavigation/TabNavigation'
import UserTeaser from '~/components/UserTeaser/UserTeaser'
import PaginationButtons from '~/components/_new/generic/PaginationButtons/PaginationButtons'
import HcHashtag from '~/components/Hashtag/Hashtag'
export default {
components: {
TabNavigation,
HcEmpty,
MasonryGrid,
MasonryGridItem,
PostTeaser,
PaginationButtons,
UserTeaser,
HcHashtag,
},
mixins: [postListActions],
props: {
search: {
type: String,
},
pageSize: {
type: Number,
default: 12,
},
},
data() {
return {
posts: [],
users: [],
hashtags: [],
postCount: 0,
userCount: 0,
hashtagCount: 0,
postPage: 0,
userPage: 0,
hashtagPage: 0,
activeTab: null,
firstPosts: this.pageSize,
firstUsers: this.pageSize,
firstHashtags: this.pageSize,
postsOffset: 0,
usersOffset: 0,
hashtagsOffset: 0,
}
},
computed: {
activeResources() {
if (this.activeTab === 'Post') return this.posts
if (this.activeTab === 'User') return this.users
if (this.activeTab === 'Hashtag') return this.hashtags
return []
},
activeResourceCount() {
if (this.activeTab === 'Post') return this.postCount
if (this.activeTab === 'User') return this.userCount
if (this.activeTab === 'Hashtag') return this.hashtagCount
return 0
},
activePage() {
if (this.activeTab === 'Post') return this.postPage
if (this.activeTab === 'User') return this.userPage
if (this.activeTab === 'Hashtag') return this.hashtagPage
return 0
},
tabOptions() {
return [
{
type: 'Post',
title: this.$t('search.heading.Post', {}, this.postCount),
count: this.postCount,
disabled: this.postCount === 0,
},
{
type: 'User',
title: this.$t('search.heading.User', {}, this.userCount),
count: this.userCount,
disabled: this.userCount === 0,
},
{
type: 'Hashtag',
title: this.$t('search.heading.Tag', {}, this.hashtagCount),
count: this.hashtagCount,
disabled: this.hashtagCount === 0,
},
]
},
hasPrevious() {
if (this.activeTab === 'Post') return this.postsOffset > 0
if (this.activeTab === 'User') return this.usersOffset > 0
if (this.activeTab === 'Hashtag') return this.hashtagsOffset > 0
return false
},
hasNext() {
if (this.activeTab === 'Post') return (this.postPage + 1) * this.pageSize < this.postCount
if (this.activeTab === 'User') return (this.userPage + 1) * this.pageSize < this.userCount
if (this.activeTab === 'Hashtag')
return (this.hashtagPage + 1) * this.pageSize < this.hashtagCount
return false
},
searchCount() {
return this.postCount + this.userCount + this.hashtagCount
},
},
methods: {
clearPage() {
this.postPage = 0
this.userPage = 0
this.hashtagPage = 0
},
switchTab(tabType) {
if (this.activeTab !== tabType) {
this.activeTab = tabType
}
},
previousResults() {
switch (this.activeTab) {
case 'Post':
this.postPage--
this.postsOffset = this.postPage * this.pageSize
break
case 'User':
this.userPage--
this.usersOffset = this.userPage * this.pageSize
break
case 'Hashtag':
this.hashtagPage--
this.hashtagsOffset = this.hashtagPage * this.pageSize
break
}
},
nextResults() {
// scroll to top??
switch (this.activeTab) {
case 'Post':
this.postPage++
this.postsOffset += this.pageSize
break
case 'User':
this.userPage++
this.usersOffset += this.pageSize
break
case 'Hashtag':
this.hashtagPage++
this.hashtagsOffset += this.pageSize
break
}
},
refetchPostList() {
this.$apollo.queries.searchPosts.refetch()
},
},
apollo: {
searchHashtags: {
query() {
return searchHashtags
},
variables() {
const { firstHashtags, hashtagsOffset, search } = this
return {
query: search,
firstHashtags,
hashtagsOffset,
}
},
skip() {
return !this.search
},
update({ searchHashtags }) {
this.hashtags = searchHashtags.hashtags
this.hashtagCount = searchHashtags.hashtagCount
if (this.postCount === 0 && this.userCount === 0 && this.hashtagCount > 0)
this.activeTab = 'Hashtag'
},
fetchPolicy: 'cache-and-network',
},
searchUsers: {
query() {
return searchUsers
},
variables() {
const { firstUsers, usersOffset, search } = this
return {
query: search,
firstUsers,
usersOffset,
}
},
skip() {
return !this.search
},
update({ searchUsers }) {
this.users = searchUsers.users
this.userCount = searchUsers.userCount
if (this.postCount === 0 && this.userCount > 0) this.activeTab = 'User'
},
fetchPolicy: 'cache-and-network',
},
searchPosts: {
query() {
return searchPosts
},
variables() {
const { firstPosts, postsOffset, search } = this
return {
query: search,
firstPosts,
postsOffset,
}
},
skip() {
return !this.search
},
update({ searchPosts }) {
this.posts = searchPosts.posts
this.postCount = searchPosts.postCount
if (this.postCount > 0) this.activeTab = 'Post'
},
fetchPolicy: 'cache-and-network',
},
},
}
</script>
<style lang="scss">
.search-results {
> .results {
padding: $space-small;
background-color: $color-neutral-80;
border-radius: 0 $border-radius-base $border-radius-base $border-radius-base;
&.--user {
width: 100%;
max-width: 600px;
}
&.--empty {
width: 100%;
max-width: 600px;
background-color: transparent;
border: $border-size-base solid $color-neutral-80;
}
}
.user-list > .item {
transition: opacity 0.1s;
&:not(:last-child) {
margin-bottom: $space-small;
}
&:hover {
opacity: 0.8;
}
}
}
.grid-total-search-results {
padding: 0;
margin: 0;
}
</style>

View File

@ -5,62 +5,81 @@ import PaginationButtons from './PaginationButtons'
const localVue = global.localVue
describe('PaginationButtons.vue', () => {
let propsData = {}
const propsData = {
showPageCounter: true,
activePage: 1,
activeResourceCount: 57,
}
let wrapper
let nextButton
let backButton
const mocks = {
$t: jest.fn(),
}
const Wrapper = () => {
return mount(PaginationButtons, { propsData, localVue })
return mount(PaginationButtons, { mocks, propsData, localVue })
}
describe('mount', () => {
describe('next button', () => {
beforeEach(() => {
propsData.hasNext = true
wrapper = Wrapper()
nextButton = wrapper.find('[data-test="next-button"]')
})
beforeEach(() => {
wrapper = Wrapper()
})
describe('next button', () => {
it('is disabled by default', () => {
propsData = {}
wrapper = Wrapper()
nextButton = wrapper.find('[data-test="next-button"]')
const nextButton = wrapper.find('[data-test="next-button"]')
expect(nextButton.attributes().disabled).toEqual('disabled')
})
it('is enabled if hasNext is true', () => {
it('is enabled if hasNext is true', async () => {
wrapper.setProps({ hasNext: true })
await wrapper.vm.$nextTick()
const nextButton = wrapper.find('[data-test="next-button"]')
expect(nextButton.attributes().disabled).toBeUndefined()
})
it('emits next when clicked', async () => {
await nextButton.trigger('click')
wrapper.setProps({ hasNext: true })
await wrapper.vm.$nextTick()
wrapper.find('[data-test="next-button"]').trigger('click')
await wrapper.vm.$nextTick()
expect(wrapper.emitted().next).toHaveLength(1)
})
})
describe('back button', () => {
beforeEach(() => {
propsData.hasPrevious = true
wrapper = Wrapper()
backButton = wrapper.find('[data-test="previous-button"]')
})
describe('previous button', () => {
it('is disabled by default', () => {
propsData = {}
wrapper = Wrapper()
backButton = wrapper.find('[data-test="previous-button"]')
expect(backButton.attributes().disabled).toEqual('disabled')
const previousButton = wrapper.find('[data-test="previous-button"]')
expect(previousButton.attributes().disabled).toEqual('disabled')
})
it('is enabled if hasPrevious is true', () => {
expect(backButton.attributes().disabled).toBeUndefined()
it('is enabled if hasPrevious is true', async () => {
wrapper.setProps({ hasPrevious: true })
await wrapper.vm.$nextTick()
const previousButton = wrapper.find('[data-test="previous-button"]')
expect(previousButton.attributes().disabled).toBeUndefined()
})
it('emits back when clicked', async () => {
await backButton.trigger('click')
wrapper.setProps({ hasPrevious: true })
await wrapper.vm.$nextTick()
wrapper.find('[data-test="previous-button"]').trigger('click')
await wrapper.vm.$nextTick()
expect(wrapper.emitted().back).toHaveLength(1)
})
})
describe('page counter', () => {
it('displays the page counter when showPageCount is true', () => {
const paginationPageCount = wrapper.find('[data-test="pagination-pageCount"]')
expect(paginationPageCount.text().replace(/\s+/g, ' ')).toEqual('2 / 3')
})
it('does not display the page counter when showPageCount is false', async () => {
wrapper.setProps({ showPageCounter: false })
await wrapper.vm.$nextTick()
const paginationPageCount = wrapper.find('[data-test="pagination-pageCount"]')
expect(paginationPageCount.exists()).toEqual(false)
})
})
})
})

View File

@ -1,18 +1,26 @@
<template>
<div class="pagination-buttons">
<base-button
@click="$emit('back')"
class="previous-button"
:disabled="!hasPrevious"
icon="arrow-left"
circle
data-test="previous-button"
@click="$emit('back')"
/>
<span v-if="showPageCounter" class="pagination-pageCount" data-test="pagination-pageCount">
{{ $t('search.page') }} {{ activePage + 1 }} /
{{ Math.floor((activeResourceCount - 1) / pageSize) + 1 }}
</span>
<base-button
@click="$emit('next')"
class="next-button"
:disabled="!hasNext"
icon="arrow-right"
circle
data-test="next-button"
@click="$emit('next')"
/>
</div>
</template>
@ -20,12 +28,31 @@
<script>
export default {
props: {
pageSize: {
type: Number,
default: 24,
},
hasNext: {
type: Boolean,
default: false,
},
hasPrevious: {
type: Boolean,
},
activePage: {
type: Number,
default: 0,
},
totalResultCount: {
type: Number,
default: 0,
},
activeResourceCount: {
type: Number,
default: 0,
},
showPageCounter: {
type: Boolean,
default: false,
},
},
@ -39,4 +66,10 @@ export default {
width: $size-width-paginate;
margin: $space-x-small auto;
}
.pagination-pageCount {
justify-content: space-around;
margin: 8px auto;
}
</style>

View File

@ -0,0 +1,105 @@
import { config, mount } from '@vue/test-utils'
import TabNavigation from './TabNavigation'
const localVue = global.localVue
config.stubs['client-only'] = '<span><slot /></span>'
describe('TabNavigation', () => {
let mocks, propsData, wrapper
const Wrapper = () => {
return mount(TabNavigation, { mocks, localVue, propsData })
}
beforeEach(() => {
mocks = {
$t: jest.fn(),
}
propsData = {
tabs: [
{
type: 'Post',
title: 'Posts',
count: 12,
disabled: false,
},
{
type: 'User',
title: 'Users',
count: 9,
disabled: false,
},
{
type: 'Hashtag',
title: 'Hashtags',
count: 0,
disabled: true,
},
],
activeTab: 'Post',
}
wrapper = Wrapper()
})
describe('mount', () => {
it('renders tab-navigation component', () => {
expect(wrapper.find('.tab-navigation').exists()).toBe(true)
})
describe('displays', () => {
// we couldn't get it running with "jest.runAllTimers()" and so we used "setTimeout"
// time is a bit more then 3000 milisec see "webapp/components/CountTo.vue"
const counterTimeout = 3000 + 10
it('shows a total of 17 results', () => {
setTimeout(() => {
expect(wrapper.find('.total-search-results').text()).toContain('17')
}, counterTimeout)
})
it('shows tab with 12 posts', () => {
setTimeout(() => {
expect(wrapper.find('[data-test="Post-tab"]').text()).toContain('12')
}, counterTimeout)
})
it('shows tab with 9 users', () => {
setTimeout(() => {
expect(wrapper.find('[data-test="User-tab"]').text()).toContain('9')
}, counterTimeout)
})
it('shows tab with 0 hashtags', () => {
setTimeout(() => {
expect(wrapper.find('[data-test="Hashtag-tab"]').text()).toContain('0')
}, counterTimeout)
})
describe('basic props setting', () => {
it('has post tab as active tab', () => {
expect(wrapper.find('[data-test="Post-tab"]').classes('--active')).toBe(true)
})
it('has user tab inactive', () => {
expect(wrapper.find('[data-test="User-tab"]').classes('--active')).toBe(false)
})
it('has hashtag tab disabled', () => {
expect(wrapper.find('[data-test="Hashtag-tab"]').classes('--disabled')).toBe(true)
})
})
})
describe('interactions', () => {
it('emits "switch-tab" with "User" after clicking on user tab', () => {
wrapper.find('[data-test="User-tab-click"]').trigger('click')
expect(wrapper.emitted('switch-tab')).toEqual([['User']])
})
it('emits no "switch-tab" after clicking on inactiv hashtag tab', () => {
wrapper.find('[data-test="Hashtag-tab-click"]').trigger('click')
expect(wrapper.emitted('switch-tab')).toBeFalsy()
})
})
})
})

View File

@ -0,0 +1,111 @@
<template>
<ds-grid-item class="tab-navigation" :row-span="tabs.length" column-span="fullWidth">
<base-card class="ds-tab-nav">
<ul class="Tabs">
<li
v-for="tab in tabs"
:key="tab.type"
:class="[
'Tabs__tab',
'pointer',
activeTab === tab.type && '--active',
tab.disabled && '--disabled',
]"
:data-test="tab.type + '-tab'"
>
<a :data-test="tab.type + '-tab-click'" @click="switchTab(tab)">
<ds-space margin="small">
<client-only :placeholder="$t('client-only.loading')">
<ds-number :label="tab.title">
<hc-count-to slot="count" :end-val="tab.count" />
</ds-number>
</client-only>
</ds-space>
</a>
</li>
</ul>
</base-card>
</ds-grid-item>
</template>
<script>
import HcCountTo from '~/components/CountTo.vue'
export default {
components: {
HcCountTo,
},
props: {
tabs: {
type: Array,
required: true,
},
activeTab: {
type: String,
},
},
methods: {
switchTab(tab) {
if (!tab.disabled) {
this.$emit('switch-tab', tab.type)
}
},
},
}
</script>
<style lang="scss">
.pointer {
cursor: pointer;
}
.Tabs {
position: relative;
background-color: #fff;
height: 100%;
display: flex;
margin: 0;
padding: 0;
list-style: none;
&__tab {
text-align: center;
height: 100%;
flex-grow: 1;
&:hover {
border-bottom: 2px solid #c9c6ce;
}
&.--active {
border-bottom: 2px solid #17b53f;
}
&.--disabled {
opacity: $opacity-disabled;
&:hover {
border-bottom: none;
}
}
}
}
.tab-navigation {
position: sticky;
top: 53px;
z-index: 2;
}
.ds-tab-nav.base-card {
padding: 0;
.ds-tab-nav-item {
&.ds-tab-nav-item-active {
border-bottom: 3px solid #17b53f;
&:first-child {
border-bottom-left-radius: $border-radius-x-large;
}
&:last-child {
border-bottom-right-radius: $border-radius-x-large;
}
}
}
}
</style>

View File

@ -0,0 +1,188 @@
import { storiesOf } from '@storybook/vue'
import { withA11y } from '@storybook/addon-a11y'
import HcEmpty from '~/components/Empty/Empty'
import MasonryGrid from '~/components/MasonryGrid/MasonryGrid'
import MasonryGridItem from '~/components/MasonryGrid/MasonryGridItem'
import PostTeaser from '~/components/PostTeaser/PostTeaser'
import TabNavigation from '~/components/_new/generic/TabNavigation/TabNavigation'
import UserTeaser from '~/components/UserTeaser/UserTeaser'
import HcHashtag from '~/components/Hashtag/Hashtag'
import helpers from '~/storybook/helpers'
import faker from 'faker'
import { post } from '~/components/PostTeaser/PostTeaser.story.js'
import { user } from '~/components/UserTeaser/UserTeaser.story.js'
helpers.init()
const postMock = (fields) => {
return {
...post,
id: faker.random.uuid(),
createdAt: faker.date.past(),
updatedAt: faker.date.recent(),
deleted: false,
disabled: false,
typename: 'Post',
...fields,
}
}
const userMock = (fields) => {
return {
...user,
id: faker.random.uuid(),
createdAt: faker.date.past(),
updatedAt: faker.date.recent(),
deleted: false,
disabled: false,
typename: 'User',
...fields,
}
}
const posts = [
postMock(),
postMock({ author: user }),
postMock({ title: faker.lorem.sentence() }),
postMock({ contentExcerpt: faker.lorem.paragraph() }),
postMock({ author: user }),
postMock({ title: faker.lorem.sentence() }),
postMock({ author: user }),
]
const users = [
userMock(),
userMock({ slug: 'louie-rider', name: 'Louie Rider' }),
userMock({ slug: 'louicinda-johnson', name: 'Louicinda Jonhson' }),
userMock({ slug: 'sam-louie', name: 'Sam Louie' }),
userMock({ slug: 'loucette', name: 'Loucette Rider' }),
userMock({ slug: 'louis', name: 'Louis' }),
userMock({ slug: 'louanna', name: 'Louanna' }),
]
storiesOf('TabNavigator', module)
.addDecorator(withA11y)
.addDecorator(helpers.layout)
.add('given search results of posts, users, hashtags', () => ({
components: {
TabNavigation,
HcEmpty,
MasonryGrid,
MasonryGridItem,
PostTeaser,
UserTeaser,
HcHashtag,
},
store: helpers.store,
data: () => ({
posts: posts,
users: users,
hashtags: [],
postCount: posts.length,
userCount: users.length,
hashtagCount: 0,
activeTab: 'Post',
}),
computed: {
activeResources() {
if (this.activeTab === 'Post') return this.posts
if (this.activeTab === 'User') return this.users
if (this.activeTab === 'Hashtag') return this.hashtags
return []
},
activeResourceCount() {
if (this.activeTab === 'Post') return this.postCount
if (this.activeTab === 'User') return this.userCount
if (this.activeTab === 'Hashtag') return this.hashtagCount
return 0
},
tabOptions() {
return [
{
type: 'Post',
title: this.$t('search.heading.Post', {}, this.postCount),
count: this.postCount,
disabled: this.postCount === 0,
},
{
type: 'User',
title: this.$t('search.heading.User', {}, this.userCount),
count: this.userCount,
disabled: this.userCount === 0,
},
{
type: 'Hashtag',
title: this.$t('search.heading.Tag', {}, this.hashtagCount),
count: this.hashtagCount,
disabled: this.hashtagCount === 0,
},
]
},
searchCount() {
return this.postCount + this.userCount + this.hashtagCount
},
},
methods: {
switchTab(tabType) {
if (this.activeTab !== tabType) {
this.activeTab = tabType
}
},
},
template: `
<div id="search-results" class="search-results">
<ds-flex-item :width="{ base: '100%', sm: 3, md: 5, lg: 3 }">
<masonry-grid>
<!-- tabs -->
<tab-navigation :tabs="tabOptions" :activeTab="activeTab" @switch-tab="switchTab" />
<!-- search results -->
<template v-if="!(!activeResourceCount || searchCount === 0)">
<!-- posts -->
<template v-if="activeTab === 'Post'">
<masonry-grid-item
v-for="post in activeResources"
:key="post.id"
:imageAspectRatio="post.image && post.image.aspectRatio"
>
<post-teaser
:post="post"
:width="{ base: '100%', md: '100%', xl: '50%' }"
@removePostFromList="posts = removePostFromList(post, posts)"
@pinPost="pinPost(post, refetchPostList)"
@unpinPost="unpinPost(post, refetchPostList)"
/>
</masonry-grid-item>
</template>
<!-- users -->
<template v-if="activeTab === 'User'">
<ds-grid-item v-for="user in activeResources" :key="user.id" :row-span="2">
<base-card :wideContent="true">
<user-teaser :user="user" />
</base-card>
</ds-grid-item>
</template>
<!-- hashtags -->
<template v-if="activeTab === 'Hashtag'">
<ds-grid-item v-for="hashtag in activeResources" :key="hashtag.id" :row-span="2">
<base-card :wideContent="true">
<hc-hashtag :id="hashtag.id" />
</base-card>
</ds-grid-item>
</template>
</template>
<!-- no results -->
<ds-grid-item v-else :row-span="7" column-span="fullWidth">
<ds-space centered>
<hc-empty icon="tasks" :message="$t('search.no-results', { search })" />
</ds-space>
</ds-grid-item>
</masonry-grid>
</ds-flex-item>
</div>
`,
}))

View File

@ -9,7 +9,7 @@
</template>
<script>
import { findResourcesQuery } from '~/graphql/Search.js'
import { searchQuery } from '~/graphql/Search.js'
import SearchableInput from '~/components/generic/SearchableInput/SearchableInput.vue'
export default {
@ -28,14 +28,14 @@ export default {
this.pending = true
try {
const {
data: { findResources },
data: { searchResults },
} = await this.$apollo.query({
query: findResourcesQuery,
query: searchQuery,
variables: {
query: value,
},
})
this.searchResults = findResources
this.searchResults = searchResults
} catch (error) {
this.searchResults = []
} finally {

View File

@ -1,6 +1,6 @@
<template>
<ds-heading soft size="h5" class="search-heading">
{{ $t(`search.heading.${resourceType}`) }}
{{ $t(`search.heading.${resourceType}`, {}, 2) }}
</ds-heading>
</template>
<script>

View File

@ -60,13 +60,6 @@ describe('SearchableInput.vue', () => {
expect(select.element.value).toBe('abcd')
})
it('searches for the term when enter is pressed', async () => {
select.element.value = 'ab'
select.trigger('input')
select.trigger('keyup.enter')
await expect(wrapper.emitted().query[0]).toEqual(['ab'])
})
it('calls onDelete when the delete key is pressed', () => {
const spy = jest.spyOn(wrapper.vm, 'onDelete')
select.trigger('input')
@ -117,5 +110,15 @@ describe('SearchableInput.vue', () => {
expect(mocks.$router.push).toHaveBeenCalledWith('?hashtag=Hashtag')
})
})
it('opens the search result page when enter is pressed', async () => {
select.element.value = 'ab'
select.trigger('input')
select.trigger('keyup.enter')
expect(mocks.$router.push).toHaveBeenCalledWith({
path: '/search/search-results',
query: { search: 'ab' },
})
})
})
})

View File

@ -112,7 +112,7 @@ export const searchResults = [
},
]
storiesOf('Search Field', module)
storiesOf('SearchableInput', module)
.addDecorator(withA11y)
.addDecorator(helpers.layout)
.add('test', () => ({
@ -122,6 +122,6 @@ storiesOf('Search Field', module)
searchResults,
}),
template: `
<searchable-input :options="searchResults" />
<searchable-input :loading="false" :options="searchResults" />
`,
}))

View File

@ -107,15 +107,12 @@ export default {
this.$emit('query', this.value)
}, this.delay)
},
/**
* TODO: on enter we should go to a dedicated search page!?
*/
onEnter(event) {
clearTimeout(this.searchProcess)
if (!this.loading) {
this.previousSearchTerm = this.unprocessedSearchInput
this.$emit('query', this.unprocessedSearchInput)
}
this.$router.push({
path: '/search/search-results',
query: { search: this.unprocessedSearchInput },
})
this.$emit('clearSearch')
},
onDelete(event) {
clearTimeout(this.searchProcess)

46
webapp/config/index.js Normal file
View File

@ -0,0 +1,46 @@
// ATTENTION: DO NOT PUT ANY SECRETS IN HERE (or the .env)
import dotenv from 'dotenv'
dotenv.config() // we want to synchronize @nuxt-dotenv and nuxt-env
// Load Package Details for some default values
const pkg = require('../package')
const environment = {
NODE_ENV: process.env.NODE_ENV,
DEBUG: process.env.NODE_ENV !== 'production' || false,
PRODUCTION: process.env.NODE_ENV === 'production' || false,
NUXT_BUILD: process.env.NUXT_BUILD || '.nuxt',
STYLEGUIDE_DEV: process.env.STYLEGUIDE_DEV || false,
RELEASE: process.env.release,
}
const server = {
GRAPHQL_URI: process.env.GRAPHQL_URI || 'http://localhost:4000',
BACKEND_TOKEN: process.env.BACKEND_TOKEN || 'NULL',
}
const sentry = {
SENTRY_DSN_WEBAPP: process.env.SENTRY_DSN_WEBAPP,
COMMIT: process.env.COMMIT,
}
const options = {
VERSION: process.env.VERSION || pkg.version,
DESCRIPTION: process.env.DESCRIPTION || pkg.description,
}
const CONFIG = {
...environment,
...server,
...sentry,
...options,
}
// override process.env with the values here since they contain default values
process.env = {
...process.env,
...CONFIG,
}
export default CONFIG

View File

@ -1,12 +1,12 @@
import gql from 'graphql-tag'
import { userFragment, postFragment } from './Fragments'
import { userFragment, postFragment, tagsCategoriesAndPinnedFragment } from './Fragments'
export const findResourcesQuery = gql`
export const searchQuery = gql`
${userFragment}
${postFragment}
query($query: String!) {
findResources(query: $query, limit: 5) {
searchResults(query: $query, limit: 5) {
__typename
... on Post {
...post
@ -25,3 +25,51 @@ export const findResourcesQuery = gql`
}
}
`
export const searchPosts = gql`
${userFragment}
${postFragment}
${tagsCategoriesAndPinnedFragment}
query($query: String!, $firstPosts: Int, $postsOffset: Int) {
searchPosts(query: $query, firstPosts: $firstPosts, postsOffset: $postsOffset) {
postCount
posts {
__typename
...post
...tagsCategoriesAndPinned
commentsCount
shoutedCount
author {
...user
}
}
}
}
`
export const searchUsers = gql`
${userFragment}
query($query: String!, $firstUsers: Int, $usersOffset: Int) {
searchUsers(query: $query, firstUsers: $firstUsers, usersOffset: $usersOffset) {
userCount
users {
__typename
...user
}
}
}
`
export const searchHashtags = gql`
query($query: String!, $firstHashtags: Int, $hashtagsOffset: Int) {
searchHashtags(query: $query, firstHashtags: $firstHashtags, hashtagsOffset: $hashtagsOffset) {
hashtagCount
hashtags {
__typename
id
}
}
}
`

View File

@ -75,6 +75,9 @@
}
}
},
"client-only": {
"loading": "Lade …"
},
"code-of-conduct": {
"subheader": "für das Soziale Netzwerk von {ORGANIZATION_NAME}"
},
@ -556,13 +559,18 @@
},
"search": {
"failed": "Nichts gefunden",
"for": "Suche nach ",
"heading": {
"Post": "Beiträge",
"Tag": "Hashtags",
"User": "Benutzer"
"Post": "Beitrag ::: Beiträge",
"Tag": "Hashtag ::: Hashtags",
"User": "Benutzer ::: Benutzer"
},
"hint": "Wonach suchst Du?",
"placeholder": "Suchen"
"no-results": "Keine Ergebnisse für \"{search}\" gefunden. Versuch' es mit einem anderen Begriff!",
"page": "Seite",
"placeholder": "Suchen",
"results": "Ergebnis gefunden ::: Ergebnisse gefunden",
"title": "Suchergebnisse"
},
"settings": {
"blocked-users": {

View File

@ -75,6 +75,9 @@
}
}
},
"client-only": {
"loading": "Loading …"
},
"code-of-conduct": {
"subheader": "for the social network of {ORGANIZATION_NAME}"
},
@ -556,13 +559,18 @@
},
"search": {
"failed": "Nothing found",
"for": "Searching for ",
"heading": {
"Post": "Posts",
"Tag": "Hashtags",
"User": "Users"
"Post": "Post ::: Posts",
"Tag": "Hashtag ::: Hashtags",
"User": "User ::: Users"
},
"hint": "What are you searching for?",
"placeholder": "Search"
"no-results": "No results found for \"{search}\". Try a different search term!",
"page": "Page",
"placeholder": "Search",
"results": "result found ::: results found",
"title": "Search Results"
},
"settings": {
"blocked-users": {

View File

@ -36,7 +36,7 @@ $ docker-compose up
````
And the maintenance mode page or service will be started as well in an own container.
In the browser you can reach it under `http://localhost:3503/`.
In the browser you can reach it under `http://localhost:5000/`.
{% endtab %}
{% tab title="On The Server" %}

View File

@ -0,0 +1,39 @@
import PostMutations from '~/graphql/PostMutations'
export default {
methods: {
removePostFromList(deletedPost, posts) {
return posts.filter((post) => {
return post.id !== deletedPost.id
})
},
pinPost(post, refetchPostList = () => {}) {
this.$apollo
.mutate({
mutation: PostMutations().pinPost,
variables: {
id: post.id,
},
})
.then(() => {
this.$toast.success(this.$t('post.menu.pinnedSuccessfully'))
refetchPostList()
})
.catch((error) => this.$toast.error(error.message))
},
unpinPost(post, refetchPostList = () => {}) {
this.$apollo
.mutate({
mutation: PostMutations().unpinPost,
variables: {
id: post.id,
},
})
.then(() => {
this.$toast.success(this.$t('post.menu.unpinnedSuccessfully'))
refetchPostList()
})
.catch((error) => this.$toast.error(error.message))
},
},
}

View File

@ -1,41 +1,25 @@
import path from 'path'
import dotenv from 'dotenv'
import manifest from './constants/manifest.js'
import metadata from './constants/metadata.js'
dotenv.config() // we want to synchronize @nuxt-dotenv and nuxt-env
const pkg = require('./package')
export const envWhitelist = [
'NODE_ENV',
'MAPBOX_TOKEN',
'PUBLIC_REGISTRATION',
'WEBSOCKETS_URI',
'GRAPHQL_URI',
]
const dev = process.env.NODE_ENV !== 'production'
const CONFIG = require('./config').default // we need to use require since this is only evaluated at compile time.
const styleguidePath = '../styleguide'
const styleguideStyles = process.env.STYLEGUIDE_DEV
const styleguideStyles = CONFIG.STYLEGUIDE_DEV
? [
`${styleguidePath}/src/system/styles/main.scss`,
`${styleguidePath}/src/system/styles/shared.scss`,
]
: '@human-connection/styleguide/dist/shared.scss'
const buildDir = process.env.NUXT_BUILD || '.nuxt'
const additionalSentryConfig = {}
if (process.env.COMMIT) additionalSentryConfig.release = process.env.COMMIT
export default {
buildDir,
buildDir: CONFIG.NUXT_BUILD,
mode: 'universal',
dev: dev,
debug: dev ? 'nuxt:*,app' : null,
dev: CONFIG.DEBUG,
debug: CONFIG.DEBUG ? 'nuxt:*,app' : null,
modern: !dev ? 'server' : false,
modern: CONFIG.PRODUCTION ? 'server' : false,
pageTransition: {
name: 'slide-up',
@ -43,7 +27,7 @@ export default {
},
env: {
release: pkg.version,
release: CONFIG.VERSION,
// pages which do NOT require a login
publicPages: [
'login',
@ -81,7 +65,7 @@ export default {
{
hid: 'description',
name: 'description',
content: pkg.description,
content: CONFIG.DESCRIPTION,
},
],
link: [
@ -120,7 +104,7 @@ export default {
plugins: [
{ src: '~/plugins/base-components.js', ssr: true },
{
src: `~/plugins/styleguide${process.env.STYLEGUIDE_DEV ? '-dev' : ''}.js`,
src: `~/plugins/styleguide${CONFIG.STYLEGUIDE_DEV ? '-dev' : ''}.js`,
ssr: true,
},
{ src: '~/plugins/i18n.js', ssr: true },
@ -143,18 +127,8 @@ export default {
** Nuxt.js modules
*/
modules: [
[
'@nuxtjs/dotenv',
{
only: envWhitelist,
},
],
[
'nuxt-env',
{
keys: envWhitelist,
},
],
['@nuxtjs/dotenv', { only: Object.keys(CONFIG) }],
['nuxt-env', { keys: Object.keys(CONFIG) }],
[
'vue-scrollto/nuxt',
{
@ -175,32 +149,32 @@ export default {
*/
axios: {
// See https://github.com/nuxt-community/axios-module#options
debug: dev,
debug: CONFIG.DEBUG,
proxy: true,
},
proxy: {
'/.well-known/webfinger': {
target: process.env.GRAPHQL_URI || 'http://localhost:4000',
target: CONFIG.GRAPHQL_URI,
toProxy: true, // cloudflare needs that
headers: {
Accept: 'application/json',
'X-UI-Request': true,
'X-API-TOKEN': process.env.BACKEND_TOKEN || 'NULL',
'X-API-TOKEN': CONFIG.BACKEND_TOKEN,
},
},
'/activitypub': {
// make this configurable (nuxt-dotenv)
target: process.env.GRAPHQL_URI || 'http://localhost:4000',
target: CONFIG.GRAPHQL_URI,
toProxy: true, // cloudflare needs that
headers: {
Accept: 'application/json',
'X-UI-Request': true,
'X-API-TOKEN': process.env.BACKEND_TOKEN || 'NULL',
'X-API-TOKEN': CONFIG.BACKEND_TOKEN,
},
},
'/api': {
// make this configurable (nuxt-dotenv)
target: process.env.GRAPHQL_URI || 'http://localhost:4000',
target: CONFIG.GRAPHQL_URI,
pathRewrite: {
'^/api': '',
},
@ -208,7 +182,7 @@ export default {
headers: {
Accept: 'application/json',
'X-UI-Request': true,
'X-API-TOKEN': process.env.BACKEND_TOKEN || 'NULL',
'X-API-TOKEN': CONFIG.BACKEND_TOKEN,
},
},
},
@ -235,9 +209,9 @@ export default {
},
sentry: {
dsn: process.env.SENTRY_DSN_WEBAPP,
publishRelease: !!process.env.COMMIT,
config: additionalSentryConfig,
dsn: CONFIG.SENTRY_DSN_WEBAPP,
publishRelease: !!CONFIG.COMMIT,
config: CONFIG.COMMIT ? { release: CONFIG.COMMIT } : {},
},
manifest,
@ -250,7 +224,7 @@ export default {
** You can extend webpack config here
*/
extend(config, ctx) {
if (process.env.STYLEGUIDE_DEV) {
if (CONFIG.STYLEGUIDE_DEV) {
config.resolve.alias['@@'] = path.resolve(__dirname, `${styleguidePath}/src/system`)
config.module.rules.push({
resourceQuery: /blockType=docs/,

23910
webapp/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -17,7 +17,7 @@
"lint": "eslint --ext .js,.vue .",
"locales": "../scripts/translations/missing-keys.sh && ../scripts/translations/sort.sh",
"precommit": "yarn lint",
"test": "jest",
"test": "cross-env NODE_ENV=test jest",
"test:unit:debug": "node --inspect-brk ./node_modules/jest/bin/jest.js --no-cache --runInBand"
},
"jest": {
@ -33,7 +33,7 @@
],
"transform": {
".*\\.(vue)$": "vue-jest",
"^.+\\.js$": "<rootDir>/node_modules/babel-jest"
"^.+\\.js$": "babel-jest"
},
"moduleFileExtensions": [
"js",

View File

@ -28,9 +28,9 @@
>
<post-teaser
:post="post"
@removePostFromList="deletePost"
@pinPost="pinPost"
@unpinPost="unpinPost"
@removePostFromList="posts = removePostFromList(post, posts)"
@pinPost="pinPost(post, refetchPostList)"
@unpinPost="unpinPost(post, refetchPostList)"
/>
</masonry-grid-item>
</template>
@ -64,6 +64,7 @@
</template>
<script>
import postListActions from '~/mixins/postListActions'
// import DonationInfo from '~/components/DonationInfo/DonationInfo.vue'
import HashtagsFilter from '~/components/HashtagsFilter/HashtagsFilter.vue'
import HcEmpty from '~/components/Empty/Empty'
@ -72,7 +73,6 @@ import MasonryGrid from '~/components/MasonryGrid/MasonryGrid.vue'
import MasonryGridItem from '~/components/MasonryGrid/MasonryGridItem.vue'
import { mapGetters, mapMutations } from 'vuex'
import { filterPosts } from '~/graphql/PostQuery.js'
import PostMutations from '~/graphql/PostMutations'
import UpdateQuery from '~/components/utils/UpdateQuery'
import links from '~/constants/links.js'
@ -85,6 +85,7 @@ export default {
MasonryGrid,
MasonryGridItem,
},
mixins: [postListActions],
data() {
const { hashtag = null } = this.$route.query
return {
@ -162,41 +163,14 @@ export default {
updateQuery: UpdateQuery(this, { $state, pageKey: 'Post' }),
})
},
deletePost(deletedPost) {
this.posts = this.posts.filter((post) => {
return post.id !== deletedPost.id
})
},
resetPostList() {
this.offset = 0
this.posts = []
this.hasMore = true
},
pinPost(post) {
this.$apollo
.mutate({
mutation: PostMutations().pinPost,
variables: { id: post.id },
})
.then(() => {
this.$toast.success(this.$t('post.menu.pinnedSuccessfully'))
this.resetPostList()
this.$apollo.queries.Post.refetch()
})
.catch((error) => this.$toast.error(error.message))
},
unpinPost(post) {
this.$apollo
.mutate({
mutation: PostMutations().unpinPost,
variables: { id: post.id },
})
.then(() => {
this.$toast.success(this.$t('post.menu.unpinnedSuccessfully'))
this.resetPostList()
this.$apollo.queries.Post.refetch()
})
.catch((error) => this.$toast.error(error.message))
refetchPostList() {
this.resetPostList()
this.$apollo.queries.Post.refetch()
},
},
apollo: {

View File

@ -90,7 +90,7 @@ describe('PostIndex', () => {
expect(wrapper.vm.selected).toEqual(propsData.filterOptions[1].label)
})
it('refreshes the notificaitons', () => {
it('refreshes the notifications', () => {
expect(mocks.$apollo.queries.notifications.refresh).toHaveBeenCalledTimes(1)
})
})

View File

@ -27,7 +27,11 @@
<post-teaser
:post="relatedPost"
:width="{ base: '100%', lg: 1 }"
@removePostFromList="removePostFromList"
@removePostFromList="
post.relatedContributions = removePostFromList(relatedPost, post.relatedContributions)
"
@pinPost="pinPost(relatedPost, refetchPostList)"
@unpinPost="unpinPost(relatedPost, refetchPostList)"
/>
</masonry-grid-item>
</masonry-grid>
@ -37,6 +41,7 @@
</template>
<script>
import postListActions from '~/mixins/postListActions'
import HcEmpty from '~/components/Empty/Empty'
import PostTeaser from '~/components/PostTeaser/PostTeaser.vue'
import HcCategory from '~/components/Category'
@ -47,10 +52,6 @@ import MasonryGridItem from '~/components/MasonryGrid/MasonryGridItem.vue'
import { sortTagsAlphabetically } from '~/components/utils/PostHelpers'
export default {
transition: {
name: 'slide-up',
mode: 'out-in',
},
components: {
PostTeaser,
HcCategory,
@ -59,6 +60,11 @@ export default {
MasonryGrid,
MasonryGridItem,
},
transition: {
name: 'slide-up',
mode: 'out-in',
},
mixins: [postListActions],
computed: {
post() {
return this.Post ? this.Post[0] || {} : {}
@ -68,10 +74,8 @@ export default {
},
},
methods: {
removePostFromList(deletedPost) {
this.post.relatedContributions = this.post.relatedContributions.filter((contribution) => {
return contribution.id !== deletedPost.id
})
refetchPostList() {
this.$apollo.queries.Post.refetch()
},
},
apollo: {

View File

@ -108,50 +108,10 @@
<ds-flex-item :width="{ base: '100%', sm: 3, md: 5, lg: 3 }">
<masonry-grid>
<ds-grid-item class="profile-top-navigation" :row-span="3" column-span="fullWidth">
<base-card class="ds-tab-nav">
<ul class="Tabs">
<li class="Tabs__tab pointer" :class="{ active: tabActive === 'post' }">
<a @click="handleTab('post')">
<ds-space margin="small">
<client-only placeholder="Loading...">
<ds-number :label="$t('common.post', null, user.contributionsCount)">
<hc-count-to slot="count" :end-val="user.contributionsCount" />
</ds-number>
</client-only>
</ds-space>
</a>
</li>
<li class="Tabs__tab pointer" :class="{ active: tabActive === 'comment' }">
<a @click="handleTab('comment')">
<ds-space margin="small">
<client-only placeholder="Loading...">
<ds-number :label="$t('profile.commented')">
<hc-count-to slot="count" :end-val="user.commentedCount" />
</ds-number>
</client-only>
</ds-space>
</a>
</li>
<li
class="Tabs__tab pointer"
:class="{ active: tabActive === 'shout' }"
v-if="myProfile || user.showShoutsPublicly"
>
<a @click="handleTab('shout')">
<ds-space margin="small">
<client-only placeholder="Loading...">
<ds-number :label="$t('profile.shouted')">
<hc-count-to slot="count" :end-val="user.shoutedCount" />
</ds-number>
</client-only>
</ds-space>
</a>
</li>
</ul>
</base-card>
</ds-grid-item>
<!-- TapNavigation -->
<tab-navigation :tabs="tabOptions" :activeTab="tabActive" @switch-tab="handleTab" />
<!-- feed -->
<ds-grid-item :row-span="2" column-span="fullWidth">
<ds-space centered>
<nuxt-link :to="{ name: 'post-create' }">
@ -181,9 +141,9 @@
<post-teaser
:post="post"
:width="{ base: '100%', md: '100%', xl: '50%' }"
@removePostFromList="removePostFromList"
@pinPost="pinPost"
@unpinPost="unpinPost"
@removePostFromList="posts = removePostFromList(post, posts)"
@pinPost="pinPost(post, refetchPostList)"
@unpinPost="unpinPost(post, refetchPostList)"
/>
</masonry-grid-item>
</template>
@ -210,6 +170,7 @@
<script>
import uniqBy from 'lodash/uniqBy'
import postListActions from '~/mixins/postListActions'
import PostTeaser from '~/components/PostTeaser/PostTeaser.vue'
import HcFollowButton from '~/components/FollowButton.vue'
import HcCountTo from '~/components/CountTo.vue'
@ -221,11 +182,11 @@ import HcUpload from '~/components/Upload'
import UserAvatar from '~/components/_new/generic/UserAvatar/UserAvatar'
import MasonryGrid from '~/components/MasonryGrid/MasonryGrid.vue'
import MasonryGridItem from '~/components/MasonryGrid/MasonryGridItem.vue'
import TabNavigation from '~/components/_new/generic/TabNavigation/TabNavigation'
import { profilePagePosts } from '~/graphql/PostQuery'
import UserQuery from '~/graphql/User'
import { muteUser, unmuteUser } from '~/graphql/settings/MutedUsers'
import { blockUser, unblockUser } from '~/graphql/settings/BlockedUsers'
import PostMutations from '~/graphql/PostMutations'
import UpdateQuery from '~/components/utils/UpdateQuery'
import SocialMedia from '~/components/SocialMedia/SocialMedia'
@ -251,7 +212,9 @@ export default {
MasonryGrid,
MasonryGridItem,
FollowList,
TabNavigation,
},
mixins: [postListActions],
transition: {
name: 'slide-up',
mode: 'out-in',
@ -286,17 +249,36 @@ export default {
const { slug } = this.user || {}
return slug && `@${slug}`
},
tabOptions() {
return [
{
type: 'post',
title: this.$t('common.post', null, this.user.contributionsCount),
count: this.user.contributionsCount,
disabled: this.user.contributionsCount === 0,
},
{
type: 'comment',
title: this.$t('profile.commented'),
count: this.user.commentedCount,
disabled: this.user.commentedCount === 0,
},
{
type: 'shout',
title: this.$t('profile.shouted'),
count: this.user.shoutedCount,
disabled: this.user.shoutedCount === 0,
},
]
},
},
methods: {
removePostFromList(deletedPost) {
this.posts = this.posts.filter((post) => {
return post.id !== deletedPost.id
})
},
handleTab(tab) {
this.tabActive = tab
this.filter = tabToFilterMapping({ tab, id: this.$route.params.id })
this.resetPostList()
if (this.tabActive !== tab) {
this.tabActive = tab
this.filter = tabToFilterMapping({ tab, id: this.$route.params.id })
this.resetPostList()
}
},
uniq(items, field = 'id') {
return uniqBy(items, field)
@ -321,6 +303,10 @@ export default {
this.posts = []
this.hasMore = true
},
refetchPostList() {
this.resetPostList()
this.$apollo.queries.profilePagePosts.refetch()
},
async muteUser(user) {
try {
await this.$apollo.mutate({ mutation: muteUser(), variables: { id: user.id } })
@ -369,32 +355,6 @@ export default {
},
})
},
pinPost(post) {
this.$apollo
.mutate({
mutation: PostMutations().pinPost,
variables: { id: post.id },
})
.then(() => {
this.$toast.success(this.$t('post.menu.pinnedSuccessfully'))
this.resetPostList()
this.$apollo.queries.profilePagePosts.refetch()
})
.catch((error) => this.$toast.error(error.message))
},
unpinPost(post) {
this.$apollo
.mutate({
mutation: PostMutations().unpinPost,
variables: { id: post.id },
})
.then(() => {
this.$toast.success(this.$t('post.menu.unpinnedSuccessfully'))
this.resetPostList()
this.$apollo.queries.profilePagePosts.refetch()
})
.catch((error) => this.$toast.error(error.message))
},
optimisticFollow({ followedByCurrentUser }) {
/*
* Note: followedByCountStartValue is updated to avoid counting from 0 when follow/unfollow
@ -457,33 +417,6 @@ export default {
</script>
<style lang="scss">
.pointer {
cursor: pointer;
}
.Tabs {
position: relative;
background-color: #fff;
height: 100%;
display: flex;
margin: 0;
padding: 0;
list-style: none;
&__tab {
text-align: center;
height: 100%;
flex-grow: 1;
&:hover {
border-bottom: 2px solid #c9c6ce;
}
&.active {
border-bottom: 2px solid #17b53f;
}
}
}
.profile-avatar.user-avatar {
margin: auto;
margin-top: -60px;
@ -495,26 +428,6 @@ export default {
right: $space-x-small;
}
}
.profile-top-navigation {
position: sticky;
top: 53px;
z-index: 2;
}
.ds-tab-nav.base-card {
padding: 0;
.ds-tab-nav-item {
&.ds-tab-nav-item-active {
border-bottom: 3px solid #17b53f;
&:first-child {
border-bottom-left-radius: $border-radius-x-large;
}
&:last-child {
border-bottom-right-radius: $border-radius-x-large;
}
}
}
}
.profile-post-add-button {
box-shadow: $box-shadow-x-large;
}

View File

@ -0,0 +1,24 @@
<template>
<search-results :search="search" />
</template>
<script>
import SearchResults from '~/components/_new/features/SearchResults/SearchResults'
export default {
layout: 'default',
watchQuery: ['search'],
head() {
return {
title: this.$t('search.title'),
}
},
components: {
SearchResults,
},
asyncData(context) {
const { search = null } = context.query
return { search }
},
}
</script>

17
webapp/store/search.js Normal file
View File

@ -0,0 +1,17 @@
export const state = () => {
return {
searchValue: '',
}
}
export const mutations = {
SET_VALUE(state, ctx) {
state.searchValue = ctx.searchValue || ''
},
}
export const getters = {
searchValue(state) {
return state.searchValue
},
}

View File

@ -70,6 +70,20 @@ const helpers = {
}
})
},
fakePost(n) {
return new Array(n || 1).fill(0).map(() => {
const title = faker.lorem.words()
const content = faker.lorem.paragraph()
return {
id: faker.random.uuid(),
title,
content,
slug: faker.lorem.slug(title),
shoutedCount: faker.random.number(),
commentsCount: faker.random.number(),
}
})
},
}
export default helpers