mirror of
https://github.com/Ocelot-Social-Community/Ocelot-Social.git
synced 2025-12-13 07:46:06 +00:00
Merge branch 'master' of https://github.com/Ocelot-Social-Community/Ocelot-Social into 4124-switch-user-role
This commit is contained in:
commit
567758bc56
9
.github/ISSUE_TEMPLATE/bug_report.md
vendored
9
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@ -8,7 +8,6 @@ title: 🐛 [Bug]
|
||||
## :bug: Bugreport
|
||||
<!-- Describe your issue in detail. Include screenshots if needed. Give us as much information as possible. Use a clear and concise description of what the bug is.-->
|
||||
|
||||
|
||||
### Steps to reproduce the behavior
|
||||
1.
|
||||
2.
|
||||
@ -16,17 +15,11 @@ title: 🐛 [Bug]
|
||||
4. ...
|
||||
5. Profit
|
||||
|
||||
|
||||
### Expected behavior
|
||||
<!-- A clear and concise description of what you expected to happen. -->
|
||||
|
||||
|
||||
### Version & Environment
|
||||
Type: [] <!-- [Desktop|Smartphone] -->
|
||||
- OS: [] <!-- [e.g. iOS8.1 or Windows] -->
|
||||
- Browser: [] <!-- [e.g. stock browser, safari, chrome] -->
|
||||
- Version [] <!-- [e.g. 22] -->
|
||||
- Device: [] <!-- [e.g. iPhone6] -->
|
||||
<!-- Add context about your environment and used version here. -->
|
||||
|
||||
### Additional context
|
||||
<!-- Add any other context about the problem here. -->
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/devops_ticket.md
vendored
2
.github/ISSUE_TEMPLATE/devops_ticket.md
vendored
@ -5,7 +5,7 @@ labels: devops
|
||||
title: 💥 [DevOps]
|
||||
---
|
||||
|
||||
## :fire: DevOps ticket
|
||||
## 💥 DevOps ticket
|
||||
<!-- Describe your issue in detail. Include screenshots if needed. Give us as much information as possible. Use a clear and concise description of what the problem is.-->
|
||||
|
||||
### Motive
|
||||
|
||||
1
.github/ISSUE_TEMPLATE/refactor_tickets.md
vendored
1
.github/ISSUE_TEMPLATE/refactor_tickets.md
vendored
@ -10,6 +10,7 @@ title: 🔧 [Refactor]
|
||||
|
||||
### Motive
|
||||
<!-- What is the purpose of this refactoring? If it's removing depcrecated code, please link to the deprecation notice. -->
|
||||
|
||||
### Related issues
|
||||
<!-- Are there any related issues to link to? Please paste them below for reference. -->
|
||||
|
||||
|
||||
2
.github/semantic.yml
vendored
2
.github/semantic.yml
vendored
@ -1,2 +0,0 @@
|
||||
# Always validate the PR title, and ignore the commits
|
||||
titleOnly: true
|
||||
18
.github/stale-disabled.yml
vendored
18
.github/stale-disabled.yml
vendored
@ -1,18 +0,0 @@
|
||||
# Number of days of inactivity before an issue becomes stale
|
||||
daysUntilStale: 60
|
||||
# Number of days of inactivity before a stale issue is closed
|
||||
daysUntilClose: 30
|
||||
# Issues with these labels will never be considered stale
|
||||
exemptLabels:
|
||||
- pinned
|
||||
- security
|
||||
- bounty
|
||||
# Label to use when marking an issue as stale
|
||||
staleLabel: stale
|
||||
# Comment to post when marking an issue as stale. Set to `false` to disable
|
||||
markComment: >
|
||||
This issue has been automatically marked as stale because it has not had
|
||||
recent activity. It will be closed if no further activity occurs. Thank you
|
||||
for your contributions.
|
||||
# Comment to post when closing a stale issue. Set to `false` to disable
|
||||
closeComment: false
|
||||
46
.github/workflows/ci.yml
vendored
46
.github/workflows/ci.yml
vendored
@ -1,46 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Continuous Integration
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Check translation files
|
||||
run: |
|
||||
scripts/translations/sort.sh
|
||||
scripts/translations/missing-keys.sh
|
||||
|
||||
- name: Build neo4j image
|
||||
uses: docker/build-push-action@v1.1.0
|
||||
with:
|
||||
repository: ocelotsocialnetwork/neo4j
|
||||
tags: latest
|
||||
path: neo4j/
|
||||
push: false
|
||||
- name: Build backend base image
|
||||
uses: docker/build-push-action@v1.1.0
|
||||
with:
|
||||
repository: ocelotsocialnetwork/backend
|
||||
tags: build-and-test
|
||||
target: build-and-test
|
||||
path: backend/
|
||||
push: false
|
||||
- name: Build webapp base image
|
||||
uses: docker/build-push-action@v1.1.0
|
||||
with:
|
||||
repository: ocelotsocialnetwork/webapp
|
||||
tags: build-and-test
|
||||
target: build-and-test
|
||||
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
|
||||
|
||||
308
.github/workflows/publish.yml
vendored
Normal file
308
.github/workflows/publish.yml
vendored
Normal file
@ -0,0 +1,308 @@
|
||||
name: ocelot.social publish CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
##############################################################################
|
||||
# JOB: PREPARE ###############################################################
|
||||
##############################################################################
|
||||
prepare:
|
||||
name: Prepare
|
||||
runs-on: ubuntu-latest
|
||||
# needs: [nothing]
|
||||
steps:
|
||||
##########################################################################
|
||||
# CHECKOUT CODE ##########################################################
|
||||
##########################################################################
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
##########################################################################
|
||||
# TODO: DO STUFF ??? #####################################################
|
||||
##########################################################################
|
||||
- name: Check translation files
|
||||
run: |
|
||||
scripts/translations/sort.sh
|
||||
scripts/translations/missing-keys.sh
|
||||
|
||||
##############################################################################
|
||||
# JOB: DOCKER BUILD COMMUNITY NEO4J ##########################################
|
||||
##############################################################################
|
||||
build_production_neo4j:
|
||||
name: Docker Build Production - Neo4J
|
||||
runs-on: ubuntu-latest
|
||||
needs: [prepare]
|
||||
steps:
|
||||
##########################################################################
|
||||
# CHECKOUT CODE ##########################################################
|
||||
##########################################################################
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
##########################################################################
|
||||
# SET ENVS ###############################################################
|
||||
##########################################################################
|
||||
- name: ENV - VERSION
|
||||
run: echo "VERSION=$(node -p -e "require('./package.json').version")" >> $GITHUB_ENV
|
||||
- name: ENV - BUILD_DATE
|
||||
run: echo "BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_ENV
|
||||
- name: ENV - BUILD_VERSION
|
||||
run: echo "BUILD_VERSION=${VERSION}.${GITHUB_RUN_NUMBER}" >> $GITHUB_ENV
|
||||
- name: ENV - BUILD_COMMIT
|
||||
run: echo "BUILD_COMMIT=${GITHUB_SHA}" >> $GITHUB_ENV
|
||||
##########################################################################
|
||||
# NEO4J ##################################################################
|
||||
##########################################################################
|
||||
- name: Neo4J | Build `community` image
|
||||
run: |
|
||||
docker build --target community -t "ocelotsocialnetwork/neo4j:community" -t "ocelotsocialnetwork/neo4j:${VERSION}" -t "ocelotsocialnetwork/neo4j:${BUILD_VERSION}" neo4j/
|
||||
docker save "ocelotsocialnetwork/neo4j" > /tmp/neo4j.tar
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: docker-neo4j-community
|
||||
path: /tmp/neo4j.tar
|
||||
|
||||
##############################################################################
|
||||
# JOB: DOCKER BUILD Production BACKEND #######################################
|
||||
##############################################################################
|
||||
build_production_backend:
|
||||
name: Docker Build Production - Backend
|
||||
runs-on: ubuntu-latest
|
||||
needs: [prepare]
|
||||
steps:
|
||||
##########################################################################
|
||||
# CHECKOUT CODE ##########################################################
|
||||
##########################################################################
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
##########################################################################
|
||||
# SET ENVS ###############################################################
|
||||
##########################################################################
|
||||
- name: ENV - VERSION
|
||||
run: echo "VERSION=$(node -p -e "require('./package.json').version")" >> $GITHUB_ENV
|
||||
- name: ENV - BUILD_DATE
|
||||
run: echo "BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_ENV
|
||||
- name: ENV - BUILD_VERSION
|
||||
run: echo "BUILD_VERSION=${VERSION}.${GITHUB_RUN_NUMBER}" >> $GITHUB_ENV
|
||||
- name: ENV - BUILD_COMMIT
|
||||
run: echo "BUILD_COMMIT=${GITHUB_SHA}" >> $GITHUB_ENV
|
||||
##########################################################################
|
||||
# BUILD BACKEND DOCKER IMAGE (production) ################################
|
||||
##########################################################################
|
||||
- name: backend | Build `production` image
|
||||
run: |
|
||||
docker build --target production -t "ocelotsocialnetwork/backend:latest" -t "ocelotsocialnetwork/backend:${VERSION}" -t "ocelotsocialnetwork/backend:${BUILD_VERSION}" backend/
|
||||
docker save "ocelotsocialnetwork/backend" > /tmp/backend.tar
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: docker-backend-production
|
||||
path: /tmp/backend.tar
|
||||
|
||||
##############################################################################
|
||||
# JOB: DOCKER BUILD PRODUCTION WEBAPP ########################################
|
||||
##############################################################################
|
||||
build_production_webapp:
|
||||
name: Docker Build Production - WebApp
|
||||
runs-on: ubuntu-latest
|
||||
needs: [prepare]
|
||||
steps:
|
||||
##########################################################################
|
||||
# CHECKOUT CODE ##########################################################
|
||||
##########################################################################
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
##########################################################################
|
||||
# SET ENVS ###############################################################
|
||||
##########################################################################
|
||||
- name: ENV - VERSION
|
||||
run: echo "VERSION=$(node -p -e "require('./package.json').version")" >> $GITHUB_ENV
|
||||
- name: ENV - BUILD_DATE
|
||||
run: echo "BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_ENV
|
||||
- name: ENV - BUILD_VERSION
|
||||
run: echo "BUILD_VERSION=${VERSION}.${GITHUB_RUN_NUMBER}" >> $GITHUB_ENV
|
||||
- name: ENV - BUILD_COMMIT
|
||||
run: echo "BUILD_COMMIT=${GITHUB_SHA}" >> $GITHUB_ENV
|
||||
##########################################################################
|
||||
# BUILD WEBAPP DOCKER IMAGE (build) ######################################
|
||||
##########################################################################
|
||||
- name: webapp | Build `production` image
|
||||
run: |
|
||||
docker build --target production -t "ocelotsocialnetwork/webapp:latest" -t "ocelotsocialnetwork/webapp:${VERSION}" -t "ocelotsocialnetwork/webapp:${BUILD_VERSION}" webapp/
|
||||
docker save "ocelotsocialnetwork/webapp" > /tmp/webapp.tar
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: docker-webapp-production
|
||||
path: /tmp/webapp.tar
|
||||
|
||||
##############################################################################
|
||||
# JOB: DOCKER BUILD PRODUCTION MAINTENANCE ###################################
|
||||
##############################################################################
|
||||
build_production_maintenance:
|
||||
name: Docker Build Production - Maintenance
|
||||
runs-on: ubuntu-latest
|
||||
needs: [prepare]
|
||||
steps:
|
||||
##########################################################################
|
||||
# CHECKOUT CODE ##########################################################
|
||||
##########################################################################
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
##########################################################################
|
||||
# SET ENVS ###############################################################
|
||||
##########################################################################
|
||||
- name: ENV - VERSION
|
||||
run: echo "VERSION=$(node -p -e "require('./package.json').version")" >> $GITHUB_ENV
|
||||
- name: ENV - BUILD_DATE
|
||||
run: echo "BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_ENV
|
||||
- name: ENV - BUILD_VERSION
|
||||
run: echo "BUILD_VERSION=${VERSION}.${GITHUB_RUN_NUMBER}" >> $GITHUB_ENV
|
||||
- name: ENV - BUILD_COMMIT
|
||||
run: echo "BUILD_COMMIT=${GITHUB_SHA}" >> $GITHUB_ENV
|
||||
##########################################################################
|
||||
# BUILD MAINTENANCE DOCKER IMAGE (build) #################################
|
||||
##########################################################################
|
||||
- name: maintenance | Build `production` image
|
||||
# TODO: --target production
|
||||
run: |
|
||||
docker build -t "ocelotsocialnetwork/maintenance:latest" -t "ocelotsocialnetwork/maintenance:${VERSION}" -t "ocelotsocialnetwork/maintenance:${BUILD_VERSION}" webapp/ -f webapp/Dockerfile.maintenance
|
||||
docker save "ocelotsocialnetwork/maintenance" > /tmp/maintenance.tar
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: docker-maintenance-production
|
||||
path: /tmp/maintenance.tar
|
||||
|
||||
##############################################################################
|
||||
# JOB: UPLOAD TO DOCKERHUB ###################################################
|
||||
##############################################################################
|
||||
upload_to_dockerhub:
|
||||
name: Upload to Dockerhub
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build_production_neo4j,build_production_backend,build_production_webapp,build_production_maintenance]
|
||||
env:
|
||||
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
steps:
|
||||
##########################################################################
|
||||
# CHECKOUT CODE ##########################################################
|
||||
##########################################################################
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
##########################################################################
|
||||
# DOWNLOAD DOCKER IMAGES #################################################
|
||||
##########################################################################
|
||||
- name: Download Docker Image (Neo4J)
|
||||
uses: actions/download-artifact@v2
|
||||
with:
|
||||
name: docker-neo4j-community
|
||||
path: /tmp
|
||||
- name: Load Docker Image
|
||||
run: docker load < /tmp/neo4j.tar
|
||||
- name: Download Docker Image (Backend)
|
||||
uses: actions/download-artifact@v2
|
||||
with:
|
||||
name: docker-backend-production
|
||||
path: /tmp
|
||||
- name: Load Docker Image
|
||||
run: docker load < /tmp/backend.tar
|
||||
- name: Download Docker Image (WebApp)
|
||||
uses: actions/download-artifact@v2
|
||||
with:
|
||||
name: docker-webapp-production
|
||||
path: /tmp
|
||||
- name: Load Docker Image
|
||||
run: docker load < /tmp/webapp.tar
|
||||
- name: Download Docker Image (Maintenance)
|
||||
uses: actions/download-artifact@v2
|
||||
with:
|
||||
name: docker-maintenance-production
|
||||
path: /tmp
|
||||
- name: Load Docker Image
|
||||
run: docker load < /tmp/maintenance.tar
|
||||
##########################################################################
|
||||
# Upload #################################################################
|
||||
##########################################################################
|
||||
- name: login to dockerhub
|
||||
run: echo "${DOCKERHUB_TOKEN}" | docker login -u "${DOCKERHUB_USERNAME}" --password-stdin
|
||||
- name: Push neo4j
|
||||
# TODO: at some point --all-tags will be needed -.-
|
||||
run: docker push ocelotsocialnetwork/neo4j
|
||||
- name: Push backend
|
||||
run: docker push ocelotsocialnetwork/backend
|
||||
- name: Push webapp
|
||||
run: docker push ocelotsocialnetwork/webapp
|
||||
- name: Push maintenance
|
||||
run: docker push ocelotsocialnetwork/maintenance
|
||||
|
||||
##############################################################################
|
||||
# JOB: GITHUB TAG LATEST VERSION #############################################
|
||||
##############################################################################
|
||||
github_tag:
|
||||
name: Tag latest version on Github
|
||||
runs-on: ubuntu-latest
|
||||
needs: [upload_to_dockerhub]
|
||||
steps:
|
||||
##########################################################################
|
||||
# CHECKOUT CODE ##########################################################
|
||||
##########################################################################
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0 # Fetch full History for changelog
|
||||
##########################################################################
|
||||
# SET ENVS ###############################################################
|
||||
##########################################################################
|
||||
- name: ENV - VERSION
|
||||
run: echo "VERSION=$(node -p -e "require('./package.json').version")" >> $GITHUB_ENV
|
||||
- name: ENV - BUILD_DATE
|
||||
run: echo "BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_ENV
|
||||
- name: ENV - BUILD_VERSION
|
||||
run: echo "BUILD_VERSION=${VERSION}.${GITHUB_RUN_NUMBER}" >> $GITHUB_ENV
|
||||
- name: ENV - BUILD_COMMIT
|
||||
run: echo "BUILD_COMMIT=${GITHUB_SHA}" >> $GITHUB_ENV
|
||||
##########################################################################
|
||||
# Push version tag to GitHub #############################################
|
||||
##########################################################################
|
||||
# TODO: this will error on duplicate
|
||||
#- name: package-version-to-git-tag
|
||||
# uses: pkgdeps/git-tag-action@v2
|
||||
# with:
|
||||
# github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
# github_repo: ${{ github.repository }}
|
||||
# version: ${{ env.VERSION }}
|
||||
# git_commit_sha: ${{ github.sha }}
|
||||
# git_tag_prefix: "v"
|
||||
##########################################################################
|
||||
# Push build tag to GitHub ###############################################
|
||||
##########################################################################
|
||||
- name: package-version-to-git-tag + build number
|
||||
uses: pkgdeps/git-tag-action@v2
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
github_repo: ${{ github.repository }}
|
||||
version: ${{ env.BUILD_VERSION }}
|
||||
git_commit_sha: ${{ github.sha }}
|
||||
git_tag_prefix: "b"
|
||||
##########################################################################
|
||||
# Push release tag to GitHub #############################################
|
||||
##########################################################################
|
||||
- name: yarn install
|
||||
run: yarn install
|
||||
- name: generate changelog
|
||||
run: yarn auto-changelog --latest-version ${{ env.VERSION }} --unreleased-only
|
||||
- name: package-version-to-git-release
|
||||
continue-on-error: true # Will fail if tag exists
|
||||
id: create_release
|
||||
uses: actions/create-release@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token
|
||||
with:
|
||||
tag_name: ${{ env.VERSION }}
|
||||
release_name: ${{ env.VERSION }}
|
||||
body_path: ./CHANGELOG.md
|
||||
draft: false
|
||||
prerelease: false
|
||||
244
.github/workflows/test.yml
vendored
Normal file
244
.github/workflows/test.yml
vendored
Normal file
@ -0,0 +1,244 @@
|
||||
name: ocelot.social test CI
|
||||
|
||||
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
##############################################################################
|
||||
# JOB: PREPARE #####################################################
|
||||
##############################################################################
|
||||
prepare:
|
||||
name: Prepare
|
||||
runs-on: ubuntu-latest
|
||||
# needs: [nothing]
|
||||
steps:
|
||||
##########################################################################
|
||||
# CHECKOUT CODE ##########################################################
|
||||
##########################################################################
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
##########################################################################
|
||||
# TODO: DO STUFF ??? #####################################################
|
||||
##########################################################################
|
||||
- name: Check translation files
|
||||
run: |
|
||||
scripts/translations/sort.sh
|
||||
scripts/translations/missing-keys.sh
|
||||
|
||||
##############################################################################
|
||||
# JOB: DOCKER BUILD TEST NEO4J ###############################################
|
||||
##############################################################################
|
||||
build_test_neo4j:
|
||||
name: Docker Build Test - Neo4J
|
||||
runs-on: ubuntu-latest
|
||||
needs: [prepare]
|
||||
steps:
|
||||
##########################################################################
|
||||
# CHECKOUT CODE ##########################################################
|
||||
##########################################################################
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
##########################################################################
|
||||
# NEO4J ##################################################################
|
||||
##########################################################################
|
||||
- name: Neo4J | Build `community` image
|
||||
run: |
|
||||
docker build --target community -t "ocelotsocialnetwork/neo4j:community" neo4j/
|
||||
docker save "ocelotsocialnetwork/neo4j:community" > /tmp/neo4j.tar
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: docker-neo4j-image
|
||||
path: /tmp/neo4j.tar
|
||||
|
||||
##############################################################################
|
||||
# JOB: DOCKER BUILD TEST BACKEND #############################################
|
||||
##############################################################################
|
||||
build_test_backend:
|
||||
name: Docker Build Test - Backend
|
||||
runs-on: ubuntu-latest
|
||||
needs: [prepare]
|
||||
steps:
|
||||
##########################################################################
|
||||
# CHECKOUT CODE ##########################################################
|
||||
##########################################################################
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
##########################################################################
|
||||
# BUILD BACKEND DOCKER IMAGE (build) #####################################
|
||||
##########################################################################
|
||||
- name: backend | Build `test` image
|
||||
run: |
|
||||
docker build --target test -t "ocelotsocialnetwork/backend:test" backend/
|
||||
docker save "ocelotsocialnetwork/backend:test" > /tmp/backend.tar
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: docker-backend-test
|
||||
path: /tmp/backend.tar
|
||||
|
||||
##############################################################################
|
||||
# JOB: DOCKER BUILD TEST WEBAPP ##############################################
|
||||
##############################################################################
|
||||
build_test_webapp:
|
||||
name: Docker Build Test - WebApp
|
||||
runs-on: ubuntu-latest
|
||||
needs: [prepare]
|
||||
steps:
|
||||
##########################################################################
|
||||
# CHECKOUT CODE ##########################################################
|
||||
##########################################################################
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
##########################################################################
|
||||
# BUILD WEBAPP DOCKER IMAGE (build) ######################################
|
||||
##########################################################################
|
||||
- name: webapp | Build `test` image
|
||||
run: |
|
||||
docker build --target test -t "ocelotsocialnetwork/webapp:test" webapp/
|
||||
docker save "ocelotsocialnetwork/webapp:test" > /tmp/webapp.tar
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: docker-webapp-test
|
||||
path: /tmp/webapp.tar
|
||||
|
||||
##############################################################################
|
||||
# JOB: LINT BACKEND ##########################################################
|
||||
##############################################################################
|
||||
lint_backend:
|
||||
name: Lint backend
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build_test_backend]
|
||||
steps:
|
||||
##########################################################################
|
||||
# CHECKOUT CODE ##########################################################
|
||||
##########################################################################
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
##########################################################################
|
||||
# DOWNLOAD DOCKER IMAGE ##################################################
|
||||
##########################################################################
|
||||
- name: Download Docker Image (Backend)
|
||||
uses: actions/download-artifact@v2
|
||||
with:
|
||||
name: docker-backend-test
|
||||
path: /tmp
|
||||
- name: Load Docker Image
|
||||
run: docker load < /tmp/backend.tar
|
||||
##########################################################################
|
||||
# LINT BACKEND ###########################################################
|
||||
##########################################################################
|
||||
- name: backend | Lint
|
||||
run: docker run --rm ocelotsocialnetwork/backend:test yarn run lint
|
||||
|
||||
##############################################################################
|
||||
# JOB: LINT WEBAPP ###########################################################
|
||||
##############################################################################
|
||||
lint_webapp:
|
||||
name: Lint webapp
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build_test_webapp]
|
||||
steps:
|
||||
##########################################################################
|
||||
# CHECKOUT CODE ##########################################################
|
||||
##########################################################################
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
##########################################################################
|
||||
# DOWNLOAD DOCKER IMAGE ##################################################
|
||||
##########################################################################
|
||||
- name: Download Docker Image (Webapp)
|
||||
uses: actions/download-artifact@v2
|
||||
with:
|
||||
name: docker-webapp-test
|
||||
path: /tmp
|
||||
- name: Load Docker Image
|
||||
run: docker load < /tmp/webapp.tar
|
||||
##########################################################################
|
||||
# LINT WEBAPP ############################################################
|
||||
##########################################################################
|
||||
- name: webapp | Lint
|
||||
run: docker run --rm ocelotsocialnetwork/webapp:test yarn run lint
|
||||
|
||||
##############################################################################
|
||||
# JOB: UNIT TEST BACKEND #####################################################
|
||||
##############################################################################
|
||||
unit_test_backend:
|
||||
name: Unit tests - backend
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build_test_neo4j,build_test_backend]
|
||||
steps:
|
||||
##########################################################################
|
||||
# CHECKOUT CODE ##########################################################
|
||||
##########################################################################
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
##########################################################################
|
||||
# DOWNLOAD DOCKER IMAGES #################################################
|
||||
##########################################################################
|
||||
- name: Download Docker Image (Neo4J)
|
||||
uses: actions/download-artifact@v2
|
||||
with:
|
||||
name: docker-neo4j-image
|
||||
path: /tmp
|
||||
- name: Load Docker Image
|
||||
run: docker load < /tmp/neo4j.tar
|
||||
- name: Download Docker Image (Backend)
|
||||
uses: actions/download-artifact@v2
|
||||
with:
|
||||
name: docker-backend-test
|
||||
path: /tmp
|
||||
- name: Load Docker Image
|
||||
run: docker load < /tmp/backend.tar
|
||||
##########################################################################
|
||||
# UNIT TESTS BACKEND #####################################################
|
||||
##########################################################################
|
||||
# TODO: Why do we need those .envs?
|
||||
- name: backend | copy env files webapp
|
||||
run: cp webapp/.env.template webapp/.env
|
||||
- name: backend | copy env files backend
|
||||
run: cp backend/.env.template backend/.env
|
||||
- name: backend | docker-compose
|
||||
run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps neo4j backend
|
||||
- name: backend | Initialize Database
|
||||
run: docker-compose exec -T backend yarn db:migrate init
|
||||
- name: backend | Unit test
|
||||
run: docker-compose exec -T backend yarn test
|
||||
|
||||
##############################################################################
|
||||
# JOB: UNIT TEST WEBAPP ######################################################
|
||||
##############################################################################
|
||||
unit_test_webapp:
|
||||
name: Unit tests - webapp
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build_test_webapp]
|
||||
steps:
|
||||
##########################################################################
|
||||
# CHECKOUT CODE ##########################################################
|
||||
##########################################################################
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
##########################################################################
|
||||
# DOWNLOAD DOCKER IMAGES #################################################
|
||||
##########################################################################
|
||||
- name: Download Docker Image (Webapp)
|
||||
uses: actions/download-artifact@v2
|
||||
with:
|
||||
name: docker-webapp-test
|
||||
path: /tmp
|
||||
- name: Load Docker Image
|
||||
run: docker load < /tmp/webapp.tar
|
||||
##########################################################################
|
||||
# UNIT TESTS WEBAPP #####################################################
|
||||
##########################################################################
|
||||
# TODO: Why do we need those .envs?
|
||||
- name: backend | copy env files webapp
|
||||
run: cp webapp/.env.template webapp/.env
|
||||
- name: backend | copy env files backend
|
||||
run: cp backend/.env.template backend/.env
|
||||
- name: backend | docker-compose
|
||||
run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps webapp
|
||||
- name: webapp | Unit tests
|
||||
#run: docker run --rm ocelotsocialnetwork/webapp:build yarn run test
|
||||
run: docker-compose exec -T webapp yarn test
|
||||
18779
CHANGELOG.md
18779
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
100
README.md
100
README.md
@ -1,4 +1,4 @@
|
||||
# Human-Connection
|
||||
# ocelot.social
|
||||
|
||||
[](https://travis-ci.com/Human-Connection/Human-Connection)
|
||||
[](https://codecov.io/gh/Human-Connection/Human-Connection/)
|
||||
@ -6,22 +6,13 @@
|
||||
[](https://discordapp.com/invite/DFSjPaX)
|
||||
[](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**.
|
||||
|
||||
[](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/)
|
||||
|
||||
[](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/links/0)[](https://sourcerer.io/fame/roschaefer/Human-Connection/Human-Connection/links/1)[](https://sourcerer.io/fame/roschaefer/Human-Connection/Human-Connection/links/2)[](https://sourcerer.io/fame/roschaefer/Human-Connection/Human-Connection/links/3)[](https://sourcerer.io/fame/roschaefer/Human-Connection/Human-Connection/links/4)[](https://sourcerer.io/fame/roschaefer/Human-Connection/Human-Connection/links/5)[](https://sourcerer.io/fame/roschaefer/Human-Connection/Human-Connection/links/6)[](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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -1,28 +1,94 @@
|
||||
##################################################################################
|
||||
# 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).${BUILD_NUMBER} here so we default to 0.0.0.0
|
||||
ENV BUILD_VERSION="0.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
|
||||
##################################################################################
|
||||
# TEST ###########################################################################
|
||||
##################################################################################
|
||||
FROM build as test
|
||||
|
||||
# Run command
|
||||
CMD /bin/sh -c "yarn run dev"
|
||||
|
||||
##################################################################################
|
||||
# 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"
|
||||
@ -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 %}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ocelot-social-backend",
|
||||
"version": "0.6.3",
|
||||
"version": "0.6.5",
|
||||
"description": "GraphQL Backend for ocelot.social",
|
||||
"repository": "https://github.com/Ocelot-Social-Community/Ocelot-Social",
|
||||
"author": "ocelot.social Community",
|
||||
@ -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",
|
||||
@ -69,6 +69,7 @@
|
||||
"helmet": "~3.22.0",
|
||||
"ioredis": "^4.16.1",
|
||||
"jsonwebtoken": "~8.5.1",
|
||||
"languagedetect": "^2.0.0",
|
||||
"linkifyjs": "~2.1.8",
|
||||
"lodash": "~4.17.14",
|
||||
"merge-graphql-schemas": "^1.7.7",
|
||||
|
||||
@ -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}`,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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!`)
|
||||
}
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@ import { hashSync } from 'bcryptjs'
|
||||
import { Factory } from 'rosie'
|
||||
import { getDriver, getNeode } from './neo4j'
|
||||
import CONFIG from '../config/index.js'
|
||||
import generateInviteCode from '../schema/resolvers/helpers/generateInviteCode.js'
|
||||
|
||||
const neode = getNeode()
|
||||
|
||||
@ -48,8 +49,9 @@ Factory.define('badge')
|
||||
|
||||
Factory.define('image')
|
||||
.attr('url', faker.image.unsplash.imageUrl)
|
||||
.attr('aspectRatio', 1)
|
||||
.attr('aspectRatio', 1.3333333333333333)
|
||||
.attr('alt', faker.lorem.sentence)
|
||||
.attr('type', 'image/jpeg')
|
||||
.after((buildObject, options) => {
|
||||
const { url: imageUrl } = buildObject
|
||||
if (imageUrl) buildObject.url = uniqueImageUrl(imageUrl)
|
||||
@ -205,7 +207,7 @@ const emailDefaults = {
|
||||
}
|
||||
|
||||
Factory.define('emailAddress')
|
||||
.attr(emailDefaults)
|
||||
.attrs(emailDefaults)
|
||||
.after((buildObject, options) => {
|
||||
return neode.create('EmailAddress', buildObject)
|
||||
})
|
||||
@ -216,6 +218,28 @@ Factory.define('unverifiedEmailAddress')
|
||||
return neode.create('UnverifiedEmailAddress', buildObject)
|
||||
})
|
||||
|
||||
const inviteCodeDefaults = {
|
||||
code: () => generateInviteCode(),
|
||||
createdAt: () => new Date().toISOString(),
|
||||
expiresAt: () => null,
|
||||
}
|
||||
|
||||
Factory.define('inviteCode')
|
||||
.attrs(inviteCodeDefaults)
|
||||
.option('generatedById', null)
|
||||
.option('generatedBy', ['generatedById'], (generatedById) => {
|
||||
if (generatedById) return neode.find('User', generatedById)
|
||||
return Factory.build('user')
|
||||
})
|
||||
.after(async (buildObject, options) => {
|
||||
const [inviteCode, generatedBy] = await Promise.all([
|
||||
neode.create('InviteCode', buildObject),
|
||||
options.generatedBy,
|
||||
])
|
||||
await Promise.all([inviteCode.relateTo(generatedBy, 'generated')])
|
||||
return inviteCode
|
||||
})
|
||||
|
||||
Factory.define('location')
|
||||
.attrs({
|
||||
name: 'Germany',
|
||||
|
||||
@ -541,6 +541,16 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
|
||||
),
|
||||
])
|
||||
|
||||
await Factory.build(
|
||||
'inviteCode',
|
||||
{
|
||||
code: 'AAAAAA',
|
||||
},
|
||||
{
|
||||
generatedBy: jennyRostock,
|
||||
},
|
||||
)
|
||||
|
||||
authenticatedUser = await louie.toJson()
|
||||
const mention1 =
|
||||
'Hey <a class="mention" data-mention-id="u3" href="/profile/u3">@jenny-rostock</a>, what\'s up?'
|
||||
@ -931,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')
|
||||
@ -938,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(
|
||||
|
||||
@ -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.')
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@ import notifications from './notifications/notificationsMiddleware'
|
||||
import hashtags from './hashtags/hashtagsMiddleware'
|
||||
import email from './email/emailMiddleware'
|
||||
import sentry from './sentryMiddleware'
|
||||
import languages from './languages/languages'
|
||||
|
||||
export default (schema) => {
|
||||
const middlewares = {
|
||||
@ -30,6 +31,7 @@ export default (schema) => {
|
||||
softDelete,
|
||||
includedFields,
|
||||
orderBy,
|
||||
languages,
|
||||
}
|
||||
|
||||
let order = [
|
||||
@ -39,6 +41,7 @@ export default (schema) => {
|
||||
// 'activityPub', disabled temporarily
|
||||
'validation',
|
||||
'sluggify',
|
||||
'languages',
|
||||
'excerpt',
|
||||
'email',
|
||||
'notifications',
|
||||
|
||||
28
backend/src/middleware/languages/languages.js
Normal file
28
backend/src/middleware/languages/languages.js
Normal file
@ -0,0 +1,28 @@
|
||||
import LanguageDetect from 'languagedetect'
|
||||
import sanitizeHtml from 'sanitize-html'
|
||||
|
||||
const removeHtmlTags = (input) => {
|
||||
return sanitizeHtml(input, {
|
||||
allowedTags: [],
|
||||
allowedAttributes: {},
|
||||
})
|
||||
}
|
||||
|
||||
const setPostLanguage = (text) => {
|
||||
const lngDetector = new LanguageDetect()
|
||||
lngDetector.setLanguageType('iso2')
|
||||
return lngDetector.detect(removeHtmlTags(text), 1)[0][0]
|
||||
}
|
||||
|
||||
export default {
|
||||
Mutation: {
|
||||
CreatePost: async (resolve, root, args, context, info) => {
|
||||
args.language = await setPostLanguage(args.content)
|
||||
return resolve(root, args, context, info)
|
||||
},
|
||||
UpdatePost: async (resolve, root, args, context, info) => {
|
||||
args.language = await setPostLanguage(args.content)
|
||||
return resolve(root, args, context, info)
|
||||
},
|
||||
},
|
||||
}
|
||||
132
backend/src/middleware/languages/languages.spec.js
Normal file
132
backend/src/middleware/languages/languages.spec.js
Normal file
@ -0,0 +1,132 @@
|
||||
import Factory, { cleanDatabase } from '../../db/factories'
|
||||
import { gql } from '../../helpers/jest'
|
||||
import { getNeode, getDriver } from '../../db/neo4j'
|
||||
import createServer from '../../server'
|
||||
import { createTestClient } from 'apollo-server-testing'
|
||||
|
||||
let mutate
|
||||
let authenticatedUser
|
||||
let variables
|
||||
|
||||
const driver = getDriver()
|
||||
const neode = getNeode()
|
||||
|
||||
beforeAll(async () => {
|
||||
const { server } = createServer({
|
||||
context: () => {
|
||||
return {
|
||||
driver,
|
||||
neode,
|
||||
user: authenticatedUser,
|
||||
}
|
||||
},
|
||||
})
|
||||
mutate = createTestClient(server).mutate
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanDatabase()
|
||||
})
|
||||
|
||||
const createPostMutation = gql`
|
||||
mutation($title: String!, $content: String!, $categoryIds: [ID]) {
|
||||
CreatePost(title: $title, content: $content, categoryIds: $categoryIds) {
|
||||
language
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
describe('languagesMiddleware', () => {
|
||||
variables = {
|
||||
title: 'Test post languages',
|
||||
categoryIds: ['cat9'],
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await cleanDatabase()
|
||||
const user = await Factory.build('user')
|
||||
authenticatedUser = await user.toJson()
|
||||
await Factory.build('category', {
|
||||
id: 'cat9',
|
||||
name: 'Democracy & Politics',
|
||||
icon: 'university',
|
||||
})
|
||||
})
|
||||
|
||||
it('detects German', async () => {
|
||||
variables = {
|
||||
...variables,
|
||||
content: 'Jeder sollte vor seiner eigenen Tür kehren.',
|
||||
}
|
||||
await expect(
|
||||
mutate({
|
||||
mutation: createPostMutation,
|
||||
variables,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
data: {
|
||||
CreatePost: {
|
||||
language: 'de',
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('detects English', async () => {
|
||||
variables = {
|
||||
...variables,
|
||||
content: 'A journey of a thousand miles begins with a single step.',
|
||||
}
|
||||
await expect(
|
||||
mutate({
|
||||
mutation: createPostMutation,
|
||||
variables,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
data: {
|
||||
CreatePost: {
|
||||
language: 'en',
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('detects Spanish', async () => {
|
||||
variables = {
|
||||
...variables,
|
||||
content: 'A caballo regalado, no le mires el diente.',
|
||||
}
|
||||
await expect(
|
||||
mutate({
|
||||
mutation: createPostMutation,
|
||||
variables,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
data: {
|
||||
CreatePost: {
|
||||
language: 'es',
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('detects German in between lots of html tags', async () => {
|
||||
variables = {
|
||||
...variables,
|
||||
content:
|
||||
'<strong>Jeder</strong> <strike>sollte</strike> <strong>vor</strong> <span>seiner</span> eigenen <blockquote>Tür</blockquote> kehren.',
|
||||
}
|
||||
await expect(
|
||||
mutate({
|
||||
mutation: createPostMutation,
|
||||
variables,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
data: {
|
||||
CreatePost: {
|
||||
language: 'de',
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
@ -106,6 +119,9 @@ export default shield(
|
||||
notifications: isAuthenticated,
|
||||
Donations: isAuthenticated,
|
||||
userData: isAuthenticated,
|
||||
MyInviteCodes: isAuthenticated,
|
||||
isValidInviteCode: allow,
|
||||
queryLocations: isAuthenticated,
|
||||
},
|
||||
Mutation: {
|
||||
'*': deny,
|
||||
@ -149,6 +165,7 @@ export default shield(
|
||||
pinPost: isAdmin,
|
||||
unpinPost: isAdmin,
|
||||
UpdateDonations: isAdmin,
|
||||
GenerateInviteCode: isAuthenticated,
|
||||
switchUserRole: isAdmin,
|
||||
},
|
||||
User: {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -2,6 +2,7 @@ import slugify from 'slug'
|
||||
export default async function uniqueSlug(string, isUnique) {
|
||||
const slug = slugify(string || 'anonymous', {
|
||||
lower: true,
|
||||
multicharmap: { Ä: 'AE', ä: 'ae', Ö: 'OE', ö: 'oe', Ü: 'UE', ü: 'ue', ß: 'ss' },
|
||||
})
|
||||
if (await isUnique(slug)) return slug
|
||||
|
||||
|
||||
@ -18,4 +18,16 @@ describe('uniqueSlug', () => {
|
||||
const isUnique = jest.fn().mockResolvedValue(true)
|
||||
expect(uniqueSlug(string, isUnique)).resolves.toEqual('anonymous')
|
||||
})
|
||||
|
||||
it('Converts umlaut to a two letter equivalent', async () => {
|
||||
const umlaut = 'ÄÖÜäöüß'
|
||||
const isUnique = jest.fn().mockResolvedValue(true)
|
||||
await expect(uniqueSlug(umlaut, isUnique)).resolves.toEqual('aeoeueaeoeuess')
|
||||
})
|
||||
|
||||
it('Removes Spanish enya and diacritics', async () => {
|
||||
const diacritics = 'áàéèíìóòúùñçÁÀÉÈÍÌÓÒÚÙÑÇ'
|
||||
const isUnique = jest.fn().mockResolvedValue(true)
|
||||
await expect(uniqueSlug(diacritics, isUnique)).resolves.toEqual('aaeeiioouuncaaeeiioouunc')
|
||||
})
|
||||
})
|
||||
|
||||
@ -2,8 +2,6 @@ import { UserInputError } from 'apollo-server'
|
||||
|
||||
const COMMENT_MIN_LENGTH = 1
|
||||
const NO_POST_ERR_MESSAGE = 'Comment cannot be created without a post!'
|
||||
const NO_CATEGORIES_ERR_MESSAGE =
|
||||
'You cannot save a post without at least one category or more than three'
|
||||
const USERNAME_MIN_LENGTH = 3
|
||||
const validateCreateComment = async (resolve, root, args, context, info) => {
|
||||
const content = args.content.replace(/<(?:.|\n)*?>/gm, '').trim()
|
||||
@ -46,20 +44,6 @@ const validateUpdateComment = async (resolve, root, args, context, info) => {
|
||||
return resolve(root, args, context, info)
|
||||
}
|
||||
|
||||
const validatePost = async (resolve, root, args, context, info) => {
|
||||
const { categoryIds } = args
|
||||
if (!Array.isArray(categoryIds) || !categoryIds.length || categoryIds.length > 3) {
|
||||
throw new UserInputError(NO_CATEGORIES_ERR_MESSAGE)
|
||||
}
|
||||
return resolve(root, args, context, info)
|
||||
}
|
||||
|
||||
const validateUpdatePost = async (resolve, root, args, context, info) => {
|
||||
const { categoryIds } = args
|
||||
if (typeof categoryIds === 'undefined') return resolve(root, args, context, info)
|
||||
return validatePost(resolve, root, args, context, info)
|
||||
}
|
||||
|
||||
const validateReport = async (resolve, root, args, context, info) => {
|
||||
const { resourceId } = args
|
||||
const { user } = context
|
||||
@ -138,8 +122,6 @@ export default {
|
||||
Mutation: {
|
||||
CreateComment: validateCreateComment,
|
||||
UpdateComment: validateUpdateComment,
|
||||
CreatePost: validatePost,
|
||||
UpdatePost: validateUpdatePost,
|
||||
UpdateUser: validateUpdateUser,
|
||||
fileReport: validateReport,
|
||||
review: validateReview,
|
||||
|
||||
@ -30,27 +30,7 @@ const updateCommentMutation = gql`
|
||||
}
|
||||
}
|
||||
`
|
||||
const createPostMutation = gql`
|
||||
mutation($id: ID, $title: String!, $content: String!, $language: String, $categoryIds: [ID]) {
|
||||
CreatePost(
|
||||
id: $id
|
||||
title: $title
|
||||
content: $content
|
||||
language: $language
|
||||
categoryIds: $categoryIds
|
||||
) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const updatePostMutation = gql`
|
||||
mutation($id: ID!, $title: String!, $content: String!, $categoryIds: [ID]) {
|
||||
UpdatePost(id: $id, title: $title, content: $content, categoryIds: $categoryIds) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`
|
||||
const reportMutation = gql`
|
||||
mutation($resourceId: ID!, $reasonCategory: ReasonCategory!, $reasonDescription: String!) {
|
||||
fileReport(
|
||||
@ -227,104 +207,6 @@ describe('validateCreateComment', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('validatePost', () => {
|
||||
let createPostVariables
|
||||
beforeEach(async () => {
|
||||
createPostVariables = {
|
||||
title: 'I am a title',
|
||||
content: 'Some content',
|
||||
}
|
||||
authenticatedUser = await commentingUser.toJson()
|
||||
})
|
||||
|
||||
describe('categories', () => {
|
||||
describe('null', () => {
|
||||
it('throws UserInputError', async () => {
|
||||
createPostVariables = { ...createPostVariables, categoryIds: null }
|
||||
await expect(
|
||||
mutate({ mutation: createPostMutation, variables: createPostVariables }),
|
||||
).resolves.toMatchObject({
|
||||
data: { CreatePost: null },
|
||||
errors: [
|
||||
{
|
||||
message: 'You cannot save a post without at least one category or more than three',
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('empty', () => {
|
||||
it('throws UserInputError', async () => {
|
||||
createPostVariables = { ...createPostVariables, categoryIds: [] }
|
||||
await expect(
|
||||
mutate({ mutation: createPostMutation, variables: createPostVariables }),
|
||||
).resolves.toMatchObject({
|
||||
data: { CreatePost: null },
|
||||
errors: [
|
||||
{
|
||||
message: 'You cannot save a post without at least one category or more than three',
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('more than 3 categoryIds', () => {
|
||||
it('throws UserInputError', async () => {
|
||||
createPostVariables = {
|
||||
...createPostVariables,
|
||||
categoryIds: ['cat9', 'cat27', 'cat15', 'cat4'],
|
||||
}
|
||||
await expect(
|
||||
mutate({ mutation: createPostMutation, variables: createPostVariables }),
|
||||
).resolves.toMatchObject({
|
||||
data: { CreatePost: null },
|
||||
errors: [
|
||||
{
|
||||
message: 'You cannot save a post without at least one category or more than three',
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateUpdatePost', () => {
|
||||
describe('post created without categories somehow', () => {
|
||||
let owner, updatePostVariables
|
||||
beforeEach(async () => {
|
||||
const postSomehowCreated = await neode.create('Post', {
|
||||
id: 'how-was-this-created',
|
||||
})
|
||||
owner = await neode.create('User', {
|
||||
id: 'author-of-post-without-category',
|
||||
slug: 'hacker',
|
||||
})
|
||||
await postSomehowCreated.relateTo(owner, 'author')
|
||||
authenticatedUser = await owner.toJson()
|
||||
updatePostVariables = {
|
||||
id: 'how-was-this-created',
|
||||
title: 'I am a title',
|
||||
content: 'Some content',
|
||||
categoryIds: [],
|
||||
}
|
||||
})
|
||||
|
||||
it('requires at least one category for successful update', async () => {
|
||||
await expect(
|
||||
mutate({ mutation: updatePostMutation, variables: updatePostVariables }),
|
||||
).resolves.toMatchObject({
|
||||
data: { UpdatePost: null },
|
||||
errors: [
|
||||
{ message: 'You cannot save a post without at least one category or more than three' },
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateReport', () => {
|
||||
|
||||
@ -3,5 +3,6 @@ export default {
|
||||
alt: { type: 'string' },
|
||||
sensitive: { type: 'boolean', default: false },
|
||||
aspectRatio: { type: 'float', default: 1.0 },
|
||||
type: { type: 'string' },
|
||||
createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() },
|
||||
}
|
||||
|
||||
@ -1,16 +1,17 @@
|
||||
export default {
|
||||
code: { type: 'string', primary: true },
|
||||
createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() },
|
||||
token: { type: 'string', primary: true, token: true },
|
||||
generatedBy: {
|
||||
expiresAt: { type: 'string', isoDate: true, default: null },
|
||||
generated: {
|
||||
type: 'relationship',
|
||||
relationship: 'GENERATED',
|
||||
target: 'User',
|
||||
direction: 'in',
|
||||
},
|
||||
activated: {
|
||||
redeemed: {
|
||||
type: 'relationship',
|
||||
relationship: 'ACTIVATED',
|
||||
target: 'EmailAddress',
|
||||
direction: 'out',
|
||||
relationship: 'REDEEMED',
|
||||
target: 'User',
|
||||
direction: 'in',
|
||||
},
|
||||
}
|
||||
@ -100,6 +100,18 @@ export default {
|
||||
target: 'User',
|
||||
direction: 'in',
|
||||
},
|
||||
inviteCodes: {
|
||||
type: 'relationship',
|
||||
relationship: 'GENERATED',
|
||||
target: 'InviteCode',
|
||||
direction: 'out',
|
||||
},
|
||||
redeemedInviteCode: {
|
||||
type: 'relationship',
|
||||
relationship: 'REDEEMED',
|
||||
target: 'InviteCode',
|
||||
direction: 'out',
|
||||
},
|
||||
termsAndConditionsAgreedVersion: {
|
||||
type: 'string',
|
||||
allow: [null],
|
||||
|
||||
@ -15,4 +15,5 @@ export default {
|
||||
Donations: require('./Donations.js').default,
|
||||
Report: require('./Report.js').default,
|
||||
Migration: require('./Migration.js').default,
|
||||
InviteCode: require('./InviteCode.js').default,
|
||||
}
|
||||
|
||||
@ -0,0 +1,8 @@
|
||||
export default function generateInviteCode() {
|
||||
// 6 random numbers in [ 0, 35 ] are 36 possible numbers (10 [0-9] + 26 [A-Z])
|
||||
return Array.from({ length: 6 }, (n = Math.floor(Math.random() * 36)) => {
|
||||
// n > 9: it is a letter (ASCII 65 is A) -> 10 + 55 = 65
|
||||
// else: it is a number (ASCII 48 is 0) -> 0 + 48 = 48
|
||||
return String.fromCharCode(n > 9 ? n + 55 : n + 48)
|
||||
}).join('')
|
||||
}
|
||||
@ -2,7 +2,7 @@ import Resolver from './helpers/Resolver'
|
||||
export default {
|
||||
Image: {
|
||||
...Resolver('Image', {
|
||||
undefinedToNull: ['sensitive', 'alt', 'aspectRatio'],
|
||||
undefinedToNull: ['sensitive', 'alt', 'aspectRatio', 'type'],
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
@ -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)
|
||||
@ -53,8 +53,8 @@ export async function mergeImage(resource, relationshipType, imageInput, opts =
|
||||
if (!(existingImage || upload)) throw new UserInputError('Cannot find image for given resource')
|
||||
if (existingImage && upload) deleteImageFile(existingImage, deleteCallback)
|
||||
const url = await uploadImageFile(upload, uploadCallback)
|
||||
const { alt, sensitive, aspectRatio } = imageInput
|
||||
const image = { alt, sensitive, aspectRatio, url }
|
||||
const { alt, sensitive, aspectRatio, type } = imageInput
|
||||
const image = { alt, sensitive, aspectRatio, url, type }
|
||||
txResult = await transaction.run(
|
||||
`
|
||||
MATCH (resource {id: $resource.id})
|
||||
|
||||
109
backend/src/schema/resolvers/inviteCodes.js
Normal file
109
backend/src/schema/resolvers/inviteCodes.js
Normal file
@ -0,0 +1,109 @@
|
||||
import generateInviteCode from './helpers/generateInviteCode'
|
||||
import Resolver from './helpers/Resolver'
|
||||
|
||||
const uniqueInviteCode = async (session, code) => {
|
||||
return session.readTransaction(async (txc) => {
|
||||
const result = await txc.run(`MATCH (ic:InviteCode { id: $code }) RETURN count(ic) AS count`, {
|
||||
code,
|
||||
})
|
||||
return parseInt(String(result.records[0].get('count'))) === 0
|
||||
})
|
||||
}
|
||||
|
||||
export default {
|
||||
Query: {
|
||||
MyInviteCodes: async (_parent, args, context, _resolveInfo) => {
|
||||
const {
|
||||
user: { id: userId },
|
||||
} = context
|
||||
const session = context.driver.session()
|
||||
const readTxResultPromise = session.readTransaction(async (txc) => {
|
||||
const result = await txc.run(
|
||||
`MATCH (user:User {id: $userId})-[:GENERATED]->(ic:InviteCode)
|
||||
RETURN properties(ic) AS inviteCodes`,
|
||||
{
|
||||
userId,
|
||||
},
|
||||
)
|
||||
return result.records.map((record) => record.get('inviteCodes'))
|
||||
})
|
||||
try {
|
||||
const txResult = await readTxResultPromise
|
||||
return txResult
|
||||
} finally {
|
||||
session.close()
|
||||
}
|
||||
},
|
||||
isValidInviteCode: async (_parent, args, context, _resolveInfo) => {
|
||||
const { code } = args
|
||||
if (!code) return false
|
||||
const session = context.driver.session()
|
||||
const readTxResultPromise = session.readTransaction(async (txc) => {
|
||||
const result = await txc.run(
|
||||
`MATCH (ic:InviteCode { code: toUpper($code) })
|
||||
RETURN
|
||||
CASE
|
||||
WHEN ic.expiresAt IS NULL THEN true
|
||||
WHEN datetime(ic.expiresAt) >= datetime() THEN true
|
||||
ELSE false END AS result`,
|
||||
{
|
||||
code,
|
||||
},
|
||||
)
|
||||
return result.records.map((record) => record.get('result'))
|
||||
})
|
||||
try {
|
||||
const txResult = await readTxResultPromise
|
||||
return !!txResult[0]
|
||||
} finally {
|
||||
session.close()
|
||||
}
|
||||
},
|
||||
},
|
||||
Mutation: {
|
||||
GenerateInviteCode: async (_parent, args, context, _resolveInfo) => {
|
||||
const {
|
||||
user: { id: userId },
|
||||
} = context
|
||||
const session = context.driver.session()
|
||||
let code = generateInviteCode()
|
||||
while (!(await uniqueInviteCode(session, code))) {
|
||||
code = generateInviteCode()
|
||||
}
|
||||
const writeTxResultPromise = session.writeTransaction(async (txc) => {
|
||||
const result = await txc.run(
|
||||
`MATCH (user:User {id: $userId})
|
||||
MERGE (user)-[:GENERATED]->(ic:InviteCode { code: $code })
|
||||
ON CREATE SET
|
||||
ic.createdAt = toString(datetime()),
|
||||
ic.expiresAt = $expiresAt
|
||||
RETURN ic AS inviteCode`,
|
||||
{
|
||||
userId,
|
||||
code,
|
||||
expiresAt: args.expiresAt,
|
||||
},
|
||||
)
|
||||
return result.records.map((record) => record.get('inviteCode').properties)
|
||||
})
|
||||
try {
|
||||
const txResult = await writeTxResultPromise
|
||||
return txResult[0]
|
||||
} finally {
|
||||
session.close()
|
||||
}
|
||||
},
|
||||
},
|
||||
InviteCode: {
|
||||
...Resolver('InviteCode', {
|
||||
idAttribute: 'code',
|
||||
undefinedToNull: ['expiresAt'],
|
||||
hasOne: {
|
||||
generatedBy: '<-[:GENERATED]-(related:User)',
|
||||
},
|
||||
hasMany: {
|
||||
redeemedBy: '<-[:REDEEMED]-(related:User)',
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
200
backend/src/schema/resolvers/inviteCodes.spec.js
Normal file
200
backend/src/schema/resolvers/inviteCodes.spec.js
Normal file
@ -0,0 +1,200 @@
|
||||
import Factory, { cleanDatabase } from '../../db/factories'
|
||||
import { getDriver } from '../../db/neo4j'
|
||||
import { gql } from '../../helpers/jest'
|
||||
import createServer from '../../server'
|
||||
import { createTestClient } from 'apollo-server-testing'
|
||||
|
||||
let user
|
||||
let query
|
||||
let mutate
|
||||
|
||||
const driver = getDriver()
|
||||
|
||||
const generateInviteCodeMutation = gql`
|
||||
mutation($expiresAt: String = null) {
|
||||
GenerateInviteCode(expiresAt: $expiresAt) {
|
||||
code
|
||||
createdAt
|
||||
expiresAt
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const myInviteCodesQuery = gql`
|
||||
query {
|
||||
MyInviteCodes {
|
||||
code
|
||||
createdAt
|
||||
expiresAt
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const isValidInviteCodeQuery = gql`
|
||||
query($code: ID!) {
|
||||
isValidInviteCode(code: $code)
|
||||
}
|
||||
`
|
||||
|
||||
beforeAll(async () => {
|
||||
await cleanDatabase()
|
||||
const { server } = createServer({
|
||||
context: () => {
|
||||
return {
|
||||
driver,
|
||||
user,
|
||||
}
|
||||
},
|
||||
})
|
||||
query = createTestClient(server).query
|
||||
mutate = createTestClient(server).mutate
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanDatabase()
|
||||
})
|
||||
|
||||
describe('inviteCodes', () => {
|
||||
describe('as unauthenticated user', () => {
|
||||
it('cannot generate invite codes', async () => {
|
||||
await expect(mutate({ mutation: generateInviteCodeMutation })).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
errors: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
extensions: { code: 'INTERNAL_SERVER_ERROR' },
|
||||
}),
|
||||
]),
|
||||
data: {
|
||||
GenerateInviteCode: null,
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('cannot query invite codes', async () => {
|
||||
await expect(query({ query: myInviteCodesQuery })).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
errors: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
extensions: { code: 'INTERNAL_SERVER_ERROR' },
|
||||
}),
|
||||
]),
|
||||
data: {
|
||||
MyInviteCodes: null,
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('as authenticated user', () => {
|
||||
beforeAll(async () => {
|
||||
const authenticatedUser = await Factory.build(
|
||||
'user',
|
||||
{
|
||||
role: 'user',
|
||||
},
|
||||
{
|
||||
email: 'user@example.org',
|
||||
password: '1234',
|
||||
},
|
||||
)
|
||||
user = await authenticatedUser.toJson()
|
||||
})
|
||||
|
||||
it('generates an invite code without expiresAt', async () => {
|
||||
await expect(mutate({ mutation: generateInviteCodeMutation })).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
errors: undefined,
|
||||
data: {
|
||||
GenerateInviteCode: {
|
||||
code: expect.stringMatching(/^[0-9A-Z]{6,6}$/),
|
||||
expiresAt: null,
|
||||
createdAt: expect.any(String),
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('generates an invite code with expiresAt', async () => {
|
||||
const nextWeek = new Date()
|
||||
nextWeek.setDate(nextWeek.getDate() + 7)
|
||||
await expect(
|
||||
mutate({
|
||||
mutation: generateInviteCodeMutation,
|
||||
variables: { expiresAt: nextWeek.toISOString() },
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
errors: undefined,
|
||||
data: {
|
||||
GenerateInviteCode: {
|
||||
code: expect.stringMatching(/^[0-9A-Z]{6,6}$/),
|
||||
expiresAt: nextWeek.toISOString(),
|
||||
createdAt: expect.any(String),
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
let inviteCodes
|
||||
|
||||
it('returns the created invite codes when queried', async () => {
|
||||
const response = await query({ query: myInviteCodesQuery })
|
||||
inviteCodes = response.data.MyInviteCodes
|
||||
expect(inviteCodes).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('does not return the created invite codes of other users when queried', async () => {
|
||||
await Factory.build('inviteCode')
|
||||
const response = await query({ query: myInviteCodesQuery })
|
||||
inviteCodes = response.data.MyInviteCodes
|
||||
expect(inviteCodes).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('validates an invite code without expiresAt', async () => {
|
||||
const unExpiringInviteCode = inviteCodes.filter((ic) => ic.expiresAt === null)[0].code
|
||||
const result = await query({
|
||||
query: isValidInviteCodeQuery,
|
||||
variables: { code: unExpiringInviteCode },
|
||||
})
|
||||
expect(result.data.isValidInviteCode).toBeTruthy()
|
||||
})
|
||||
|
||||
it('validates an invite code in lower case', async () => {
|
||||
const unExpiringInviteCode = inviteCodes.filter((ic) => ic.expiresAt === null)[0].code
|
||||
const result = await query({
|
||||
query: isValidInviteCodeQuery,
|
||||
variables: { code: unExpiringInviteCode.toLowerCase() },
|
||||
})
|
||||
expect(result.data.isValidInviteCode).toBeTruthy()
|
||||
})
|
||||
|
||||
it('validates an invite code with expiresAt in the future', async () => {
|
||||
const expiringInviteCode = inviteCodes.filter((ic) => ic.expiresAt !== null)[0].code
|
||||
const result = await query({
|
||||
query: isValidInviteCodeQuery,
|
||||
variables: { code: expiringInviteCode },
|
||||
})
|
||||
expect(result.data.isValidInviteCode).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does not validate an invite code which expired in the past', async () => {
|
||||
const lastWeek = new Date()
|
||||
lastWeek.setDate(lastWeek.getDate() - 7)
|
||||
const inviteCode = await Factory.build('inviteCode', {
|
||||
expiresAt: lastWeek.toISOString(),
|
||||
})
|
||||
const code = inviteCode.get('code')
|
||||
const result = await query({ query: isValidInviteCodeQuery, variables: { code } })
|
||||
expect(result.data.isValidInviteCode).toBeFalsy()
|
||||
})
|
||||
|
||||
it('does not validate an invite code which does not exits', async () => {
|
||||
const result = await query({ query: isValidInviteCodeQuery, variables: { code: 'AAA' } })
|
||||
expect(result.data.isValidInviteCode).toBeFalsy()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,4 +1,6 @@
|
||||
import { UserInputError } from 'apollo-server'
|
||||
import Resolver from './helpers/Resolver'
|
||||
import { queryLocations } from './users/location'
|
||||
|
||||
export default {
|
||||
Location: {
|
||||
@ -16,4 +18,13 @@ export default {
|
||||
],
|
||||
}),
|
||||
},
|
||||
Query: {
|
||||
queryLocations: async (object, args, context, resolveInfo) => {
|
||||
try {
|
||||
return queryLocations(args)
|
||||
} catch (e) {
|
||||
throw new UserInputError(e.message)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@ -76,7 +76,6 @@ export default {
|
||||
},
|
||||
Mutation: {
|
||||
CreatePost: async (_parent, params, context, _resolveInfo) => {
|
||||
const { categoryIds } = params
|
||||
const { image: imageInput } = params
|
||||
delete params.categoryIds
|
||||
delete params.image
|
||||
@ -92,13 +91,9 @@ export default {
|
||||
WITH post
|
||||
MATCH (author:User {id: $userId})
|
||||
MERGE (post)<-[:WROTE]-(author)
|
||||
WITH post
|
||||
UNWIND $categoryIds AS categoryId
|
||||
MATCH (category:Category {id: categoryId})
|
||||
MERGE (post)-[:CATEGORIZED]->(category)
|
||||
RETURN post {.*}
|
||||
`,
|
||||
{ userId: context.user.id, categoryIds, params },
|
||||
{ userId: context.user.id, params },
|
||||
)
|
||||
const [post] = createPostTransactionResponse.records.map((record) => record.get('post'))
|
||||
if (imageInput) {
|
||||
|
||||
@ -317,19 +317,6 @@ describe('CreatePost', () => {
|
||||
expected,
|
||||
)
|
||||
})
|
||||
|
||||
describe('language', () => {
|
||||
beforeEach(() => {
|
||||
variables = { ...variables, language: 'es' }
|
||||
})
|
||||
|
||||
it('allows a user to set the language of the post', async () => {
|
||||
const expected = { data: { CreatePost: { language: 'es' } } }
|
||||
await expect(mutate({ mutation: createPostMutation, variables })).resolves.toMatchObject(
|
||||
expected,
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -318,6 +318,7 @@ export default {
|
||||
avatar: '-[:AVATAR_IMAGE]->(related:Image)',
|
||||
invitedBy: '<-[:INVITED]-(related:User)',
|
||||
location: '-[:IS_IN]->(related:Location)',
|
||||
redeemedInviteCode: '-[:REDEEMED]->(related:InviteCode)',
|
||||
},
|
||||
hasMany: {
|
||||
followedBy: '<-[:FOLLOWS]-(related:User)',
|
||||
@ -329,6 +330,7 @@ export default {
|
||||
shouted: '-[:SHOUTED]->(related:Post)',
|
||||
categories: '-[:CATEGORIZED]->(related:Category)',
|
||||
badges: '<-[:REWARDED]-(related:Badge)',
|
||||
inviteCodes: '-[:GENERATED]->(related:InviteCode)',
|
||||
},
|
||||
}),
|
||||
},
|
||||
|
||||
@ -277,9 +277,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,
|
||||
})
|
||||
})
|
||||
|
||||
@ -137,4 +137,15 @@ const createOrUpdateLocations = async (userId, locationName, session) => {
|
||||
})
|
||||
}
|
||||
|
||||
export const queryLocations = async ({ place, lang }) => {
|
||||
const res = await fetch(
|
||||
`https://api.mapbox.com/geocoding/v5/mapbox.places/${place}.json?access_token=${CONFIG.MAPBOX_TOKEN}&types=region,place,country&language=${lang}`,
|
||||
)
|
||||
// Return empty array if no location found or error occurred
|
||||
if (!res || !res.features) {
|
||||
return []
|
||||
}
|
||||
return res.features
|
||||
}
|
||||
|
||||
export default createOrUpdateLocations
|
||||
|
||||
@ -6,7 +6,7 @@ import createServer from '../../../server'
|
||||
|
||||
const neode = getNeode()
|
||||
const driver = getDriver()
|
||||
let authenticatedUser, mutate, variables
|
||||
let authenticatedUser, mutate, query, variables
|
||||
|
||||
const updateUserMutation = gql`
|
||||
mutation($id: ID!, $name: String!, $locationName: String) {
|
||||
@ -16,6 +16,15 @@ const updateUserMutation = gql`
|
||||
}
|
||||
`
|
||||
|
||||
const queryLocations = gql`
|
||||
query($place: String!, $lang: String!) {
|
||||
queryLocations(place: $place, lang: $lang) {
|
||||
place_name
|
||||
id
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const newlyCreatedNodesWithLocales = [
|
||||
{
|
||||
city: {
|
||||
@ -76,6 +85,7 @@ beforeAll(() => {
|
||||
},
|
||||
})
|
||||
mutate = createTestClient(server).mutate
|
||||
query = createTestClient(server).query
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
@ -85,6 +95,66 @@ beforeEach(() => {
|
||||
|
||||
afterEach(cleanDatabase)
|
||||
|
||||
describe('Location Service', () => {
|
||||
// Authentication
|
||||
// TODO: unify, externalize, simplify, wtf?
|
||||
let user
|
||||
beforeEach(async () => {
|
||||
user = await Factory.build('user', {
|
||||
id: 'location-user',
|
||||
})
|
||||
authenticatedUser = await user.toJson()
|
||||
})
|
||||
|
||||
it('query Location existing', async () => {
|
||||
variables = {
|
||||
place: 'Berlin',
|
||||
lang: 'en',
|
||||
}
|
||||
const result = await query({ query: queryLocations, variables })
|
||||
expect(result.data.queryLocations).toEqual([
|
||||
{ id: 'place.14094307404564380', place_name: 'Berlin, Germany' },
|
||||
{ id: 'place.15095411613564380', place_name: 'Berlin, Maryland, United States' },
|
||||
{ id: 'place.5225018734564380', place_name: 'Berlin, Connecticut, United States' },
|
||||
{ id: 'place.16922023226564380', place_name: 'Berlin, New Jersey, United States' },
|
||||
{ id: 'place.4035845612564380', place_name: 'Berlin Township, New Jersey, United States' },
|
||||
])
|
||||
})
|
||||
|
||||
it('query Location existing in different language', async () => {
|
||||
variables = {
|
||||
place: 'Berlin',
|
||||
lang: 'de',
|
||||
}
|
||||
const result = await query({ query: queryLocations, variables })
|
||||
expect(result.data.queryLocations).toEqual([
|
||||
{ id: 'place.14094307404564380', place_name: 'Berlin, Deutschland' },
|
||||
{ id: 'place.15095411613564380', place_name: 'Berlin, Maryland, Vereinigte Staaten' },
|
||||
{ id: 'place.16922023226564380', place_name: 'Berlin, New Jersey, Vereinigte Staaten' },
|
||||
{ id: 'place.10735893248465990', place_name: 'Berlin Heights, Ohio, Vereinigte Staaten' },
|
||||
{ id: 'place.1165756679564380', place_name: 'Berlin, Massachusetts, Vereinigte Staaten' },
|
||||
])
|
||||
})
|
||||
|
||||
it('query Location not existing', async () => {
|
||||
variables = {
|
||||
place: 'GbHtsd4sdHa',
|
||||
lang: 'en',
|
||||
}
|
||||
const result = await query({ query: queryLocations, variables })
|
||||
expect(result.data.queryLocations).toEqual([])
|
||||
})
|
||||
|
||||
it('query Location without a place name given', async () => {
|
||||
variables = {
|
||||
place: '',
|
||||
lang: 'en',
|
||||
}
|
||||
const result = await query({ query: queryLocations, variables })
|
||||
expect(result.data.queryLocations).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('userMiddleware', () => {
|
||||
describe('UpdateUser', () => {
|
||||
let user
|
||||
@ -95,7 +165,7 @@ describe('userMiddleware', () => {
|
||||
authenticatedUser = await user.toJson()
|
||||
})
|
||||
|
||||
it('creates a Location node with localised city/state/country names', async () => {
|
||||
it('creates a Location node with localized city/state/country names', async () => {
|
||||
variables = {
|
||||
...variables,
|
||||
id: 'updating-user',
|
||||
|
||||
@ -8,6 +8,7 @@ type Image {
|
||||
alt: String,
|
||||
sensitive: Boolean,
|
||||
aspectRatio: Float,
|
||||
type: String,
|
||||
}
|
||||
|
||||
input ImageInput {
|
||||
@ -15,4 +16,5 @@ input ImageInput {
|
||||
upload: Upload,
|
||||
sensitive: Boolean,
|
||||
aspectRatio: Float,
|
||||
type: String,
|
||||
}
|
||||
|
||||
17
backend/src/schema/types/type/InviteCode.gql
Normal file
17
backend/src/schema/types/type/InviteCode.gql
Normal file
@ -0,0 +1,17 @@
|
||||
type InviteCode {
|
||||
code: ID!
|
||||
createdAt: String!
|
||||
generatedBy: User @relation(name: "GENERATED", direction: "IN")
|
||||
redeemedBy: [User] @relation(name: "REDEEMED", direction: "IN")
|
||||
expiresAt: String
|
||||
}
|
||||
|
||||
|
||||
type Mutation {
|
||||
GenerateInviteCode(expiresAt: String = null): InviteCode
|
||||
}
|
||||
|
||||
type Query {
|
||||
MyInviteCodes: [InviteCode]
|
||||
isValidInviteCode(code: ID!): Boolean
|
||||
}
|
||||
@ -16,3 +16,13 @@ type Location {
|
||||
parent: Location @cypher(statement: "MATCH (this)-[:IS_IN]->(l:Location) RETURN l")
|
||||
}
|
||||
|
||||
# This is not smart - we need one location for everything - use the same type everywhere!
|
||||
type LocationMapBox {
|
||||
id: ID!
|
||||
place_name: String!
|
||||
}
|
||||
|
||||
type Query {
|
||||
queryLocations(place: String!, lang: String!): [LocationMapBox]!
|
||||
}
|
||||
|
||||
@ -136,7 +136,7 @@ type Post {
|
||||
"""
|
||||
)
|
||||
tags: [Tag]! @relation(name: "TAGGED", direction: "OUT")
|
||||
categories: [Category]! @relation(name: "CATEGORIZED", direction: "OUT")
|
||||
categories: [Category] @relation(name: "CATEGORIZED", direction: "OUT")
|
||||
|
||||
comments: [Comment]! @relation(name: "COMMENTS", direction: "IN")
|
||||
commentsCount: Int!
|
||||
|
||||
@ -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]!
|
||||
}
|
||||
|
||||
@ -56,6 +56,9 @@ type User {
|
||||
followedBy: [User]! @relation(name: "FOLLOWS", direction: "IN")
|
||||
followedByCount: Int! @cypher(statement: "MATCH (this)<-[:FOLLOWS]-(r:User) RETURN COUNT(DISTINCT r)")
|
||||
|
||||
inviteCodes: [InviteCode] @relation(name: "GENERATED", direction: "OUT")
|
||||
redeemedInviteCode: InviteCode @relation(name: "REDEEMED", direction: "OUT")
|
||||
|
||||
# Is the currently logged in user following that user?
|
||||
followedByCurrentUser: Boolean! @cypher(
|
||||
statement: """
|
||||
@ -83,7 +86,7 @@ type User {
|
||||
RETURN COUNT(user) >= 1
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
# contributions: [WrittenPost]!
|
||||
# contributions2(first: Int = 10, offset: Int = 0): [WrittenPost2]!
|
||||
# @cypher(
|
||||
@ -104,7 +107,7 @@ type User {
|
||||
shouted: [Post]! @relation(name: "SHOUTED", direction: "OUT")
|
||||
shoutedCount: Int! @cypher(statement: "MATCH (this)-[:SHOUTED]->(r:Post) WHERE NOT r.deleted = true AND NOT r.disabled = true RETURN COUNT(DISTINCT r)")
|
||||
|
||||
categories: [Category]! @relation(name: "CATEGORIZED", direction: "OUT")
|
||||
categories: [Category] @relation(name: "CATEGORIZED", direction: "OUT")
|
||||
|
||||
badges: [Badge]! @relation(name: "REWARDED", direction: "IN")
|
||||
badgesCount: Int! @cypher(statement: "MATCH (this)<-[:REWARDED]-(r:Badge) RETURN COUNT(r)")
|
||||
|
||||
@ -6302,6 +6302,11 @@ knuth-shuffle-seeded@^1.0.6:
|
||||
dependencies:
|
||||
seed-random "~2.2.0"
|
||||
|
||||
languagedetect@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/languagedetect/-/languagedetect-2.0.0.tgz#4b8fa2b7593b2a3a02fb1100891041c53238936c"
|
||||
integrity sha512-AZb/liiQ+6ZoTj4f1J0aE6OkzhCo8fyH+tuSaPfSo8YHCWLFJrdSixhtO2TYdIkjcDQNaR4RmGaV2A5FJklDMQ==
|
||||
|
||||
latest-version@^3.0.0:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/latest-version/-/latest-version-3.1.0.tgz#a205383fea322b33b5ae3b18abee0dc2f356ee15"
|
||||
|
||||
@ -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",
|
||||
() => {
|
||||
|
||||
@ -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 |
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
```
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
@ -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:
|
||||
- NODE_ENV="development"
|
||||
# - DEBUG=true
|
||||
- NUXT_BUILD=/tmp/nuxt # avoid file permission issues when `rm -rf .nuxt/`
|
||||
- PUBLIC_REGISTRATION=true
|
||||
command: yarn run dev
|
||||
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:
|
||||
|
||||
@ -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}"
|
||||
47
docker-compose.test.yml
Normal file
47
docker-compose.test.yml
Normal file
@ -0,0 +1,47 @@
|
||||
version: "3.4"
|
||||
|
||||
services:
|
||||
########################################################
|
||||
# WEBAPP ###############################################
|
||||
########################################################
|
||||
webapp:
|
||||
image: ocelotsocialnetwork/webapp:test
|
||||
build:
|
||||
target: test
|
||||
environment:
|
||||
- NODE_ENV="test"
|
||||
|
||||
########################################################
|
||||
# BACKEND ##############################################
|
||||
########################################################
|
||||
backend:
|
||||
image: ocelotsocialnetwork/backend:test
|
||||
build:
|
||||
target: test
|
||||
environment:
|
||||
- NODE_ENV="test"
|
||||
|
||||
########################################################
|
||||
# NEO4J ################################################
|
||||
########################################################
|
||||
neo4j:
|
||||
image: ocelotsocialnetwork/neo4j:community
|
||||
|
||||
########################################################
|
||||
# MAINTENANCE ##########################################
|
||||
########################################################
|
||||
maintenance:
|
||||
image: ocelotsocialnetwork/maintenance:test
|
||||
|
||||
########################################################
|
||||
# MAILSERVER TO FAKE SMTP ##############################
|
||||
########################################################
|
||||
mailserver:
|
||||
image: djfarrelly/maildev
|
||||
ports:
|
||||
- 1080:80
|
||||
networks:
|
||||
- external-net
|
||||
volumes:
|
||||
webapp_node_modules:
|
||||
backend_node_modules:
|
||||
@ -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="production"
|
||||
# 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="production"
|
||||
# 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:
|
||||
100
docu/deploy-structure.drawio
Normal file
100
docu/deploy-structure.drawio
Normal file
@ -0,0 +1,100 @@
|
||||
<mxfile host="65bd71144e" modified="2021-02-03T20:30:58.296Z" 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="xKmtht6USsh8FCHwohKS" type="embed">
|
||||
<diagram id="auPSk0nsFbUqeJNpueqE" name="Page-1">
|
||||
<mxGraphModel dx="1692" dy="788" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="827" pageHeight="1169" background="#333333" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0"/>
|
||||
<mxCell id="1" parent="0"/>
|
||||
<mxCell id="2" value="Backend" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#1ba1e2;strokeColor=#006EAF;fontColor=#ffffff;" vertex="1" parent="1">
|
||||
<mxGeometry x="540" y="120" width="120" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="3" value="Neo4J" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e51400;strokeColor=#B20000;fontColor=#ffffff;" vertex="1" parent="1">
|
||||
<mxGeometry x="540" y="210" width="120" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="4" value="Maintenance" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#a20025;strokeColor=#6F0000;fontColor=#ffffff;" vertex="1" parent="1">
|
||||
<mxGeometry x="540" y="300" width="120" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="5" value="WebApp" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#1ba1e2;strokeColor=#006EAF;fontColor=#ffffff;gradientColor=#A20025;" vertex="1" parent="1">
|
||||
<mxGeometry x="540" y="360" width="120" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="13" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;" edge="1" parent="1" source="6" target="2">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="6" value="Dockerfile" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#60a917;strokeColor=#2D7600;fontColor=#ffffff;" vertex="1" parent="1">
|
||||
<mxGeometry x="370" y="120" width="120" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="12" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;" edge="1" parent="1" source="7" target="3">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="7" value="Dockerfile" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#1ba1e2;strokeColor=#006EAF;fontColor=#ffffff;" vertex="1" parent="1">
|
||||
<mxGeometry x="370" y="210" width="120" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="11" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" edge="1" parent="1" source="8" target="4">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="8" value="Dockerfile" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#60a917;strokeColor=#2D7600;fontColor=#ffffff;" vertex="1" parent="1">
|
||||
<mxGeometry x="370" y="300" width="120" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="14" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" edge="1" parent="1" source="9" target="5">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="15" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;" edge="1" parent="1" source="9" target="10">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="9" value="Dockerfile" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#a20025;strokeColor=#6F0000;fontColor=#ffffff;" vertex="1" parent="1">
|
||||
<mxGeometry x="370" y="390" width="120" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="10" value="Storybook" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#1ba1e2;strokeColor=#006EAF;fontColor=#ffffff;gradientColor=#A20025;" vertex="1" parent="1">
|
||||
<mxGeometry x="540" y="420" width="120" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="17" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" edge="1" parent="1" source="16" target="6">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="18" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" edge="1" parent="1" source="16" target="7">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="19" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" edge="1" parent="1" source="16" target="8">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="20" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" edge="1" parent="1" source="16" target="9">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="16" value="docker-compose" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#60a917;strokeColor=#2D7600;fontColor=#ffffff;" vertex="1" parent="1">
|
||||
<mxGeometry x="150" y="340" width="120" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="21" value="Structural Problems" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#a20025;strokeColor=#6F0000;fontColor=#ffffff;" vertex="1" parent="1">
|
||||
<mxGeometry x="540" y="670" width="120" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="22" value="Developer<br>works here" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#1ba1e2;strokeColor=#006EAF;fontColor=#ffffff;" vertex="1" parent="1">
|
||||
<mxGeometry x="540" y="550" width="120" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="23" value="Pay 2 Win<br>or<br>Insecurity" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e51400;strokeColor=#B20000;fontColor=#ffffff;" vertex="1" parent="1">
|
||||
<mxGeometry x="540" y="610" width="120" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="33" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" edge="1" parent="1" source="24" target="7">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="24" value="Kubernetes" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="150" y="210" width="120" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="27" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;" edge="1" parent="1" source="26" target="16">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="28" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" edge="1" parent="1" source="26" target="9">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="31" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;exitDx=0;exitDy=0;entryX=0.5;entryY=0;entryDx=0;entryDy=0;" edge="1" parent="1" source="26" target="30">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="26" value="Github Actions CI" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e51400;strokeColor=#B20000;fontColor=#ffffff;gradientColor=#A20025;" vertex="1" parent="1">
|
||||
<mxGeometry x="150" y="480" width="120" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="30" value="Dockerhub" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e51400;strokeColor=#B20000;fontColor=#ffffff;" vertex="1" parent="1">
|
||||
<mxGeometry x="150" y="620" width="120" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="32" value="Whitelabeling" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="-40" y="620" width="120" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
</mxfile>
|
||||
BIN
docu/deploy-structure.png
Normal file
BIN
docu/deploy-structure.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
121
docu/kubernetes.drawio
Normal file
121
docu/kubernetes.drawio
Normal 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
BIN
docu/kubernetes.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
@ -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
|
||||
```
|
||||
|
||||
|
||||
@ -1,10 +1,42 @@
|
||||
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).${BUILD_NUMBER} here so we default to 0.0.0.0
|
||||
ENV BUILD_VERSION="0.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/
|
||||
10
package.json
10
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ocelot-social",
|
||||
"version": "0.6.3",
|
||||
"version": "0.6.5",
|
||||
"description": "Fullstack and API tests with cypress and cucumber for ocelot.social",
|
||||
"author": "ocelot.social Community",
|
||||
"license": "MIT",
|
||||
@ -23,14 +23,13 @@
|
||||
"cypress:open": "cross-env cypress open --browser firefox",
|
||||
"cucumber:setup": "cd backend && yarn run dev",
|
||||
"cucumber": "wait-on tcp:4000 && cucumber-js --require-module @babel/register --exit",
|
||||
"release": "standard-version",
|
||||
"generate:changelog": "yarn version && auto-changelog"
|
||||
"release": "yarn version --no-git-tag-version --no-commit-hooks --no-commit && auto-changelog --latest-version $(node -p -e \"require('./package.json').version\") && cd backend && yarn version --no-git-tag-version --no-commit-hooks --no-commit --new-version $(node -p -e \"require('./../package.json').version\") && cd ../webapp && yarn version --no-git-tag-version --no-commit-hooks --no-commit --new-version $(node -p -e \"require('./../package.json').version\")"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.9.0",
|
||||
"@babel/preset-env": "^7.12.7",
|
||||
"@babel/register": "^7.12.10",
|
||||
"auto-changelog": "^1.16.4",
|
||||
"auto-changelog": "^2.2.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"codecov": "^3.7.1",
|
||||
"cross-env": "^7.0.2",
|
||||
@ -51,8 +50,7 @@
|
||||
"neode": "^0.3.7",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"rosie": "^2.0.1",
|
||||
"slug": "^2.1.1",
|
||||
"standard-version": "^8.0.1"
|
||||
"slug": "^2.1.1"
|
||||
},
|
||||
"resolutions": {
|
||||
"set-value": "^2.0.1"
|
||||
|
||||
@ -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)"
|
||||
|
||||
@ -1,35 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
ROOT_DIR=$(dirname "$0")/..
|
||||
# BUILD_COMMIT=${TRAVIS_COMMIT:-$(git rev-parse HEAD)}
|
||||
|
||||
VERSION=$(jq -r '.version' $ROOT_DIR/package.json)
|
||||
IFS='.' read -r major minor patch <<< $VERSION
|
||||
apps=(develop-webapp develop-backend develop-neo4j develop-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
|
||||
|
||||
echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin
|
||||
|
||||
for app in "${apps[@]}"
|
||||
do
|
||||
SOURCE="ocelotsocialnetwork/${app}:latest"
|
||||
echo "docker push $SOURCE"
|
||||
docker push $SOURCE
|
||||
|
||||
for tag in "${tags[@]}"
|
||||
do
|
||||
TARGET="ocelotsocialnetwork/${app}:${tag}"
|
||||
if DOCKER_CLI_EXPERIMENTAL=enabled docker manifest inspect $TARGET >/dev/null; then
|
||||
echo "docker image ${TARGET} already present, skipping ..."
|
||||
else
|
||||
echo -e "docker tag $SOURCE $TARGET\ndocker push $TARGET"
|
||||
docker tag $SOURCE $TARGET
|
||||
docker push $TARGET
|
||||
fi
|
||||
done
|
||||
done
|
||||
@ -1,4 +1,3 @@
|
||||
MAPBOX_TOKEN="pk.eyJ1IjoiYnVzZmFrdG9yIiwiYSI6ImNraDNiM3JxcDBhaWQydG1uczhpZWtpOW4ifQ.7TNRTO-o9aK1Y6MyW_Nd4g"
|
||||
SENTRY_DSN_WEBAPP=
|
||||
COMMIT=
|
||||
PUBLIC_REGISTRATION=false
|
||||
|
||||
@ -1,32 +1,96 @@
|
||||
##################################################################################
|
||||
# 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).${BUILD_NUMBER} here so we default to 0.0.0.0
|
||||
ENV BUILD_VERSION="0.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
|
||||
|
||||
##################################################################################
|
||||
# TEST ###########################################################################
|
||||
##################################################################################
|
||||
FROM build as test
|
||||
|
||||
# Run command
|
||||
CMD /bin/sh -c "yarn run dev"
|
||||
|
||||
##################################################################################
|
||||
# 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"
|
||||
@ -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 ./
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@ -1,10 +1,8 @@
|
||||
import { config, mount } from '@vue/test-utils'
|
||||
import ContributionForm from './ContributionForm.vue'
|
||||
|
||||
import Vue from 'vue'
|
||||
import Vuex from 'vuex'
|
||||
import PostMutations from '~/graphql/PostMutations.js'
|
||||
import CategoriesSelect from '~/components/CategoriesSelect/CategoriesSelect'
|
||||
|
||||
import ImageUploader from '~/components/ImageUploader/ImageUploader'
|
||||
import MutationObserver from 'mutation-observer'
|
||||
@ -17,45 +15,8 @@ config.stubs['client-only'] = '<span><slot /></span>'
|
||||
config.stubs['nuxt-link'] = '<span><slot /></span>'
|
||||
config.stubs['v-popover'] = '<span><slot /></span>'
|
||||
|
||||
const categories = [
|
||||
{
|
||||
id: 'cat3',
|
||||
slug: 'health-wellbeing',
|
||||
icon: 'medkit',
|
||||
},
|
||||
{
|
||||
id: 'cat12',
|
||||
slug: 'it-internet-data-privacy',
|
||||
icon: 'mouse-pointer',
|
||||
},
|
||||
{
|
||||
id: 'cat9',
|
||||
slug: 'democracy-politics',
|
||||
icon: 'university',
|
||||
},
|
||||
{
|
||||
id: 'cat15',
|
||||
slug: 'consumption-sustainability',
|
||||
icon: 'shopping-cart',
|
||||
},
|
||||
{
|
||||
id: 'cat4',
|
||||
slug: 'environment-nature',
|
||||
icon: 'tree',
|
||||
},
|
||||
]
|
||||
|
||||
describe('ContributionForm.vue', () => {
|
||||
let wrapper,
|
||||
postTitleInput,
|
||||
expectedParams,
|
||||
cancelBtn,
|
||||
mocks,
|
||||
propsData,
|
||||
categoryIds,
|
||||
englishLanguage,
|
||||
deutschLanguage,
|
||||
dataPrivacyButton
|
||||
let wrapper, postTitleInput, expectedParams, cancelBtn, mocks, propsData
|
||||
const postTitle = 'this is a title for a post'
|
||||
const postTitleTooShort = 'xx'
|
||||
let postTitleTooLong = ''
|
||||
@ -82,8 +43,6 @@ describe('ContributionForm.vue', () => {
|
||||
slug: 'this-is-a-title-for-a-post',
|
||||
content: postContent,
|
||||
contentExcerpt: postContent,
|
||||
language: 'en',
|
||||
categoryIds,
|
||||
},
|
||||
},
|
||||
}),
|
||||
@ -136,18 +95,9 @@ describe('ContributionForm.vue', () => {
|
||||
describe('CreatePost', () => {
|
||||
describe('invalid form submission', () => {
|
||||
beforeEach(async () => {
|
||||
wrapper.find(CategoriesSelect).setData({ categories })
|
||||
postTitleInput = wrapper.find('.ds-input')
|
||||
postTitleInput.setValue(postTitle)
|
||||
await wrapper.vm.updateEditorContent(postContent)
|
||||
englishLanguage = wrapper
|
||||
.findAll('li')
|
||||
.filter((language) => language.text() === 'English')
|
||||
englishLanguage.trigger('click')
|
||||
dataPrivacyButton = await wrapper
|
||||
.find(CategoriesSelect)
|
||||
.find('[data-test="category-buttons-cat12"]')
|
||||
dataPrivacyButton.trigger('click')
|
||||
})
|
||||
|
||||
it('title cannot be empty', async () => {
|
||||
@ -173,22 +123,6 @@ describe('ContributionForm.vue', () => {
|
||||
await wrapper.find('form').trigger('submit')
|
||||
expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('has at least one category', async () => {
|
||||
dataPrivacyButton = await wrapper
|
||||
.find(CategoriesSelect)
|
||||
.find('[data-test="category-buttons-cat12"]')
|
||||
dataPrivacyButton.trigger('click')
|
||||
wrapper.find('form').trigger('submit')
|
||||
expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('has no more than three categories', async () => {
|
||||
wrapper.vm.formData.categoryIds = ['cat4', 'cat9', 'cat15', 'cat27']
|
||||
await Vue.nextTick()
|
||||
wrapper.find('form').trigger('submit')
|
||||
expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('valid form submission', () => {
|
||||
@ -198,48 +132,26 @@ describe('ContributionForm.vue', () => {
|
||||
variables: {
|
||||
title: postTitle,
|
||||
content: postContent,
|
||||
language: 'en',
|
||||
id: null,
|
||||
categoryIds: ['cat12'],
|
||||
image: null,
|
||||
},
|
||||
}
|
||||
postTitleInput = wrapper.find('.ds-input')
|
||||
postTitleInput.setValue(postTitle)
|
||||
await wrapper.vm.updateEditorContent(postContent)
|
||||
wrapper.find(CategoriesSelect).setData({ categories })
|
||||
englishLanguage = wrapper
|
||||
.findAll('li')
|
||||
.filter((language) => language.text() === 'English')
|
||||
englishLanguage.trigger('click')
|
||||
await Vue.nextTick()
|
||||
dataPrivacyButton = await wrapper
|
||||
.find(CategoriesSelect)
|
||||
.find('[data-test="category-buttons-cat12"]')
|
||||
dataPrivacyButton.trigger('click')
|
||||
await Vue.nextTick()
|
||||
})
|
||||
|
||||
it('creates a post with valid title, content, and at least one category', async () => {
|
||||
it('creates a post with valid title and content', async () => {
|
||||
await wrapper.find('form').trigger('submit')
|
||||
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams))
|
||||
})
|
||||
|
||||
it('supports changing the language', async () => {
|
||||
expectedParams.variables.language = 'de'
|
||||
deutschLanguage = wrapper
|
||||
.findAll('li')
|
||||
.filter((language) => language.text() === 'Deutsch')
|
||||
deutschLanguage.trigger('click')
|
||||
wrapper.find('form').trigger('submit')
|
||||
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams))
|
||||
})
|
||||
|
||||
it('supports adding a teaser image', async () => {
|
||||
expectedParams.variables.image = {
|
||||
aspectRatio: null,
|
||||
sensitive: false,
|
||||
upload: imageUpload,
|
||||
type: null,
|
||||
}
|
||||
const spy = jest
|
||||
.spyOn(FileReader.prototype, 'readAsDataURL')
|
||||
@ -292,18 +204,6 @@ describe('ContributionForm.vue', () => {
|
||||
postTitleInput = wrapper.find('.ds-input')
|
||||
postTitleInput.setValue(postTitle)
|
||||
await wrapper.vm.updateEditorContent(postContent)
|
||||
categoryIds = ['cat12']
|
||||
wrapper.find(CategoriesSelect).setData({ categories })
|
||||
englishLanguage = wrapper
|
||||
.findAll('li')
|
||||
.filter((language) => language.text() === 'English')
|
||||
englishLanguage.trigger('click')
|
||||
await Vue.nextTick()
|
||||
dataPrivacyButton = await wrapper
|
||||
.find(CategoriesSelect)
|
||||
.find('[data-test="category-buttons-cat12"]')
|
||||
dataPrivacyButton.trigger('click')
|
||||
await Vue.nextTick()
|
||||
})
|
||||
|
||||
it('shows an error toaster when apollo mutation rejects', async () => {
|
||||
@ -322,14 +222,7 @@ describe('ContributionForm.vue', () => {
|
||||
slug: 'dies-ist-ein-post',
|
||||
title: 'dies ist ein Post',
|
||||
content: 'auf Deutsch geschrieben',
|
||||
language: 'de',
|
||||
image,
|
||||
categories: [
|
||||
{
|
||||
id: 'cat12',
|
||||
name: 'Democracy & Politics',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
wrapper = Wrapper()
|
||||
@ -352,8 +245,6 @@ describe('ContributionForm.vue', () => {
|
||||
slug: 'this-is-a-title-for-a-post',
|
||||
content: postContent,
|
||||
contentExcerpt: postContent,
|
||||
language: 'en',
|
||||
categoryIds,
|
||||
},
|
||||
},
|
||||
})
|
||||
@ -363,9 +254,7 @@ describe('ContributionForm.vue', () => {
|
||||
variables: {
|
||||
title: propsData.contribution.title,
|
||||
content: propsData.contribution.content,
|
||||
language: propsData.contribution.language,
|
||||
id: propsData.contribution.id,
|
||||
categoryIds: ['cat12'],
|
||||
image: {
|
||||
sensitive: false,
|
||||
},
|
||||
@ -380,18 +269,6 @@ describe('ContributionForm.vue', () => {
|
||||
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams))
|
||||
})
|
||||
|
||||
it('supports updating categories', async () => {
|
||||
expectedParams.variables.categoryIds.push('cat3')
|
||||
wrapper.find(CategoriesSelect).setData({ categories })
|
||||
await Vue.nextTick()
|
||||
const healthWellbeingButton = await wrapper
|
||||
.find(CategoriesSelect)
|
||||
.find('[data-test="category-buttons-cat3"]')
|
||||
healthWellbeingButton.trigger('click')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams))
|
||||
})
|
||||
|
||||
it('supports deleting a teaser image', async () => {
|
||||
expectedParams.variables.image = null
|
||||
propsData.contribution.image = { url: '/uploads/someimage.png' }
|
||||
|
||||
@ -19,6 +19,7 @@
|
||||
:class="[formData.imageBlurred && '--blur-image']"
|
||||
@addHeroImage="addHeroImage"
|
||||
@addImageAspectRatio="addImageAspectRatio"
|
||||
@addImageType="addImageType"
|
||||
/>
|
||||
</template>
|
||||
<div v-if="formData.image" class="blur-toggle">
|
||||
@ -50,22 +51,6 @@
|
||||
{{ contentLength }}
|
||||
<base-icon v-if="errors && errors.content" name="warning" />
|
||||
</ds-chip>
|
||||
<categories-select model="categoryIds" :existingCategoryIds="formData.categoryIds" />
|
||||
<ds-chip size="base" :color="errors && errors.categoryIds && 'danger'">
|
||||
{{ formData.categoryIds.length }} / 3
|
||||
<base-icon v-if="errors && errors.categoryIds" name="warning" />
|
||||
</ds-chip>
|
||||
<ds-select
|
||||
model="language"
|
||||
icon="globe"
|
||||
class="select-field"
|
||||
:options="languageOptions"
|
||||
:placeholder="$t('contribution.languageSelectText')"
|
||||
:label="$t('contribution.languageSelectLabel')"
|
||||
/>
|
||||
<ds-chip v-if="errors && errors.language" size="base" color="danger">
|
||||
<base-icon name="warning" />
|
||||
</ds-chip>
|
||||
<div class="buttons">
|
||||
<base-button data-test="cancel-button" :disabled="loading" @click="$router.back()" danger>
|
||||
{{ $t('actions.cancel') }}
|
||||
@ -81,19 +66,15 @@
|
||||
|
||||
<script>
|
||||
import gql from 'graphql-tag'
|
||||
import orderBy from 'lodash/orderBy'
|
||||
import { mapGetters } from 'vuex'
|
||||
import HcEditor from '~/components/Editor/Editor'
|
||||
import locales from '~/locales'
|
||||
import PostMutations from '~/graphql/PostMutations.js'
|
||||
import CategoriesSelect from '~/components/CategoriesSelect/CategoriesSelect'
|
||||
import ImageUploader from '~/components/ImageUploader/ImageUploader'
|
||||
import links from '~/constants/links.js'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
HcEditor,
|
||||
CategoriesSelect,
|
||||
ImageUploader,
|
||||
},
|
||||
props: {
|
||||
@ -103,12 +84,12 @@ export default {
|
||||
},
|
||||
},
|
||||
data() {
|
||||
const { title, content, image, language, categories } = this.contribution
|
||||
|
||||
const languageOptions = orderBy(locales, 'name').map((locale) => {
|
||||
return { label: locale.name, value: locale.code }
|
||||
})
|
||||
const { sensitive: imageBlurred = false, aspectRatio: imageAspectRatio = null } = image || {}
|
||||
const { title, content, image } = this.contribution
|
||||
const {
|
||||
sensitive: imageBlurred = false,
|
||||
aspectRatio: imageAspectRatio = null,
|
||||
type: imageType = null,
|
||||
} = image || {}
|
||||
|
||||
return {
|
||||
links,
|
||||
@ -117,27 +98,14 @@ export default {
|
||||
content: content || '',
|
||||
image: image || null,
|
||||
imageAspectRatio,
|
||||
imageType,
|
||||
imageBlurred,
|
||||
language: languageOptions.find((option) => option.value === language) || null,
|
||||
categoryIds: categories ? categories.map((category) => category.id) : [],
|
||||
},
|
||||
formSchema: {
|
||||
title: { required: true, min: 3, max: 100 },
|
||||
content: { required: true },
|
||||
categoryIds: {
|
||||
type: 'array',
|
||||
required: true,
|
||||
validator: (_, value = []) => {
|
||||
if (value.length === 0 || value.length > 3) {
|
||||
return [new Error(this.$t('common.validations.categories'))]
|
||||
}
|
||||
return []
|
||||
},
|
||||
},
|
||||
language: { required: true },
|
||||
imageBlurred: { required: false },
|
||||
},
|
||||
languageOptions,
|
||||
loading: false,
|
||||
users: [],
|
||||
hashtags: [],
|
||||
@ -155,7 +123,7 @@ export default {
|
||||
methods: {
|
||||
submit() {
|
||||
let image = null
|
||||
const { title, content, categoryIds } = this.formData
|
||||
const { title, content } = this.formData
|
||||
if (this.formData.image) {
|
||||
image = {
|
||||
sensitive: this.formData.imageBlurred,
|
||||
@ -163,6 +131,7 @@ export default {
|
||||
if (this.imageUpload) {
|
||||
image.upload = this.imageUpload
|
||||
image.aspectRatio = this.formData.imageAspectRatio
|
||||
image.type = this.formData.imageType
|
||||
}
|
||||
}
|
||||
this.loading = true
|
||||
@ -172,9 +141,7 @@ export default {
|
||||
variables: {
|
||||
title,
|
||||
content,
|
||||
categoryIds,
|
||||
id: this.contribution.id || null,
|
||||
language: this.formData.language.value,
|
||||
image,
|
||||
},
|
||||
})
|
||||
@ -213,6 +180,9 @@ export default {
|
||||
addImageAspectRatio(aspectRatio) {
|
||||
this.formData.imageAspectRatio = aspectRatio
|
||||
},
|
||||
addImageType(imageType) {
|
||||
this.formData.imageType = imageType
|
||||
},
|
||||
},
|
||||
apollo: {
|
||||
User: {
|
||||
|
||||
@ -14,9 +14,6 @@
|
||||
<div class="filter-menu-options">
|
||||
<h2 class="title">{{ $t('filter-menu.filter-by') }}</h2>
|
||||
<following-filter />
|
||||
<categories-filter />
|
||||
<emotions-filter />
|
||||
<languages-filter />
|
||||
</div>
|
||||
</template>
|
||||
</dropdown>
|
||||
@ -26,17 +23,11 @@
|
||||
import Dropdown from '~/components/Dropdown'
|
||||
import { mapGetters } from 'vuex'
|
||||
import FollowingFilter from './FollowingFilter'
|
||||
import CategoriesFilter from './CategoriesFilter'
|
||||
import EmotionsFilter from './EmotionsFilter'
|
||||
import LanguagesFilter from './LanguagesFilter'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Dropdown,
|
||||
FollowingFilter,
|
||||
CategoriesFilter,
|
||||
EmotionsFilter,
|
||||
LanguagesFilter,
|
||||
},
|
||||
props: {
|
||||
placement: { type: String },
|
||||
|
||||
@ -25,12 +25,11 @@ describe('ImageUploader.vue', () => {
|
||||
|
||||
describe('handles errors', () => {
|
||||
beforeEach(() => jest.useFakeTimers())
|
||||
const message = 'File upload failed'
|
||||
const fileError = { status: 'error' }
|
||||
const unSupportedFileMessage = 'message'
|
||||
|
||||
it('shows an error toaster when verror is called', () => {
|
||||
wrapper.vm.onDropzoneError(fileError, message)
|
||||
expect(mocks.$toast.error).toHaveBeenCalledWith(fileError.status, message)
|
||||
it('shows an error toaster when unSupported file is uploaded', () => {
|
||||
wrapper.vm.onUnSupportedFormat(unSupportedFileMessage)
|
||||
expect(mocks.$toast.error).toHaveBeenCalledWith(unSupportedFileMessage)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,17 +1,21 @@
|
||||
<template>
|
||||
<div class="image-uploader">
|
||||
<vue-dropzone
|
||||
v-show="!showCropper"
|
||||
v-show="!showCropper && !hasImage"
|
||||
id="postdropzone"
|
||||
:options="dropzoneOptions"
|
||||
:use-custom-slot="true"
|
||||
@vdropzone-error="onDropzoneError"
|
||||
@vdropzone-file-added="initCropper"
|
||||
@vdropzone-file-added="fileAdded"
|
||||
>
|
||||
<loading-spinner v-if="isLoadingImage" />
|
||||
<base-icon v-else name="image" />
|
||||
<base-icon v-else-if="!hasImage" name="image" />
|
||||
<div v-if="!hasImage" class="supported-formats">
|
||||
{{ $t('contribution.teaserImage.supportedFormats') }}
|
||||
</div>
|
||||
</vue-dropzone>
|
||||
<div v-show="!showCropper && hasImage">
|
||||
<base-button
|
||||
v-if="hasImage"
|
||||
class="delete-image-button"
|
||||
icon="trash"
|
||||
circle
|
||||
danger
|
||||
@ -20,10 +24,12 @@
|
||||
:title="$t('actions.delete')"
|
||||
@click.stop="deleteImage"
|
||||
/>
|
||||
<div v-if="!hasImage" class="supported-formats">
|
||||
{{ $t('contribution.teaserImage.supportedFormats') }}
|
||||
</div>
|
||||
</vue-dropzone>
|
||||
</div>
|
||||
<div v-show="!showCropper && imageCanBeCropped" class="crop-overlay">
|
||||
<base-button class="crop-confirm" filled @click="initCropper">
|
||||
{{ $t('contribution.teaserImage.cropImage') }}
|
||||
</base-button>
|
||||
</div>
|
||||
<div v-show="showCropper" class="crop-overlay">
|
||||
<img id="cropping-image" />
|
||||
<base-button class="crop-confirm" filled @click="cropImage">
|
||||
@ -48,6 +54,8 @@ import Cropper from 'cropperjs'
|
||||
import LoadingSpinner from '~/components/_new/generic/LoadingSpinner/LoadingSpinner'
|
||||
import 'cropperjs/dist/cropper.css'
|
||||
|
||||
const minAspectRatio = 0.3
|
||||
|
||||
export default {
|
||||
components: {
|
||||
LoadingSpinner,
|
||||
@ -65,41 +73,69 @@ export default {
|
||||
url: () => '',
|
||||
maxFilesize: 5.0,
|
||||
previewTemplate: '<span class="no-preview" />',
|
||||
acceptedFiles: '.png,.jpg,.jpeg,.gif',
|
||||
},
|
||||
cropper: null,
|
||||
file: null,
|
||||
showCropper: false,
|
||||
imageCanBeCropped: false,
|
||||
isLoadingImage: false,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onDropzoneError(file, message) {
|
||||
this.$toast.error(file.status, message)
|
||||
onUnSupportedFormat(message) {
|
||||
this.$toast.error(message)
|
||||
},
|
||||
initCropper(file) {
|
||||
this.showCropper = true
|
||||
addImageProcess(src) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image()
|
||||
img.onload = () => resolve(img)
|
||||
img.onerror = reject
|
||||
img.src = src
|
||||
})
|
||||
},
|
||||
async fileAdded(file) {
|
||||
const supportedFormats = ['image/jpg', 'image/jpeg', 'image/png', 'image/gif']
|
||||
if (supportedFormats.indexOf(file.type) < 0) {
|
||||
this.onUnSupportedFormat(this.$t('contribution.teaserImage.errors.unSupported-file-format'))
|
||||
this.$nextTick((this.isLoadingImage = false))
|
||||
return null
|
||||
}
|
||||
const imageURL = URL.createObjectURL(file)
|
||||
const image = await this.addImageProcess(imageURL)
|
||||
const aspectRatio = image.width / image.height
|
||||
if (aspectRatio < minAspectRatio) {
|
||||
this.aspectRatioError()
|
||||
return null
|
||||
}
|
||||
this.saveImage(aspectRatio, file, file.type)
|
||||
this.file = file
|
||||
|
||||
if (this.file.type === 'image/jpeg') this.imageCanBeCropped = true
|
||||
this.$nextTick((this.isLoadingImage = false))
|
||||
},
|
||||
initCropper() {
|
||||
this.showCropper = true
|
||||
const imageElement = document.querySelector('#cropping-image')
|
||||
imageElement.src = URL.createObjectURL(file)
|
||||
imageElement.src = URL.createObjectURL(this.file)
|
||||
this.cropper = new Cropper(imageElement, { zoomable: false, autoCropArea: 0.9 })
|
||||
},
|
||||
cropImage() {
|
||||
this.isLoadingImage = true
|
||||
|
||||
const onCropComplete = (aspectRatio, imageFile) => {
|
||||
this.$emit('addImageAspectRatio', aspectRatio)
|
||||
this.$emit('addHeroImage', imageFile)
|
||||
const onCropComplete = (aspectRatio, imageFile, imageType) => {
|
||||
this.saveImage(aspectRatio, imageFile, imageType)
|
||||
this.$nextTick((this.isLoadingImage = false))
|
||||
this.closeCropper()
|
||||
}
|
||||
|
||||
if (this.file.type === 'image/jpeg') {
|
||||
const canvas = this.cropper.getCroppedCanvas()
|
||||
canvas.toBlob((blob) => {
|
||||
const imageAspectRatio = canvas.width / canvas.height
|
||||
if (imageAspectRatio < minAspectRatio) {
|
||||
this.aspectRatioError()
|
||||
return
|
||||
}
|
||||
const croppedImageFile = new File([blob], this.file.name, { type: this.file.type })
|
||||
onCropComplete(imageAspectRatio, croppedImageFile)
|
||||
onCropComplete(imageAspectRatio, croppedImageFile, 'image/jpeg')
|
||||
}, 'image/jpeg')
|
||||
} else {
|
||||
// TODO: use cropped file instead of original file
|
||||
@ -111,9 +147,18 @@ export default {
|
||||
this.showCropper = false
|
||||
this.cropper.destroy()
|
||||
},
|
||||
aspectRatioError() {
|
||||
this.$toast.error(this.$t('contribution.teaserImage.errors.aspect-ratio-too-small'))
|
||||
},
|
||||
saveImage(aspectRatio = 1.0, file, fileType) {
|
||||
this.$emit('addImageAspectRatio', aspectRatio)
|
||||
this.$emit('addHeroImage', file)
|
||||
this.$emit('addImageType', fileType)
|
||||
},
|
||||
deleteImage() {
|
||||
this.$emit('addHeroImage', null)
|
||||
this.$emit('addImageAspectRatio', null)
|
||||
this.$emit('addImageType', null)
|
||||
},
|
||||
},
|
||||
}
|
||||
@ -122,7 +167,6 @@ export default {
|
||||
.image-uploader {
|
||||
position: relative;
|
||||
min-height: $size-image-uploader-min-height;
|
||||
cursor: pointer;
|
||||
|
||||
.image + & {
|
||||
position: absolute;
|
||||
@ -167,6 +211,14 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
.delete-image-button {
|
||||
position: absolute;
|
||||
top: $space-small;
|
||||
right: $space-small;
|
||||
z-index: $z-index-surface;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dz-message {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
@ -175,6 +227,7 @@ export default {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: $z-index-surface;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
> .base-icon {
|
||||
|
||||
@ -23,18 +23,7 @@
|
||||
<div class="content hyphenate-text" v-html="excerpt" />
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
<footer class="footer">
|
||||
<div class="categories">
|
||||
<hc-category
|
||||
v-for="category in post.categories"
|
||||
:key="category.id"
|
||||
v-tooltip="{
|
||||
content: $t(`contribution.category.name.${category.slug}`),
|
||||
placement: 'bottom-start',
|
||||
delay: { show: 500 },
|
||||
}"
|
||||
:icon="category.icon"
|
||||
/>
|
||||
</div>
|
||||
<div class="categories-placeholder"></div>
|
||||
<counter-icon
|
||||
icon="bullhorn"
|
||||
:count="post.shoutedCount"
|
||||
@ -67,7 +56,6 @@
|
||||
<script>
|
||||
import UserTeaser from '~/components/UserTeaser/UserTeaser'
|
||||
import ContentMenu from '~/components/ContentMenu/ContentMenu'
|
||||
import HcCategory from '~/components/Category'
|
||||
import HcRibbon from '~/components/Ribbon'
|
||||
import CounterIcon from '~/components/_new/generic/CounterIcon/CounterIcon'
|
||||
import { mapGetters } from 'vuex'
|
||||
@ -77,7 +65,6 @@ export default {
|
||||
name: 'PostTeaser',
|
||||
components: {
|
||||
UserTeaser,
|
||||
HcCategory,
|
||||
HcRibbon,
|
||||
ContentMenu,
|
||||
CounterIcon,
|
||||
@ -181,7 +168,7 @@ export default {
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
> .categories {
|
||||
> .categories-placeholder {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
|
||||
@ -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 })
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
374
webapp/components/_new/features/SearchResults/SearchResults.vue
Normal file
374
webapp/components/_new/features/SearchResults/SearchResults.vue
Normal 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>
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
111
webapp/components/_new/generic/TabNavigation/TabNavigation.vue
Normal file
111
webapp/components/_new/generic/TabNavigation/TabNavigation.vue
Normal 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>
|
||||
@ -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>
|
||||
`,
|
||||
}))
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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' },
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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" />
|
||||
`,
|
||||
}))
|
||||
|
||||
@ -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)
|
||||
|
||||
49
webapp/config/index.js
Normal file
49
webapp/config/index.js
Normal file
@ -0,0 +1,49 @@
|
||||
// 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,
|
||||
// Cookies
|
||||
COOKIE_EXPIRE_TIME: process.env.COOKIE_EXPIRE_TIME || 730, // Two years by default
|
||||
COOKIE_HTTPS_ONLY: process.env.COOKIE_HTTPS_ONLY || process.env.NODE_ENV === 'production', // ensure true in production if not set explicitly
|
||||
}
|
||||
|
||||
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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user