Merge branch 'master' into dependabot/npm_and_yarn/dotenv-16.3.1

This commit is contained in:
mahula 2023-07-31 11:44:49 +02:00 committed by GitHub
commit 0458119d6e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
251 changed files with 8262 additions and 4404 deletions

View File

@ -1,4 +1,5 @@
backend: &backend backend: &backend
- '.github/workflows/test-backend.yml'
- 'backend/**/*' - 'backend/**/*'
- 'neo4j/**/*' - 'neo4j/**/*'
@ -6,4 +7,5 @@ docker: &docker
- 'docker-compose.*' - 'docker-compose.*'
webapp: &webapp webapp: &webapp
- '.github/workflows/test-webapp.yml'
- 'webapp/**/*' - 'webapp/**/*'

View File

@ -0,0 +1,42 @@
###############################################################################
# A Github repo has max 10 GB of cache.
# https://github.blog/changelog/2021-11-23-github-actions-cache-size-is-now-increased-to-10gb-per-repository/
#
# To avoid "cache thrashing" by their cache eviction policy it is recommended
# to apply a cache cleanup workflow at PR closing to dele cache leftovers of
# the current branch:
# https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#force-deleting-cache-entries
###############################################################################
name: ocelot.social cache cleanup on pr closing
on:
pull_request:
types:
- closed
jobs:
clean-branch-cache:
name: Cleanup branch cache
runs-on: ubuntu-latest
continue-on-error: true
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Cleanup
run: |
gh extension install actions/gh-actions-cache
REPO=${{ github.repository }}
BRANCH="refs/pull/${{ github.event.pull_request.number }}/merge"
echo "Fetching list of cache key"
cacheKeysForPR=$(gh actions-cache list -R $REPO -B $BRANCH | cut -f 1 )
set +e
echo "Deleting caches..."
for cacheKey in $cacheKeysForPR
do
gh actions-cache delete $cacheKey -R $REPO -B $BRANCH --confirm
done
echo "Done"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@ -1,7 +1,7 @@
name: ocelot.social backend test CI name: ocelot.social backend test CI
on: [push] on: push
jobs: jobs:
files-changed: files-changed:
@ -13,7 +13,7 @@ jobs:
steps: steps:
- uses: actions/checkout@v3.3.0 - uses: actions/checkout@v3.3.0
- name: Check for frontend file changes - name: Check for backend file changes
uses: dorny/paths-filter@v2.11.1 uses: dorny/paths-filter@v2.11.1
id: changes id: changes
with: with:
@ -34,12 +34,13 @@ jobs:
run: | run: |
docker build --target community -t "ocelotsocialnetwork/neo4j-community:test" neo4j/ docker build --target community -t "ocelotsocialnetwork/neo4j-community:test" neo4j/
docker save "ocelotsocialnetwork/neo4j-community:test" > /tmp/neo4j.tar docker save "ocelotsocialnetwork/neo4j-community:test" > /tmp/neo4j.tar
- name: Upload Artifact - name: Cache docker images
uses: actions/upload-artifact@v3 id: cache-neo4j
uses: actions/cache/save@v3.3.1
with: with:
name: docker-neo4j-image
path: /tmp/neo4j.tar path: /tmp/neo4j.tar
key: ${{ github.run_id }}-backend-neo4j-cache
build_test_backend: build_test_backend:
name: Docker Build Test - Backend name: Docker Build Test - Backend
@ -54,12 +55,13 @@ jobs:
run: | run: |
docker build --target test -t "ocelotsocialnetwork/backend:test" backend/ docker build --target test -t "ocelotsocialnetwork/backend:test" backend/
docker save "ocelotsocialnetwork/backend:test" > /tmp/backend.tar docker save "ocelotsocialnetwork/backend:test" > /tmp/backend.tar
- name: Upload Artifact - name: Cache docker images
uses: actions/upload-artifact@v3 id: cache-backend
uses: actions/cache/save@v3.3.1
with: with:
name: docker-backend-test
path: /tmp/backend.tar path: /tmp/backend.tar
key: ${{ github.run_id }}-backend-cache
lint_backend: lint_backend:
name: Lint Backend name: Lint Backend
@ -84,28 +86,29 @@ jobs:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Download Docker Image (Neo4J) - name: Restore Neo4J cache
uses: actions/download-artifact@v3 uses: actions/cache/restore@v3.3.1
with: with:
name: docker-neo4j-image path: /tmp/neo4j.tar
path: /tmp key: ${{ github.run_id }}-backend-neo4j-cache
fail-on-cache-miss: true
- name: Load Docker Image - name: Restore Backend cache
run: docker load < /tmp/neo4j.tar uses: actions/cache/restore@v3.3.1
- name: Download Docker Image (Backend)
uses: actions/download-artifact@v3
with: with:
name: docker-backend-test path: /tmp/backend.tar
path: /tmp key: ${{ github.run_id }}-backend-cache
fail-on-cache-miss: true
- name: Load Docker Image - name: Load Docker Images
run: docker load < /tmp/backend.tar run: |
docker load < /tmp/neo4j.tar
docker load < /tmp/backend.tar
- name: backend | copy env files webapp - name: backend | copy env files
run: cp webapp/.env.template webapp/.env run: |
- name: backend | copy env files backend cp webapp/.env.template webapp/.env
run: cp backend/.env.template backend/.env cp backend/.env.template backend/.env
- name: backend | docker-compose - name: backend | docker-compose
run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps neo4j backend run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps neo4j backend
@ -118,3 +121,20 @@ jobs:
- name: backend | Unit test incl. coverage check - name: backend | Unit test incl. coverage check
run: docker-compose exec -T backend yarn test run: docker-compose exec -T backend yarn test
cleanup:
name: Cleanup
if: ${{ needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.docker == 'true' }}
needs: [files-changed, unit_test_backend]
runs-on: ubuntu-latest
continue-on-error: true
steps:
- name: Delete cache
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh extension install actions/gh-actions-cache
KEY="${{ github.run_id }}-backend-neo4j-cache"
gh actions-cache delete $KEY -R Ocelot-Social-Community/Ocelot-Social --confirm
KEY="${{ github.run_id }}-backend-cache"
gh actions-cache delete $KEY -R Ocelot-Social-Community/Ocelot-Social --confirm

View File

@ -1,9 +1,54 @@
name: ocelot.social end-to-end test CI name: ocelot.social end-to-end test CI
on: push on: push
jobs: jobs:
docker_preparation:
name: Fullstack test preparation
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Copy env files
run: |
cp webapp/.env.template webapp/.env
cp backend/.env.template backend/.env
- name: Build docker images
run: |
mkdir /tmp/images
docker build --target community -t "ocelotsocialnetwork/neo4j-community:test" neo4j/
docker save "ocelotsocialnetwork/neo4j-community:test" > /tmp/images/neo4j.tar
docker build --target test -t "ocelotsocialnetwork/backend:test" backend/
docker save "ocelotsocialnetwork/backend:test" > /tmp/images/backend.tar
docker build --target test -t "ocelotsocialnetwork/webapp:test" webapp/
docker save "ocelotsocialnetwork/webapp:test" > /tmp/images/webapp.tar
- name: Install cypress requirements
run: |
wget --no-verbose -O /opt/cucumber-json-formatter "https://github.com/cucumber/json-formatter/releases/download/v19.0.0/cucumber-json-formatter-linux-386"
cd backend
yarn install
yarn build
cd ..
yarn install
- name: Cache docker images
id: cache
uses: actions/cache/save@v3.3.1
with:
path: |
/opt/cucumber-json-formatter
/home/runner/.cache/Cypress
/home/runner/work/Ocelot-Social/Ocelot-Social
/tmp/images/
key: ${{ github.run_id }}-e2e-preparation-cache
fullstack_tests: fullstack_tests:
name: Fullstack tests name: Fullstack tests
if: success()
needs: docker_preparation
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
jobs: 8 jobs: 8
@ -12,34 +57,56 @@ jobs:
# run copies of the current job in parallel # run copies of the current job in parallel
job: [1, 2, 3, 4, 5, 6, 7, 8] job: [1, 2, 3, 4, 5, 6, 7, 8]
steps: steps:
- name: Checkout code - name: Restore cache
uses: actions/checkout@v3 uses: actions/cache/restore@v3.3.1
id: cache
with:
path: |
/opt/cucumber-json-formatter
/home/runner/.cache/Cypress
/home/runner/work/Ocelot-Social/Ocelot-Social
/tmp/images/
key: ${{ github.run_id }}-e2e-preparation-cache
fail-on-cache-miss: true
- name: webapp | copy env file - name: Boot up test system | docker-compose
run: cp webapp/.env.template webapp/.env
- name: backend | copy env file
run: cp backend/.env.template backend/.env
- name: boot up test system | docker-compose
run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps webapp neo4j backend
- name: cypress | Fullstack tests
id: e2e-tests
run: | run: |
cd backend chmod +x /opt/cucumber-json-formatter
yarn install sudo ln -fs /opt/cucumber-json-formatter /usr/bin/cucumber-json-formatter
yarn build docker load < /tmp/images/neo4j.tar
cd .. docker load < /tmp/images/backend.tar
yarn install docker load < /tmp/images/webapp.tar
yarn run cypress:run --spec $(cypress/parallel-features.sh ${{ matrix.job }} ${{ env.jobs }} ) docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps webapp neo4j backend
sleep 90s
########################################################################## - name: Full stack tests | run tests
# UPLOAD SCREENSHOTS - IF TESTS FAIL ##################################### id: e2e-tests
########################################################################## run: yarn run cypress:run --spec $(cypress/parallel-features.sh ${{ matrix.job }} ${{ env.jobs }} )
- name: Full stack tests | if any test failed, upload screenshots
- name: Full stack tests | if tests failed, compile html report
if: ${{ failure() && steps.e2e-tests.conclusion == 'failure' }}
run: |
cd cypress/
node create-cucumber-html-report.js
- name: Full stack tests | if tests failed, upload report
id: e2e-report
if: ${{ failure() && steps.e2e-tests.conclusion == 'failure' }} if: ${{ failure() && steps.e2e-tests.conclusion == 'failure' }}
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: cypress-screenshots name: ocelot-e2e-test-report-pr${{ needs.docker_preparation.outputs.pr-number }}
path: cypress/screenshots/ path: /home/runner/work/Ocelot-Social/Ocelot-Social/cypress/reports/cucumber_html_report
cleanup:
name: Cleanup
needs: [docker_preparation, fullstack_tests]
runs-on: ubuntu-latest
continue-on-error: true
steps:
- name: Delete cache
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh extension install actions/gh-actions-cache
KEY="${{ github.run_id }}-e2e-preparation-cache"
gh actions-cache delete $KEY -R Ocelot-Social-Community/Ocelot-Social --confirm

View File

@ -1,7 +1,7 @@
name: ocelot.social webapp test CI name: ocelot.social webapp test CI
on: [push] on: push
jobs: jobs:
files-changed: files-changed:
@ -23,7 +23,7 @@ jobs:
prepare: prepare:
name: Prepare name: Prepare
if: needs.files-changed.outputs.webapp if: needs.files-changed.outputs.webapp == 'true'
needs: files-changed needs: files-changed
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@ -34,30 +34,30 @@ jobs:
run: | run: |
scripts/translations/sort.sh scripts/translations/sort.sh
scripts/translations/missing-keys.sh scripts/translations/missing-keys.sh
build_test_webapp: build_test_webapp:
name: Docker Build Test - Webapp name: Docker Build Test - Webapp
if: needs.files-changed.outputs.docker == 'true' || needs.files-changed.outputs.webapp if: needs.files-changed.outputs.docker == 'true' || needs.files-changed.outputs.webapp == 'true'
needs: [files-changed, prepare] needs: [files-changed, prepare]
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: webapp | Build 'test' image - name: Webapp | Build 'test' image
run: | run: |
docker build --target test -t "ocelotsocialnetwork/webapp:test" webapp/ docker build --target test -t "ocelotsocialnetwork/webapp:test" webapp/
docker save "ocelotsocialnetwork/webapp:test" > /tmp/webapp.tar docker save "ocelotsocialnetwork/webapp:test" > /tmp/webapp.tar
- name: Upload Artifact - name: Cache docker image
uses: actions/upload-artifact@v3 uses: actions/cache/save@v3.3.1
with: with:
name: docker-webapp-test
path: /tmp/webapp.tar path: /tmp/webapp.tar
key: ${{ github.run_id }}-webapp-cache
lint_webapp: lint_webapp:
name: Lint Webapp name: Lint Webapp
if: needs.files-changed.outputs.webapp if: needs.files-changed.outputs.webapp == 'true'
needs: files-changed needs: files-changed
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@ -69,7 +69,7 @@ jobs:
unit_test_webapp: unit_test_webapp:
name: Unit Tests - Webapp name: Unit Tests - Webapp
if: needs.files-changed.outputs.docker == 'true' || needs.files-changed.outputs.webapp if: needs.files-changed.outputs.docker == 'true' || needs.files-changed.outputs.webapp == 'true'
needs: [files-changed, build_test_webapp] needs: [files-changed, build_test_webapp]
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
@ -78,20 +78,19 @@ jobs:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Download Docker Image (Webapp) - name: Restore webapp cache
uses: actions/download-artifact@v3 uses: actions/cache/restore@v3.3.1
with: with:
name: docker-webapp-test path: /tmp/webapp.tar
path: /tmp key: ${{ github.run_id }}-webapp-cache
- name: Load Docker Image - name: Load Docker Image
run: docker load < /tmp/webapp.tar run: docker load < /tmp/webapp.tar
- name: backend | copy env files webapp - name: Copy env files
run: cp webapp/.env.template webapp/.env run: |
cp webapp/.env.template webapp/.env
- name: backend | copy env files backend cp backend/.env.template backend/.env
run: cp backend/.env.template backend/.env
- name: backend | docker-compose - name: backend | docker-compose
run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps webapp run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps webapp
@ -99,3 +98,18 @@ jobs:
- name: webapp | Unit tests incl. coverage check - name: webapp | Unit tests incl. coverage check
run: docker-compose exec -T webapp yarn test run: docker-compose exec -T webapp yarn test
cleanup:
name: Cleanup
if: ${{ needs.files-changed.outputs.docker == 'true' || needs.files-changed.outputs.webapp == 'true' }}
needs: [files-changed, unit_test_webapp]
runs-on: ubuntu-latest
continue-on-error: true
steps:
- name: Delete cache
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh extension install actions/gh-actions-cache
KEY="${{ github.run_id }}-webapp-cache"
gh actions-cache delete $KEY -R Ocelot-Social-Community/Ocelot-Social --confirm

View File

@ -1,25 +1,219 @@
module.exports = { module.exports = {
root: true,
env: { env: {
es6: true, // es6: true,
node: true, node: true,
jest: true
}, },
parserOptions: { /* parserOptions: {
parser: 'babel-eslint' parser: 'babel-eslint'
}, },*/
parser: '@typescript-eslint/parser',
plugins: ['prettier', '@typescript-eslint' /*, 'import', 'n', 'promise'*/],
extends: [ extends: [
'standard', 'standard',
'plugin:prettier/recommended' // 'eslint:recommended',
'plugin:prettier/recommended',
// 'plugin:import/recommended',
// 'plugin:import/typescript',
// 'plugin:security/recommended',
// 'plugin:@eslint-community/eslint-comments/recommended',
], ],
plugins: [ settings: {
'jest' 'import/parsers': {
], '@typescript-eslint/parser': ['.ts', '.tsx'],
rules: { },
'import/resolver': {
typescript: {
project: ['./tsconfig.json'],
},
node: true,
},
},
/* rules: {
//'indent': [ 'error', 2 ], //'indent': [ 'error', 2 ],
//'quotes': [ "error", "single"], //'quotes': [ "error", "single"],
// 'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off', // 'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off',
'no-console': ['error'], > 'no-console': ['error'],
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off', > 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
'prettier/prettier': ['error'], > 'prettier/prettier': ['error'],
}, */
rules: {
'no-console': 'error',
camelcase: 'error',
'no-debugger': 'error',
'prettier/prettier': [
'error',
{
htmlWhitespaceSensitivity: 'ignore',
},
],
// import
// 'import/export': 'error',
// 'import/no-deprecated': 'error',
// 'import/no-empty-named-blocks': 'error',
// 'import/no-extraneous-dependencies': 'error',
// 'import/no-mutable-exports': 'error',
// 'import/no-unused-modules': 'error',
// 'import/no-named-as-default': 'error',
// 'import/no-named-as-default-member': 'error',
// 'import/no-amd': 'error',
// 'import/no-commonjs': 'error',
// 'import/no-import-module-exports': 'error',
// 'import/no-nodejs-modules': 'off',
// 'import/unambiguous': 'error',
// 'import/default': 'error',
// 'import/named': 'error',
// 'import/namespace': 'error',
// 'import/no-absolute-path': 'error',
// 'import/no-cycle': 'error',
// 'import/no-dynamic-require': 'error',
// 'import/no-internal-modules': 'off',
// 'import/no-relative-packages': 'error',
// 'import/no-relative-parent-imports': ['error', { ignore: ['@/*'] }],
// 'import/no-self-import': 'error',
// 'import/no-unresolved': 'error',
// 'import/no-useless-path-segments': 'error',
// 'import/no-webpack-loader-syntax': 'error',
// 'import/consistent-type-specifier-style': 'error',
// 'import/exports-last': 'off',
// 'import/extensions': 'error',
// 'import/first': 'error',
// 'import/group-exports': 'off',
// 'import/newline-after-import': 'error',
// 'import/no-anonymous-default-export': 'error',
// 'import/no-default-export': 'error',
// 'import/no-duplicates': 'error',
// 'import/no-named-default': 'error',
// 'import/no-namespace': 'error',
// 'import/no-unassigned-import': 'error',
// 'import/order': [
// 'error',
// {
// groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index', 'object', 'type'],
// 'newlines-between': 'always',
// pathGroups: [
// {
// pattern: '@?*/**',
// group: 'external',
// position: 'after',
// },
// {
// pattern: '@/**',
// group: 'external',
// position: 'after',
// },
// ],
// alphabetize: {
// order: 'asc' /* sort in ascending order. Options: ['ignore', 'asc', 'desc'] */,
// caseInsensitive: true /* ignore case. Options: [true, false] */,
// },
// distinctGroup: true,
// },
// ],
// 'import/prefer-default-export': 'off',
// n
// 'n/handle-callback-err': 'error',
// 'n/no-callback-literal': 'error',
// 'n/no-exports-assign': 'error',
// 'n/no-extraneous-import': 'error',
// 'n/no-extraneous-require': 'error',
// 'n/no-hide-core-modules': 'error',
// 'n/no-missing-import': 'off', // not compatible with typescript
// 'n/no-missing-require': 'error',
// 'n/no-new-require': 'error',
// 'n/no-path-concat': 'error',
// 'n/no-process-exit': 'error',
// 'n/no-unpublished-bin': 'error',
// 'n/no-unpublished-import': 'off', // TODO need to exclude seeds
// 'n/no-unpublished-require': 'error',
// 'n/no-unsupported-features': ['error', { ignores: ['modules'] }],
// 'n/no-unsupported-features/es-builtins': 'error',
// 'n/no-unsupported-features/es-syntax': 'error',
// 'n/no-unsupported-features/node-builtins': 'error',
// 'n/process-exit-as-throw': 'error',
// 'n/shebang': 'error',
// 'n/callback-return': 'error',
// 'n/exports-style': 'error',
// 'n/file-extension-in-import': 'off',
// 'n/global-require': 'error',
// 'n/no-mixed-requires': 'error',
// 'n/no-process-env': 'error',
// 'n/no-restricted-import': 'error',
// 'n/no-restricted-require': 'error',
// 'n/no-sync': 'error',
// 'n/prefer-global/buffer': 'error',
// 'n/prefer-global/console': 'error',
// 'n/prefer-global/process': 'error',
// 'n/prefer-global/text-decoder': 'error',
// 'n/prefer-global/text-encoder': 'error',
// 'n/prefer-global/url': 'error',
// 'n/prefer-global/url-search-params': 'error',
// 'n/prefer-promises/dns': 'error',
// 'n/prefer-promises/fs': 'error',
// promise
// 'promise/catch-or-return': 'error',
// 'promise/no-return-wrap': 'error',
// 'promise/param-names': 'error',
// 'promise/always-return': 'error',
// 'promise/no-native': 'off',
// 'promise/no-nesting': 'warn',
// 'promise/no-promise-in-callback': 'warn',
// 'promise/no-callback-in-promise': 'warn',
// 'promise/avoid-new': 'warn',
// 'promise/no-new-statics': 'error',
// 'promise/no-return-in-finally': 'warn',
// 'promise/valid-params': 'warn',
// 'promise/prefer-await-to-callbacks': 'error',
// 'promise/no-multiple-resolved': 'error',
// eslint comments
// '@eslint-community/eslint-comments/disable-enable-pair': ['error', { allowWholeFile: true }],
// '@eslint-community/eslint-comments/no-restricted-disable': 'error',
// '@eslint-community/eslint-comments/no-use': 'off',
// '@eslint-community/eslint-comments/require-description': 'off',
}, },
overrides: [
// only for ts files
{
files: ['*.ts', '*.tsx'],
extends: [
// 'plugin:@typescript-eslint/recommended',
// 'plugin:@typescript-eslint/recommended-requiring-type-checking',
// 'plugin:@typescript-eslint/strict',
],
rules: {
// allow explicitly defined dangling promises
// '@typescript-eslint/no-floating-promises': ['error', { ignoreVoid: true }],
'no-void': ['error', { allowAsStatement: true }],
// ignore prefer-regexp-exec rule to allow string.match(regex)
'@typescript-eslint/prefer-regexp-exec': 'off',
// this should not run on ts files: https://github.com/import-js/eslint-plugin-import/issues/2215#issuecomment-911245486
'import/unambiguous': 'off',
// this is not compatible with typeorm, due to joined tables can be null, but are not defined as nullable
'@typescript-eslint/no-unnecessary-condition': 'off',
},
parserOptions: {
tsconfigRootDir: __dirname,
project: ['./tsconfig.json'],
// this is to properly reference the referenced project database without requirement of compiling it
// eslint-disable-next-line camelcase
EXPERIMENTAL_useSourceOfProjectReferenceRedirect: true,
},
},
{
files: ['*.spec.ts'],
plugins: ['jest'],
env: {
jest: true,
},
rules: {
'jest/no-disabled-tests': 'error',
'jest/no-focused-tests': 'error',
'jest/no-identical-title': 'error',
'jest/prefer-to-have-length': 'error',
'jest/valid-expect': 'error',
'@typescript-eslint/unbound-method': 'off',
// 'jest/unbound-method': 'error',
},
},
],
}; };

View File

@ -11,7 +11,7 @@ module.exports = {
], ],
coverageThreshold: { coverageThreshold: {
global: { global: {
lines: 70, lines: 67,
}, },
}, },
testMatch: ['**/src/**/?(*.)+(spec|test).ts?(x)'], testMatch: ['**/src/**/?(*.)+(spec|test).ts?(x)'],

View File

@ -9,12 +9,12 @@
"main": "src/index.ts", "main": "src/index.ts",
"scripts": { "scripts": {
"__migrate": "migrate --compiler 'ts:./src/db/compiler.ts' --migrations-dir ./src/db/migrations", "__migrate": "migrate --compiler 'ts:./src/db/compiler.ts' --migrations-dir ./src/db/migrations",
"prod:migrate": "migrate --migrations-dir ./build/db/migrations --store ./build/db/migrate/store.js", "prod:migrate": "migrate --migrations-dir ./build/src/db/migrations --store ./build/src/db/migrate/store.js",
"start": "node build/", "start": "node build/src/",
"build": "tsc && ./scripts/build.copy.files.sh", "build": "tsc && ./scripts/build.copy.files.sh",
"dev": "nodemon --exec ts-node src/ -e js,ts,gql", "dev": "nodemon --exec ts-node src/ -e js,ts,gql",
"dev:debug": "nodemon --exec babel-node --inspect=0.0.0.0:9229 src/ -e js,ts,gql", "dev:debug": "nodemon --exec babel-node --inspect=0.0.0.0:9229 src/ -e js,ts,gql",
"lint": "eslint src --config .eslintrc.js", "lint": "eslint --max-warnings=0 --ext .js,.ts ./src",
"test": "cross-env NODE_ENV=test NODE_OPTIONS=--max-old-space-size=8192 jest --runInBand --coverage --forceExit --detectOpenHandles", "test": "cross-env NODE_ENV=test NODE_OPTIONS=--max-old-space-size=8192 jest --runInBand --coverage --forceExit --detectOpenHandles",
"db:clean": "ts-node src/db/clean.ts", "db:clean": "ts-node src/db/clean.ts",
"db:reset": "yarn run db:clean", "db:reset": "yarn run db:clean",
@ -45,7 +45,6 @@
"cheerio": "~1.0.0-rc.3", "cheerio": "~1.0.0-rc.3",
"cors": "~2.8.5", "cors": "~2.8.5",
"cross-env": "~7.0.3", "cross-env": "~7.0.3",
"debug": "~4.1.1",
"dotenv": "~8.2.0", "dotenv": "~8.2.0",
"express": "^4.17.1", "express": "^4.17.1",
"graphql": "^14.6.0", "graphql": "^14.6.0",
@ -97,27 +96,30 @@
}, },
"devDependencies": { "devDependencies": {
"@faker-js/faker": "7.6.0", "@faker-js/faker": "7.6.0",
"@types/jest": "^29.5.2", "@types/jest": "^27.0.2",
"@types/node": "^20.2.5", "@types/node": "^20.2.5",
"@typescript-eslint/eslint-plugin": "^5.57.1",
"@typescript-eslint/parser": "^5.57.1",
"apollo-server-testing": "~2.11.0", "apollo-server-testing": "~2.11.0",
"chai": "~4.2.0", "chai": "~4.2.0",
"cucumber": "~6.0.5", "cucumber": "~6.0.5",
"eslint": "~6.8.0", "eslint": "^8.37.0",
"eslint-config-prettier": "~6.15.0", "eslint-config-prettier": "^8.8.0",
"eslint-config-standard": "~14.1.1", "eslint-config-standard": "^17.0.0",
"eslint-plugin-import": "~2.20.2", "eslint-import-resolver-typescript": "^3.5.4",
"eslint-plugin-jest": "~23.8.2", "eslint-plugin-import": "^2.27.5",
"eslint-plugin-node": "~11.1.0", "eslint-plugin-jest": "^27.2.1",
"eslint-plugin-prettier": "~3.4.1", "eslint-plugin-n": "^15.7.0",
"eslint-plugin-promise": "~4.3.1", "eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-standard": "~4.0.1", "eslint-plugin-promise": "^6.1.1",
"jest": "29.4", "eslint-plugin-security": "^1.7.1",
"prettier": "^2.8.7",
"jest": "^27.2.4",
"nodemon": "~2.0.2", "nodemon": "~2.0.2",
"prettier": "~2.3.2",
"rosie": "^2.0.1", "rosie": "^2.0.1",
"ts-jest": "^29.1.0", "ts-jest": "^27.0.5",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"typescript": "^5.0.4" "typescript": "^4.9.4"
}, },
"resolutions": { "resolutions": {
"**/**/fs-capacitor": "^6.2.0", "**/**/fs-capacitor": "^6.2.0",

View File

@ -1,24 +1,24 @@
#!/bin/sh #!/bin/sh
# html files # html files
mkdir -p build/middleware/helpers/email/templates/ mkdir -p build/src/middleware/helpers/email/templates/
cp -r src/middleware/helpers/email/templates/*.html build/middleware/helpers/email/templates/ cp -r src/middleware/helpers/email/templates/*.html build/src/middleware/helpers/email/templates/
mkdir -p build/middleware/helpers/email/templates/en/ mkdir -p build/src/middleware/helpers/email/templates/en/
cp -r src/middleware/helpers/email/templates/en/*.html build/middleware/helpers/email/templates/en/ cp -r src/middleware/helpers/email/templates/en/*.html build/src/middleware/helpers/email/templates/en/
mkdir -p build/middleware/helpers/email/templates/de/ mkdir -p build/src/middleware/helpers/email/templates/de/
cp -r src/middleware/helpers/email/templates/de/*.html build/middleware/helpers/email/templates/de/ cp -r src/middleware/helpers/email/templates/de/*.html build/src/middleware/helpers/email/templates/de/
# gql files # gql files
mkdir -p build/schema/types/ mkdir -p build/src/schema/types/
cp -r src/schema/types/*.gql build/schema/types/ cp -r src/schema/types/*.gql build/src/schema/types/
mkdir -p build/schema/types/enum/ mkdir -p build/src/schema/types/enum/
cp -r src/schema/types/enum/*.gql build/schema/types/enum/ cp -r src/schema/types/enum/*.gql build/src/schema/types/enum/
mkdir -p build/schema/types/scalar/ mkdir -p build/src/schema/types/scalar/
cp -r src/schema/types/scalar/*.gql build/schema/types/scalar/ cp -r src/schema/types/scalar/*.gql build/src/schema/types/scalar/
mkdir -p build/schema/types/type/ mkdir -p build/src/schema/types/type/
cp -r src/schema/types/type/*.gql build/schema/types/type/ cp -r src/schema/types/type/*.gql build/src/schema/types/type/

View File

@ -15,7 +15,7 @@ if (require.resolve) {
} }
// Use Cypress env or process.env // Use Cypress env or process.env
declare var Cypress: any | undefined declare let Cypress: any | undefined
const env = typeof Cypress !== 'undefined' ? Cypress.env() : process.env // eslint-disable-line no-undef const env = typeof Cypress !== 'undefined' ? Cypress.env() : process.env // eslint-disable-line no-undef
const environment = { const environment = {
@ -95,6 +95,7 @@ Object.entries(required).map((entry) => {
if (!entry[1]) { if (!entry[1]) {
throw new Error(`ERROR: "${entry[0]}" env variable is missing.`) throw new Error(`ERROR: "${entry[0]}" env variable is missing.`)
} }
return entry
}) })
export default { export default {

View File

@ -1,2 +1,2 @@
const tsNode = require('ts-node'); const tsNode = require('ts-node')
module.exports = tsNode.register; module.exports = tsNode.register

File diff suppressed because it is too large Load Diff

View File

@ -2,16 +2,17 @@ import gql from 'graphql-tag'
export const createMessageMutation = () => { export const createMessageMutation = () => {
return gql` return gql`
mutation ( mutation ($roomId: ID!, $content: String!) {
$roomId: ID! CreateMessage(roomId: $roomId, content: $content) {
$content: String!
) {
CreateMessage(
roomId: $roomId
content: $content
) {
id id
content content
senderId
username
avatar
date
saved
distributed
seen
} }
} }
` `
@ -19,16 +20,31 @@ export const createMessageMutation = () => {
export const messageQuery = () => { export const messageQuery = () => {
return gql` return gql`
query($roomId: ID!) { query ($roomId: ID!, $first: Int, $offset: Int) {
Message(roomId: $roomId) { Message(roomId: $roomId, first: $first, offset: $offset, orderBy: indexId_desc) {
_id _id
id id
indexId
content content
senderId senderId
author {
id
}
username username
avatar avatar
date date
saved
distributed
seen
} }
} }
` `
} }
export const markMessagesAsSeen = () => {
return gql`
mutation ($messageIds: [String!]) {
MarkMessagesAsSeen(messageIds: $messageIds)
}
`
}

View File

@ -2,26 +2,14 @@ import gql from 'graphql-tag'
export const createRoomMutation = () => { export const createRoomMutation = () => {
return gql` return gql`
mutation ( mutation ($userId: ID!) {
$userId: ID! CreateRoom(userId: $userId) {
) {
CreateRoom(
userId: $userId
) {
id
roomId
}
}
`
}
export const roomQuery = () => {
return gql`
query {
Room {
id id
roomId roomId
roomName roomName
lastMessageAt
unreadCount
#avatar
users { users {
_id _id
id id
@ -34,3 +22,46 @@ export const roomQuery = () => {
} }
` `
} }
export const roomQuery = () => {
return gql`
query Room($first: Int, $offset: Int, $id: ID) {
Room(first: $first, offset: $offset, id: $id, orderBy: lastMessageAt_desc) {
id
roomId
roomName
avatar
lastMessageAt
unreadCount
lastMessage {
_id
id
content
senderId
username
avatar
date
saved
distributed
seen
}
users {
_id
id
name
avatar {
url
}
}
}
}
`
}
export const unreadRoomsQuery = () => {
return gql`
query {
UnreadRooms
}
`
}

View File

@ -9,10 +9,9 @@ function walkRecursive(data, fields, fieldName, callback, _key?) {
if (!Array.isArray(fields)) { if (!Array.isArray(fields)) {
throw new Error('please provide an fields array for the walkRecursive helper') throw new Error('please provide an fields array for the walkRecursive helper')
} }
if (data && typeof data === 'string' && fields.includes(_key)) { const fieldDef = fields.find((f) => f.field === _key)
// well we found what we searched for, lets replace the value with our callback result if (data && typeof data === 'string' && fieldDef) {
const key = _key.split('!') if (!fieldDef.excludes?.includes(fieldName)) data = callback(data, _key)
if (key.length === 1 || key[1] !== fieldName) data = callback(data, key[0])
} else if (data && Array.isArray(data)) { } else if (data && Array.isArray(data)) {
// go into the rabbit hole and dig through that array // go into the rabbit hole and dig through that array
data.forEach((res, index) => { data.forEach((res, index) => {

View File

@ -0,0 +1,60 @@
import { isArray } from 'lodash'
const setRoomProps = (room) => {
if (room.users) {
room.users.forEach((user) => {
user._id = user.id
})
}
if (room.lastMessage) {
room.lastMessage._id = room.lastMessage.id
}
}
const setMessageProps = (message, context) => {
message._id = message.id
if (message.senderId !== context.user.id) {
message.distributed = true
}
}
const roomProperties = async (resolve, root, args, context, info) => {
const resolved = await resolve(root, args, context, info)
if (resolved) {
if (isArray(resolved)) {
resolved.forEach((room) => {
setRoomProps(room)
})
} else {
setRoomProps(resolved)
}
}
return resolved
}
const messageProperties = async (resolve, root, args, context, info) => {
const resolved = await resolve(root, args, context, info)
if (resolved) {
if (isArray(resolved)) {
resolved.forEach((message) => {
setMessageProps(message, context)
})
} else {
setMessageProps(resolved, context)
}
}
return resolved
}
export default {
Query: {
Room: roomProperties,
Message: messageProperties,
},
Mutation: {
CreateRoom: roomProperties,
},
Subscription: {
chatMessageAdded: messageProperties,
},
}

View File

@ -30,6 +30,7 @@ const standardSanitizeHtmlOptions = {
'strike', 'strike',
'span', 'span',
'blockquote', 'blockquote',
'usertag',
], ],
allowedAttributes: { allowedAttributes: {
a: ['href', 'class', 'target', 'data-*', 'contenteditable'], a: ['href', 'class', 'target', 'data-*', 'contenteditable'],

View File

@ -14,6 +14,7 @@ import login from './login/loginMiddleware'
import sentry from './sentryMiddleware' import sentry from './sentryMiddleware'
import languages from './languages/languages' import languages from './languages/languages'
import userInteractions from './userInteractions' import userInteractions from './userInteractions'
import chatMiddleware from './chatMiddleware'
export default (schema) => { export default (schema) => {
const middlewares = { const middlewares = {
@ -31,6 +32,7 @@ export default (schema) => {
orderBy, orderBy,
languages, languages,
userInteractions, userInteractions,
chatMiddleware,
} }
let order = [ let order = [
@ -49,6 +51,7 @@ export default (schema) => {
'softDelete', 'softDelete',
'includedFields', 'includedFields',
'orderBy', 'orderBy',
'chatMiddleware',
] ]
// add permisions middleware at the first position (unless we're seeding) // add permisions middleware at the first position (unless we're seeding)

View File

@ -1,6 +1,6 @@
import cheerio from 'cheerio' import cheerio from 'cheerio'
export default (content) => { export default (content?) => {
if (!content) return [] if (!content) return []
const $ = cheerio.load(content) const $ = cheerio.load(content)
const userIds = $('a.mention[data-mention-id]') const userIds = $('a.mention[data-mention-id]')

View File

@ -50,7 +50,7 @@ beforeAll(async () => {
context: () => { context: () => {
return { return {
user: authenticatedUser, user: authenticatedUser,
neode: neode, neode,
driver, driver,
} }
}, },

View File

@ -140,16 +140,18 @@ const postAuthorOfComment = async (commentId, { context }) => {
const notifyOwnersOfGroup = async (groupId, userId, reason, context) => { const notifyOwnersOfGroup = async (groupId, userId, reason, context) => {
const cypher = ` const cypher = `
MATCH (user:User { id: $userId })
MATCH (group:Group { id: $groupId })<-[membership:MEMBER_OF]-(owner:User) MATCH (group:Group { id: $groupId })<-[membership:MEMBER_OF]-(owner:User)
WHERE membership.role = 'owner' WHERE membership.role = 'owner'
WITH owner, group WITH owner, group, user, membership
MERGE (group)-[notification:NOTIFIED {reason: $reason}]->(owner) MERGE (group)-[notification:NOTIFIED {reason: $reason}]->(owner)
WITH group, owner, notification WITH group, owner, notification, user, membership
SET notification.read = FALSE SET notification.read = FALSE
SET notification.createdAt = COALESCE(notification.createdAt, toString(datetime())) SET notification.createdAt = COALESCE(notification.createdAt, toString(datetime()))
SET notification.updatedAt = toString(datetime()) SET notification.updatedAt = toString(datetime())
SET notification.relatedUserId = $userId SET notification.relatedUserId = $userId
RETURN notification {.*, from: group, to: properties(owner)} WITH owner, group { __typename: 'Group', .*, myRole: membership.roleInGroup } AS finalGroup, user, notification
RETURN notification {.*, from: finalGroup, to: properties(owner), relatedUser: properties(user) }
` `
const session = context.driver.session() const session = context.driver.session()
const writeTxResultPromise = session.writeTransaction(async (transaction) => { const writeTxResultPromise = session.writeTransaction(async (transaction) => {
@ -173,16 +175,20 @@ const notifyOwnersOfGroup = async (groupId, userId, reason, context) => {
const notifyMemberOfGroup = async (groupId, userId, reason, context) => { const notifyMemberOfGroup = async (groupId, userId, reason, context) => {
const { user: owner } = context const { user: owner } = context
const cypher = ` const cypher = `
MATCH (owner:User { id: $ownerId })
MATCH (user:User { id: $userId }) MATCH (user:User { id: $userId })
MATCH (group:Group { id: $groupId }) MATCH (group:Group { id: $groupId })
WITH user, group OPTIONAL MATCH (user)-[membership:MEMBER_OF]->(group)
WITH user, group, owner, membership
MERGE (group)-[notification:NOTIFIED {reason: $reason}]->(user) MERGE (group)-[notification:NOTIFIED {reason: $reason}]->(user)
WITH group, user, notification WITH group, user, notification, owner, membership
SET notification.read = FALSE SET notification.read = FALSE
SET notification.createdAt = COALESCE(notification.createdAt, toString(datetime())) SET notification.createdAt = COALESCE(notification.createdAt, toString(datetime()))
SET notification.updatedAt = toString(datetime()) SET notification.updatedAt = toString(datetime())
SET notification.relatedUserId = $ownerId SET notification.relatedUserId = $ownerId
RETURN notification {.*, from: group, to: properties(user)} WITH group { __typename: 'Group', .*, myRole: membership.roleInGroup } AS finalGroup,
notification, user, owner
RETURN notification {.*, from: finalGroup, to: properties(user), relatedUser: properties(owner) }
` `
const session = context.driver.session() const session = context.driver.session()
const writeTxResultPromise = session.writeTransaction(async (transaction) => { const writeTxResultPromise = session.writeTransaction(async (transaction) => {
@ -242,7 +248,7 @@ const notifyUsersOfMention = async (label, id, idsOfUsers, reason, context) => {
SET notification.read = FALSE SET notification.read = FALSE
SET notification.createdAt = COALESCE(notification.createdAt, toString(datetime())) SET notification.createdAt = COALESCE(notification.createdAt, toString(datetime()))
SET notification.updatedAt = toString(datetime()) SET notification.updatedAt = toString(datetime())
RETURN notification {.*, from: finalResource, to: properties(user)} RETURN notification {.*, from: finalResource, to: properties(user), relatedUser: properties(user) }
` `
const session = context.driver.session() const session = context.driver.session()
const writeTxResultPromise = session.writeTransaction(async (transaction) => { const writeTxResultPromise = session.writeTransaction(async (transaction) => {
@ -276,9 +282,14 @@ const notifyUsersOfComment = async (label, commentId, postAuthorId, reason, cont
SET notification.read = FALSE SET notification.read = FALSE
SET notification.createdAt = COALESCE(notification.createdAt, toString(datetime())) SET notification.createdAt = COALESCE(notification.createdAt, toString(datetime()))
SET notification.updatedAt = toString(datetime()) SET notification.updatedAt = toString(datetime())
WITH notification, postAuthor, post, WITH notification, postAuthor, post, commenter,
comment {.*, __typename: labels(comment)[0], author: properties(commenter), post: post {.*, author: properties(postAuthor) } } AS finalResource comment {.*, __typename: labels(comment)[0], author: properties(commenter), post: post {.*, author: properties(postAuthor) } } AS finalResource
RETURN notification {.*, from: finalResource, to: properties(postAuthor)} RETURN notification {
.*,
from: finalResource,
to: properties(postAuthor),
relatedUser: properties(commenter)
}
`, `,
{ commentId, postAuthorId, reason }, { commentId, postAuthorId, reason },
) )

View File

@ -408,6 +408,7 @@ export default shield(
getInviteCode: isAuthenticated, // and inviteRegistration getInviteCode: isAuthenticated, // and inviteRegistration
Room: isAuthenticated, Room: isAuthenticated,
Message: isAuthenticated, Message: isAuthenticated,
UnreadRooms: isAuthenticated,
}, },
Mutation: { Mutation: {
'*': deny, '*': deny,
@ -463,6 +464,7 @@ export default shield(
saveCategorySettings: isAuthenticated, saveCategorySettings: isAuthenticated,
CreateRoom: isAuthenticated, CreateRoom: isAuthenticated,
CreateMessage: isAuthenticated, CreateMessage: isAuthenticated,
MarkMessagesAsSeen: isAuthenticated,
}, },
User: { User: {
email: or(isMyOwn, isAdmin), email: or(isMyOwn, isAdmin),

View File

@ -1,22 +1,22 @@
import uniqueSlug from './uniqueSlug' import uniqueSlug from './uniqueSlug'
describe('uniqueSlug', () => { describe('uniqueSlug', () => {
it('slugifies given string', () => { it('slugifies given string', async () => {
const string = 'Hello World' const string = 'Hello World'
const isUnique = jest.fn().mockResolvedValue(true) const isUnique = jest.fn().mockResolvedValue(true)
expect(uniqueSlug(string, isUnique)).resolves.toEqual('hello-world') await expect(uniqueSlug(string, isUnique)).resolves.toEqual('hello-world')
}) })
it('increments slugified string until unique', () => { it('increments slugified string until unique', async () => {
const string = 'Hello World' const string = 'Hello World'
const isUnique = jest.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true) const isUnique = jest.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true)
expect(uniqueSlug(string, isUnique)).resolves.toEqual('hello-world-1') await expect(uniqueSlug(string, isUnique)).resolves.toEqual('hello-world-1')
}) })
it('slugify null string', () => { it('slugify null string', async () => {
const string = null const string = null
const isUnique = jest.fn().mockResolvedValue(true) const isUnique = jest.fn().mockResolvedValue(true)
expect(uniqueSlug(string, isUnique)).resolves.toEqual('anonymous') await expect(uniqueSlug(string, isUnique)).resolves.toEqual('anonymous')
}) })
it('Converts umlaut to a two letter equivalent', async () => { it('Converts umlaut to a two letter equivalent', async () => {

View File

@ -3,11 +3,11 @@ import { cleanHtml } from '../middleware/helpers/cleanHtml'
// exclamation mark separetes field names, that should not be sanitized // exclamation mark separetes field names, that should not be sanitized
const fields = [ const fields = [
'content', { field: 'content', excludes: ['CreateMessage', 'Message'] },
'contentExcerpt', { field: 'contentExcerpt' },
'reasonDescription', { field: 'reasonDescription' },
'description!embed', { field: 'description', excludes: ['embed'] },
'descriptionExcerpt', { field: 'descriptionExcerpt' },
] ]
export default { export default {

View File

@ -1,38 +1,29 @@
// NOTE: We cannot use `fs` here to clean up the code. Cypress breaks on any npm // NOTE: We cannot use `fs` here to clean up the code. Cypress breaks on any npm
// module that is not browser-compatible. Node's `fs` module is server-side only // module that is not browser-compatible. Node's `fs` module is server-side only
declare var Cypress: any | undefined declare let Cypress: any | undefined
export default { export default {
Image: typeof Cypress !== 'undefined' ? require('./Image') : require('./Image').default, Image: typeof Cypress !== 'undefined' ? require('./Image') : require('./Image').default,
Badge: typeof Cypress !== 'undefined' ? require('./Badge') : require('./Badge').default, Badge: typeof Cypress !== 'undefined' ? require('./Badge') : require('./Badge').default,
User: typeof Cypress !== 'undefined' ? require('./User') : require('./User').default, User: typeof Cypress !== 'undefined' ? require('./User') : require('./User').default,
Group: typeof Cypress !== 'undefined' ? require('./Group') : require('./Group').default, Group: typeof Cypress !== 'undefined' ? require('./Group') : require('./Group').default,
EmailAddress: EmailAddress:
typeof Cypress !== 'undefined' typeof Cypress !== 'undefined' ? require('./EmailAddress') : require('./EmailAddress').default,
? require('./EmailAddress')
: require('./EmailAddress').default,
UnverifiedEmailAddress: UnverifiedEmailAddress:
typeof Cypress !== 'undefined' typeof Cypress !== 'undefined'
? require('./UnverifiedEmailAddress') ? require('./UnverifiedEmailAddress')
: require('./UnverifiedEmailAddress').default, : require('./UnverifiedEmailAddress').default,
SocialMedia: SocialMedia:
typeof Cypress !== 'undefined' typeof Cypress !== 'undefined' ? require('./SocialMedia') : require('./SocialMedia').default,
? require('./SocialMedia')
: require('./SocialMedia').default,
Post: typeof Cypress !== 'undefined' ? require('./Post') : require('./Post').default, Post: typeof Cypress !== 'undefined' ? require('./Post') : require('./Post').default,
Comment: Comment: typeof Cypress !== 'undefined' ? require('./Comment') : require('./Comment').default,
typeof Cypress !== 'undefined' ? require('./Comment') : require('./Comment').default, Category: typeof Cypress !== 'undefined' ? require('./Category') : require('./Category').default,
Category:
typeof Cypress !== 'undefined' ? require('./Category') : require('./Category').default,
Tag: typeof Cypress !== 'undefined' ? require('./Tag') : require('./Tag').default, Tag: typeof Cypress !== 'undefined' ? require('./Tag') : require('./Tag').default,
Location: Location: typeof Cypress !== 'undefined' ? require('./Location') : require('./Location').default,
typeof Cypress !== 'undefined' ? require('./Location') : require('./Location').default,
Donations: Donations:
typeof Cypress !== 'undefined' ? require('./Donations') : require('./Donations').default, typeof Cypress !== 'undefined' ? require('./Donations') : require('./Donations').default,
Report: typeof Cypress !== 'undefined' ? require('./Report') : require('./Report').default, Report: typeof Cypress !== 'undefined' ? require('./Report') : require('./Report').default,
Migration: Migration:
typeof Cypress !== 'undefined' ? require('./Migration') : require('./Migration').default, typeof Cypress !== 'undefined' ? require('./Migration') : require('./Migration').default,
InviteCode: InviteCode:
typeof Cypress !== 'undefined' typeof Cypress !== 'undefined' ? require('./InviteCode') : require('./InviteCode').default,
? require('./InviteCode')
: require('./InviteCode').default,
} }

View File

@ -170,6 +170,7 @@ describe('mergeImage', () => {
}) })
}) })
// eslint-disable-next-line jest/no-disabled-tests
it.skip('automatically creates different image sizes', async () => { it.skip('automatically creates different image sizes', async () => {
await expect( await expect(
mergeImage(post, 'HERO_IMAGE', imageInput, { uploadCallback, deleteCallback }), mergeImage(post, 'HERO_IMAGE', imageInput, { uploadCallback, deleteCallback }),

View File

@ -1,13 +1,15 @@
import { createTestClient } from 'apollo-server-testing' import { createTestClient } from 'apollo-server-testing'
import Factory, { cleanDatabase } from '../../db/factories' import Factory, { cleanDatabase } from '../../db/factories'
import { getNeode, getDriver } from '../../db/neo4j' import { getNeode, getDriver } from '../../db/neo4j'
import { createRoomMutation } from '../../graphql/rooms' import { createRoomMutation, roomQuery } from '../../graphql/rooms'
import { createMessageMutation, messageQuery } from '../../graphql/messages' import { createMessageMutation, messageQuery, markMessagesAsSeen } from '../../graphql/messages'
import createServer from '../../server' import createServer, { pubsub } from '../../server'
const driver = getDriver() const driver = getDriver()
const neode = getNeode() const neode = getNeode()
const pubsubSpy = jest.spyOn(pubsub, 'publish')
let query let query
let mutate let mutate
let authenticatedUser let authenticatedUser
@ -22,6 +24,9 @@ beforeAll(async () => {
driver, driver,
neode, neode,
user: authenticatedUser, user: authenticatedUser,
cypherParams: {
currentUserId: authenticatedUser ? authenticatedUser.id : null,
},
} }
}, },
}) })
@ -34,43 +39,44 @@ afterAll(async () => {
driver.close() driver.close()
}) })
describe('Message', () => { describe('Message', () => {
let roomId: string let roomId: string
beforeAll(async () => { beforeAll(async () => {
[chattingUser, otherChattingUser, notChattingUser] = await Promise.all([ ;[chattingUser, otherChattingUser, notChattingUser] = await Promise.all([
Factory.build( Factory.build('user', {
'user', id: 'chatting-user',
{ name: 'Chatting User',
id: 'chatting-user', }),
name: 'Chatting User', Factory.build('user', {
}, id: 'other-chatting-user',
), name: 'Other Chatting User',
Factory.build( }),
'user', Factory.build('user', {
{ id: 'not-chatting-user',
id: 'other-chatting-user', name: 'Not Chatting User',
name: 'Other Chatting User', }),
},
),
Factory.build(
'user',
{
id: 'not-chatting-user',
name: 'Not Chatting User',
},
),
]) ])
}) })
describe('create message', () => { describe('create message', () => {
beforeEach(() => {
jest.clearAllMocks()
})
describe('unauthenticated', () => { describe('unauthenticated', () => {
it('throws authorization error', async () => { it('throws authorization error', async () => {
await expect(mutate({ mutation: createMessageMutation(), variables: { await expect(
roomId: 'some-id', content: 'Some bla bla bla', } })).resolves.toMatchObject({ mutate({
errors: [{ message: 'Not Authorized!' }], mutation: createMessageMutation(),
}) variables: {
roomId: 'some-id',
content: 'Some bla bla bla',
},
}),
).resolves.toMatchObject({
errors: [{ message: 'Not Authorized!' }],
})
}) })
}) })
@ -80,14 +86,22 @@ describe('Message', () => {
}) })
describe('room does not exist', () => { describe('room does not exist', () => {
it('returns null', async () => { it('returns null and does not publish subscription', async () => {
await expect(mutate({ mutation: createMessageMutation(), variables: { await expect(
roomId: 'some-id', content: 'Some bla bla bla', } })).resolves.toMatchObject({ mutate({
errors: undefined, mutation: createMessageMutation(),
data: { variables: {
CreateMessage: null, roomId: 'some-id',
content: 'Some bla bla bla',
}, },
}) }),
).resolves.toMatchObject({
errors: undefined,
data: {
CreateMessage: null,
},
})
expect(pubsubSpy).not.toBeCalled()
}) })
}) })
@ -103,21 +117,107 @@ describe('Message', () => {
}) })
describe('user chats in room', () => { describe('user chats in room', () => {
it('returns the message', async () => { it('returns the message and publishes subscriptions', async () => {
await expect(mutate({ await expect(
mutation: createMessageMutation(), mutate({
variables: { mutation: createMessageMutation(),
roomId, variables: {
roomId,
content: 'Some nice message to other chatting user',
},
}),
).resolves.toMatchObject({
errors: undefined,
data: {
CreateMessage: {
id: expect.any(String),
content: 'Some nice message to other chatting user',
senderId: 'chatting-user',
username: 'Chatting User',
avatar: expect.any(String),
date: expect.any(String),
saved: true,
distributed: false,
seen: false,
},
},
})
expect(pubsubSpy).toBeCalledWith('ROOM_COUNT_UPDATED', {
roomCountUpdated: '1',
userId: 'other-chatting-user',
})
expect(pubsubSpy).toBeCalledWith('CHAT_MESSAGE_ADDED', {
chatMessageAdded: expect.objectContaining({
id: expect.any(String),
content: 'Some nice message to other chatting user', content: 'Some nice message to other chatting user',
} })).resolves.toMatchObject({ senderId: 'chatting-user',
username: 'Chatting User',
avatar: expect.any(String),
date: expect.any(String),
saved: true,
distributed: false,
seen: false,
}),
userId: 'other-chatting-user',
})
})
describe('room is updated as well', () => {
it('has last message set', async () => {
const result = await query({ query: roomQuery() })
await expect(result).toMatchObject({
errors: undefined, errors: undefined,
data: { data: {
CreateMessage: { Room: [
id: expect.any(String), expect.objectContaining({
content: 'Some nice message to other chatting user', lastMessageAt: expect.any(String),
}, unreadCount: 0,
lastMessage: expect.objectContaining({
_id: result.data.Room[0].lastMessage.id,
id: expect.any(String),
content: 'Some nice message to other chatting user',
senderId: 'chatting-user',
username: 'Chatting User',
avatar: expect.any(String),
date: expect.any(String),
saved: true,
distributed: false,
seen: false,
}),
}),
],
}, },
}) })
})
})
describe('unread count for other user', () => {
it('has unread count = 1', async () => {
authenticatedUser = await otherChattingUser.toJson()
await expect(query({ query: roomQuery() })).resolves.toMatchObject({
errors: undefined,
data: {
Room: [
expect.objectContaining({
lastMessageAt: expect.any(String),
unreadCount: 1,
lastMessage: expect.objectContaining({
_id: expect.any(String),
id: expect.any(String),
content: 'Some nice message to other chatting user',
senderId: 'chatting-user',
username: 'Chatting User',
avatar: expect.any(String),
date: expect.any(String),
saved: true,
distributed: false,
seen: false,
}),
}),
],
},
})
})
}) })
}) })
@ -125,19 +225,22 @@ describe('Message', () => {
beforeAll(async () => { beforeAll(async () => {
authenticatedUser = await notChattingUser.toJson() authenticatedUser = await notChattingUser.toJson()
}) })
it('returns null', async () => { it('returns null', async () => {
await expect(mutate({ await expect(
mutation: createMessageMutation(), mutate({
variables: { mutation: createMessageMutation(),
roomId, variables: {
content: 'I have no access to this room!', roomId,
} })).resolves.toMatchObject({ content: 'I have no access to this room!',
errors: undefined,
data: {
CreateMessage: null,
}, },
}) }),
).resolves.toMatchObject({
errors: undefined,
data: {
CreateMessage: null,
},
})
}) })
}) })
}) })
@ -151,14 +254,17 @@ describe('Message', () => {
}) })
it('throws authorization error', async () => { it('throws authorization error', async () => {
await expect(query({ await expect(
query: messageQuery(), query({
variables: { query: messageQuery(),
roomId: 'some-id' } variables: {
})).resolves.toMatchObject({ roomId: 'some-id',
},
}),
).resolves.toMatchObject({
errors: [{ message: 'Not Authorized!' }], errors: [{ message: 'Not Authorized!' }],
}) })
}) })
}) })
describe('authenticated', () => { describe('authenticated', () => {
@ -168,12 +274,14 @@ describe('Message', () => {
describe('room does not exists', () => { describe('room does not exists', () => {
it('returns null', async () => { it('returns null', async () => {
await expect(query({ await expect(
query: messageQuery(), query({
variables: { query: messageQuery(),
roomId: 'some-id' variables: {
}, roomId: 'some-id',
})).resolves.toMatchObject({ },
}),
).resolves.toMatchObject({
errors: undefined, errors: undefined,
data: { data: {
Message: [], Message: [],
@ -193,15 +301,21 @@ describe('Message', () => {
expect(result).toMatchObject({ expect(result).toMatchObject({
errors: undefined, errors: undefined,
data: { data: {
Message: [{ Message: [
id: expect.any(String), {
_id: result.data.Message[0].id, id: expect.any(String),
content: 'Some nice message to other chatting user', _id: result.data.Message[0].id,
senderId: 'chatting-user', indexId: 0,
username: 'Chatting User', content: 'Some nice message to other chatting user',
avatar: expect.any(String), senderId: 'chatting-user',
date: expect.any(String), username: 'Chatting User',
}], avatar: expect.any(String),
date: expect.any(String),
saved: true,
distributed: true,
seen: false,
},
],
}, },
}) })
}) })
@ -213,7 +327,7 @@ describe('Message', () => {
variables: { variables: {
roomId, roomId,
content: 'A nice response message to chatting user', content: 'A nice response message to chatting user',
} },
}) })
authenticatedUser = await chattingUser.toJson() authenticatedUser = await chattingUser.toJson()
await mutate({ await mutate({
@ -221,49 +335,126 @@ describe('Message', () => {
variables: { variables: {
roomId, roomId,
content: 'And another nice message to other chatting user', content: 'And another nice message to other chatting user',
}
})
})
it('returns the messages', async () => {
await expect(query({
query: messageQuery(),
variables: {
roomId,
}, },
})).resolves.toMatchObject({ })
})
it('returns the messages', async () => {
await expect(
query({
query: messageQuery(),
variables: {
roomId,
},
}),
).resolves.toMatchObject({
errors: undefined, errors: undefined,
data: { data: {
Message: [ Message: [
{ expect.objectContaining({
id: expect.any(String), id: expect.any(String),
indexId: 0,
content: 'Some nice message to other chatting user', content: 'Some nice message to other chatting user',
senderId: 'chatting-user', senderId: 'chatting-user',
username: 'Chatting User', username: 'Chatting User',
avatar: expect.any(String), avatar: expect.any(String),
date: expect.any(String), date: expect.any(String),
}, saved: true,
{ distributed: true,
seen: false,
}),
expect.objectContaining({
id: expect.any(String), id: expect.any(String),
indexId: 1,
content: 'A nice response message to chatting user', content: 'A nice response message to chatting user',
senderId: 'other-chatting-user', senderId: 'other-chatting-user',
username: 'Other Chatting User', username: 'Other Chatting User',
avatar: expect.any(String), avatar: expect.any(String),
date: expect.any(String), date: expect.any(String),
}, saved: true,
{ distributed: true,
seen: false,
}),
expect.objectContaining({
id: expect.any(String), id: expect.any(String),
indexId: 2,
content: 'And another nice message to other chatting user', content: 'And another nice message to other chatting user',
senderId: 'chatting-user', senderId: 'chatting-user',
username: 'Chatting User', username: 'Chatting User',
avatar: expect.any(String), avatar: expect.any(String),
date: expect.any(String), date: expect.any(String),
}, saved: true,
distributed: false,
seen: false,
}),
], ],
}, },
}) })
}) })
})
it('returns the messages paginated', async () => {
await expect(
query({
query: messageQuery(),
variables: {
roomId,
first: 2,
offset: 0,
},
}),
).resolves.toMatchObject({
errors: undefined,
data: {
Message: [
expect.objectContaining({
id: expect.any(String),
indexId: 1,
content: 'A nice response message to chatting user',
senderId: 'other-chatting-user',
username: 'Other Chatting User',
avatar: expect.any(String),
date: expect.any(String),
}),
expect.objectContaining({
id: expect.any(String),
indexId: 2,
content: 'And another nice message to other chatting user',
senderId: 'chatting-user',
username: 'Chatting User',
avatar: expect.any(String),
date: expect.any(String),
}),
],
},
})
await expect(
query({
query: messageQuery(),
variables: {
roomId,
first: 2,
offset: 2,
},
}),
).resolves.toMatchObject({
errors: undefined,
data: {
Message: [
expect.objectContaining({
id: expect.any(String),
indexId: 0,
content: 'Some nice message to other chatting user',
senderId: 'chatting-user',
username: 'Chatting User',
avatar: expect.any(String),
date: expect.any(String),
}),
],
},
})
})
})
}) })
describe('room exists, authenticated user not in room', () => { describe('room exists, authenticated user not in room', () => {
@ -272,19 +463,91 @@ describe('Message', () => {
}) })
it('returns null', async () => { it('returns null', async () => {
await expect(query({ await expect(
query: messageQuery(), query({
variables: { query: messageQuery(),
roomId, variables: {
}, roomId,
})).resolves.toMatchObject({ },
}),
).resolves.toMatchObject({
errors: undefined, errors: undefined,
data: { data: {
Message: [], Message: [],
}, },
}) })
}) })
}) })
})
})
describe('marks massges as seen', () => {
describe('unauthenticated', () => {
beforeAll(() => {
authenticatedUser = null
})
it('throws authorization error', async () => {
await expect(
mutate({
mutation: markMessagesAsSeen(),
variables: {
messageIds: ['some-id'],
},
}),
).resolves.toMatchObject({
errors: [{ message: 'Not Authorized!' }],
})
})
})
describe('authenticated', () => {
const messageIds: string[] = []
beforeAll(async () => {
authenticatedUser = await otherChattingUser.toJson()
const msgs = await query({
query: messageQuery(),
variables: {
roomId,
},
})
msgs.data.Message.forEach((m) => messageIds.push(m.id))
})
it('returns true', async () => {
await expect(
mutate({
mutation: markMessagesAsSeen(),
variables: {
messageIds,
},
}),
).resolves.toMatchObject({
errors: undefined,
data: {
MarkMessagesAsSeen: true,
},
})
})
it('has seen prop set to true', async () => {
await expect(
query({
query: messageQuery(),
variables: {
roomId,
},
}),
).resolves.toMatchObject({
data: {
Message: [
expect.objectContaining({ seen: true }),
expect.objectContaining({ seen: false }),
expect.objectContaining({ seen: true }),
],
},
})
})
}) })
}) })
}) })

View File

@ -1,7 +1,36 @@
import { neo4jgraphql } from 'neo4j-graphql-js' import { neo4jgraphql } from 'neo4j-graphql-js'
import Resolver from './helpers/Resolver' import Resolver from './helpers/Resolver'
import { getUnreadRoomsCount } from './rooms'
import { pubsub, ROOM_COUNT_UPDATED, CHAT_MESSAGE_ADDED } from '../../server'
import { withFilter } from 'graphql-subscriptions'
const setMessagesAsDistributed = async (undistributedMessagesIds, session) => {
return session.writeTransaction(async (transaction) => {
const setDistributedCypher = `
MATCH (m:Message) WHERE m.id IN $undistributedMessagesIds
SET m.distributed = true
RETURN m { .* }
`
const setDistributedTxResponse = await transaction.run(setDistributedCypher, {
undistributedMessagesIds,
})
const messages = await setDistributedTxResponse.records.map((record) => record.get('m'))
return messages
})
}
export default { export default {
Subscription: {
chatMessageAdded: {
subscribe: withFilter(
() => pubsub.asyncIterator(CHAT_MESSAGE_ADDED),
(payload, variables, context) => {
return payload.userId === context.user?.id
},
),
},
},
Query: { Query: {
Message: async (object, params, context, resolveInfo) => { Message: async (object, params, context, resolveInfo) => {
const { roomId } = params const { roomId } = params
@ -13,47 +42,121 @@ export default {
id: context.user.id, id: context.user.id,
}, },
} }
const resolved = await neo4jgraphql(object, params, context, resolveInfo) const resolved = await neo4jgraphql(object, params, context, resolveInfo)
if (resolved) { if (resolved) {
resolved.forEach((message) => { const undistributedMessagesIds = resolved
message._id = message.id .filter((msg) => !msg.distributed && msg.senderId !== context.user.id)
}) .map((msg) => msg.id)
const session = context.driver.session()
try {
if (undistributedMessagesIds.length > 0) {
await setMessagesAsDistributed(undistributedMessagesIds, session)
}
} finally {
session.close()
}
// send subscription to author to updated the messages
} }
return resolved return resolved.reverse()
}, },
}, },
Mutation: { Mutation: {
CreateMessage: async (_parent, params, context, _resolveInfo) => { CreateMessage: async (_parent, params, context, _resolveInfo) => {
const { roomId, content } = params const { roomId, content } = params
const { user: { id: currentUserId } } = context const {
user: { id: currentUserId },
} = context
const session = context.driver.session() const session = context.driver.session()
const writeTxResultPromise = session.writeTransaction(async (transaction) => { const writeTxResultPromise = session.writeTransaction(async (transaction) => {
const createMessageCypher = ` const createMessageCypher = `
MATCH (currentUser:User { id: $currentUserId })-[:CHATS_IN]->(room:Room { id: $roomId }) MATCH (currentUser:User { id: $currentUserId })-[:CHATS_IN]->(room:Room { id: $roomId })
OPTIONAL MATCH (currentUser)-[:AVATAR_IMAGE]->(image:Image)
OPTIONAL MATCH (m:Message)-[:INSIDE]->(room)
OPTIONAL MATCH (room)<-[:CHATS_IN]-(recipientUser:User)
WHERE NOT recipientUser.id = $currentUserId
WITH MAX(m.indexId) as maxIndex, room, currentUser, image, recipientUser
CREATE (currentUser)-[:CREATED]->(message:Message { CREATE (currentUser)-[:CREATED]->(message:Message {
createdAt: toString(datetime()), createdAt: toString(datetime()),
id: apoc.create.uuid(), id: apoc.create.uuid(),
content: $content indexId: CASE WHEN maxIndex IS NOT NULL THEN maxIndex + 1 ELSE 0 END,
content: LEFT($content,2000),
saved: true,
distributed: false,
seen: false
})-[:INSIDE]->(room) })-[:INSIDE]->(room)
RETURN message { .* } SET room.lastMessageAt = toString(datetime())
RETURN message {
.*,
indexId: toString(message.indexId),
recipientId: recipientUser.id,
senderId: currentUser.id,
username: currentUser.name,
avatar: image.url,
date: message.createdAt
}
` `
const createMessageTxResponse = await transaction.run( const createMessageTxResponse = await transaction.run(createMessageCypher, {
createMessageCypher, currentUserId,
{ currentUserId, roomId, content } roomId,
) content,
})
const [message] = await createMessageTxResponse.records.map((record) => const [message] = await createMessageTxResponse.records.map((record) =>
record.get('message'), record.get('message'),
) )
return message return message
}) })
try { try {
const message = await writeTxResultPromise const message = await writeTxResultPromise
if (message) {
const roomCountUpdated = await getUnreadRoomsCount(message.recipientId, session)
// send subscriptions
void pubsub.publish(ROOM_COUNT_UPDATED, {
roomCountUpdated,
userId: message.recipientId,
})
void pubsub.publish(CHAT_MESSAGE_ADDED, {
chatMessageAdded: message,
userId: message.recipientId,
})
}
return message return message
} catch (error) { } catch (error) {
throw new Error(error) throw new Error(error)
} finally { } finally {
session.close() session.close()
} }
},
MarkMessagesAsSeen: async (_parent, params, context, _resolveInfo) => {
const { messageIds } = params
const currentUserId = context.user.id
const session = context.driver.session()
const writeTxResultPromise = session.writeTransaction(async (transaction) => {
const setSeenCypher = `
MATCH (m:Message)<-[:CREATED]-(user:User)
WHERE m.id IN $messageIds AND NOT user.id = $currentUserId
SET m.seen = true
RETURN m { .* }
`
const setSeenTxResponse = await transaction.run(setSeenCypher, {
messageIds,
currentUserId,
})
const messages = await setSeenTxResponse.records.map((record) => record.get('m'))
return messages
})
try {
await writeTxResultPromise
// send subscription to author to updated the messages
return true
} finally {
session.close()
}
}, },
}, },
Message: { Message: {
@ -61,7 +164,7 @@ export default {
hasOne: { hasOne: {
author: '<-[:CREATED]-(related:User)', author: '<-[:CREATED]-(related:User)',
room: '-[:INSIDE]->(related:Room)', room: '-[:INSIDE]->(related:Room)',
} },
}), }),
} },
} }

View File

@ -238,7 +238,7 @@ describe('given some notifications', () => {
variables: { ...variables, read: false }, variables: { ...variables, read: false },
}) })
await expect(response).toMatchObject(expected) await expect(response).toMatchObject(expected)
await expect(response.data.notifications.length).toEqual(2) // double-check await expect(response.data.notifications).toHaveLength(2) // double-check
}) })
describe('if a resource gets deleted', () => { describe('if a resource gets deleted', () => {

View File

@ -7,8 +7,8 @@ export default {
notificationAdded: { notificationAdded: {
subscribe: withFilter( subscribe: withFilter(
() => pubsub.asyncIterator(NOTIFICATION_ADDED), () => pubsub.asyncIterator(NOTIFICATION_ADDED),
(payload, variables) => { (payload, variables, context) => {
return payload.notificationAdded.to.id === variables.userId return payload.notificationAdded.to.id === context.user?.id
}, },
), ),
}, },

View File

@ -907,6 +907,7 @@ describe('UpdatePost', () => {
}) })
}) })
// eslint-disable-next-line jest/no-disabled-tests
describe.skip('params.image', () => { describe.skip('params.image', () => {
describe('is object', () => { describe('is object', () => {
beforeEach(() => { beforeEach(() => {

View File

@ -28,7 +28,7 @@ export default {
}, },
SignupVerification: async (_parent, args, context) => { SignupVerification: async (_parent, args, context) => {
const { termsAndConditionsAgreedVersion } = args const { termsAndConditionsAgreedVersion } = args
const regEx = new RegExp(/^[0-9]+\.[0-9]+\.[0-9]+$/g) const regEx = /^[0-9]+\.[0-9]+\.[0-9]+$/g
if (!regEx.test(termsAndConditionsAgreedVersion)) { if (!regEx.test(termsAndConditionsAgreedVersion)) {
throw new UserInputError('Invalid version format!') throw new UserInputError('Invalid version format!')
} }

View File

@ -728,7 +728,7 @@ describe('file a report on a resource', () => {
describe('unauthenticated', () => { describe('unauthenticated', () => {
it('throws authorization error', async () => { it('throws authorization error', async () => {
authenticatedUser = null authenticatedUser = null
expect(query({ query: reportsQuery })).resolves.toMatchObject({ await expect(query({ query: reportsQuery })).resolves.toMatchObject({
data: { reports: null }, data: { reports: null },
errors: [{ message: 'Not Authorized!' }], errors: [{ message: 'Not Authorized!' }],
}) })
@ -738,7 +738,7 @@ describe('file a report on a resource', () => {
describe('authenticated', () => { describe('authenticated', () => {
it('role "user" gets no reports', async () => { it('role "user" gets no reports', async () => {
authenticatedUser = await currentUser.toJson() authenticatedUser = await currentUser.toJson()
expect(query({ query: reportsQuery })).resolves.toMatchObject({ await expect(query({ query: reportsQuery })).resolves.toMatchObject({
data: { reports: null }, data: { reports: null },
errors: [{ message: 'Not Authorized!' }], errors: [{ message: 'Not Authorized!' }],
}) })

View File

@ -1,7 +1,8 @@
import { createTestClient } from 'apollo-server-testing' import { createTestClient } from 'apollo-server-testing'
import Factory, { cleanDatabase } from '../../db/factories' import Factory, { cleanDatabase } from '../../db/factories'
import { getNeode, getDriver } from '../../db/neo4j' import { getNeode, getDriver } from '../../db/neo4j'
import { createRoomMutation, roomQuery } from '../../graphql/rooms' import { createRoomMutation, roomQuery, unreadRoomsQuery } from '../../graphql/rooms'
import { createMessageMutation } from '../../graphql/messages'
import createServer from '../../server' import createServer from '../../server'
const driver = getDriver() const driver = getDriver()
@ -21,6 +22,9 @@ beforeAll(async () => {
driver, driver,
neode, neode,
user: authenticatedUser, user: authenticatedUser,
cypherParams: {
currentUserId: authenticatedUser ? authenticatedUser.id : null,
},
} }
}, },
}) })
@ -34,57 +38,64 @@ afterAll(async () => {
}) })
describe('Room', () => { describe('Room', () => {
let roomId: string
beforeAll(async () => { beforeAll(async () => {
[chattingUser, otherChattingUser, notChattingUser] = await Promise.all([ ;[chattingUser, otherChattingUser, notChattingUser] = await Promise.all([
Factory.build( Factory.build('user', {
'user', id: 'chatting-user',
{ name: 'Chatting User',
id: 'chatting-user', }),
name: 'Chatting User', Factory.build('user', {
}, id: 'other-chatting-user',
), name: 'Other Chatting User',
Factory.build( }),
'user', Factory.build('user', {
{ id: 'not-chatting-user',
id: 'other-chatting-user', name: 'Not Chatting User',
name: 'Other Chatting User', }),
}, Factory.build('user', {
), id: 'second-chatting-user',
Factory.build( name: 'Second Chatting User',
'user', }),
{ Factory.build('user', {
id: 'not-chatting-user', id: 'third-chatting-user',
name: 'Not Chatting User', name: 'Third Chatting User',
}, }),
),
]) ])
}) })
describe('create room', () => { describe('create room', () => {
describe('unauthenticated', () => { describe('unauthenticated', () => {
it('throws authorization error', async () => { it('throws authorization error', async () => {
await expect(mutate({ mutation: createRoomMutation(), variables: { await expect(
userId: 'some-id' } })).resolves.toMatchObject({ mutate({
errors: [{ message: 'Not Authorized!' }], mutation: createRoomMutation(),
}) variables: {
userId: 'some-id',
},
}),
).resolves.toMatchObject({
errors: [{ message: 'Not Authorized!' }],
})
}) })
}) })
describe('authenticated', () => { describe('authenticated', () => {
let roomId: string
beforeAll(async () => { beforeAll(async () => {
authenticatedUser = await chattingUser.toJson() authenticatedUser = await chattingUser.toJson()
}) })
describe('user id does not exist', () => { describe('user id does not exist', () => {
it('returns null', async () => { it('returns null', async () => {
await expect(mutate({ await expect(
mutation: createRoomMutation(), mutate({
variables: { mutation: createRoomMutation(),
userId: 'not-existing-user', variables: {
}, userId: 'not-existing-user',
})).resolves.toMatchObject({ },
}),
).resolves.toMatchObject({
errors: undefined, errors: undefined,
data: { data: {
CreateRoom: null, CreateRoom: null,
@ -92,7 +103,22 @@ describe('Room', () => {
}) })
}) })
}) })
describe('user id is self', () => {
it('throws error', async () => {
await expect(
mutate({
mutation: createRoomMutation(),
variables: {
userId: 'chatting-user',
},
}),
).resolves.toMatchObject({
errors: [{ message: 'Cannot create a room with self' }],
})
})
})
describe('user id exists', () => { describe('user id exists', () => {
it('returns the id of the room', async () => { it('returns the id of the room', async () => {
const result = await mutate({ const result = await mutate({
@ -108,6 +134,26 @@ describe('Room', () => {
CreateRoom: { CreateRoom: {
id: expect.any(String), id: expect.any(String),
roomId: result.data.CreateRoom.id, roomId: result.data.CreateRoom.id,
roomName: 'Other Chatting User',
unreadCount: 0,
users: expect.arrayContaining([
{
_id: 'chatting-user',
id: 'chatting-user',
name: 'Chatting User',
avatar: {
url: expect.any(String),
},
},
{
_id: 'other-chatting-user',
id: 'other-chatting-user',
name: 'Other Chatting User',
avatar: {
url: expect.any(String),
},
},
]),
}, },
}, },
}) })
@ -116,12 +162,14 @@ describe('Room', () => {
describe('create room with same user id', () => { describe('create room with same user id', () => {
it('returns the id of the room', async () => { it('returns the id of the room', async () => {
await expect(mutate({ await expect(
mutation: createRoomMutation(), mutate({
variables: { mutation: createRoomMutation(),
userId: 'other-chatting-user', variables: {
}, userId: 'other-chatting-user',
})).resolves.toMatchObject({ },
}),
).resolves.toMatchObject({
errors: undefined, errors: undefined,
data: { data: {
CreateRoom: { CreateRoom: {
@ -130,7 +178,7 @@ describe('Room', () => {
}, },
}) })
}) })
}) })
}) })
}) })
@ -139,11 +187,11 @@ describe('Room', () => {
beforeAll(() => { beforeAll(() => {
authenticatedUser = null authenticatedUser = null
}) })
it('throws authorization error', async () => { it('throws authorization error', async () => {
await expect(query({ query: roomQuery() })).resolves.toMatchObject({ await expect(query({ query: roomQuery() })).resolves.toMatchObject({
errors: [{ message: 'Not Authorized!' }], errors: [{ message: 'Not Authorized!' }],
}) })
}) })
}) })
@ -194,7 +242,7 @@ describe('Room', () => {
}) })
it('returns the room', async () => { it('returns the room', async () => {
const result = await query({ query: roomQuery() }) const result = await query({ query: roomQuery() })
expect(result).toMatchObject({ expect(result).toMatchObject({
errors: undefined, errors: undefined,
data: { data: {
@ -203,6 +251,7 @@ describe('Room', () => {
id: expect.any(String), id: expect.any(String),
roomId: result.data.Room[0].id, roomId: result.data.Room[0].id,
roomName: 'Chatting User', roomName: 'Chatting User',
unreadCount: 0,
users: expect.arrayContaining([ users: expect.arrayContaining([
{ {
_id: 'chatting-user', _id: 'chatting-user',
@ -241,7 +290,322 @@ describe('Room', () => {
}, },
}) })
}) })
}) })
})
})
describe('unread rooms query', () => {
describe('unauthenticated', () => {
it('throws authorization error', async () => {
authenticatedUser = null
await expect(
query({
query: unreadRoomsQuery(),
}),
).resolves.toMatchObject({
errors: [{ message: 'Not Authorized!' }],
})
})
})
describe('authenticated', () => {
let otherRoomId: string
beforeAll(async () => {
authenticatedUser = await chattingUser.toJson()
const result = await mutate({
mutation: createRoomMutation(),
variables: {
userId: 'not-chatting-user',
},
})
otherRoomId = result.data.CreateRoom.roomId
await mutate({
mutation: createMessageMutation(),
variables: {
roomId: otherRoomId,
content: 'Message to not chatting user',
},
})
await mutate({
mutation: createMessageMutation(),
variables: {
roomId,
content: '1st message to other chatting user',
},
})
await mutate({
mutation: createMessageMutation(),
variables: {
roomId,
content: '2nd message to other chatting user',
},
})
authenticatedUser = await otherChattingUser.toJson()
const result2 = await mutate({
mutation: createRoomMutation(),
variables: {
userId: 'not-chatting-user',
},
})
otherRoomId = result2.data.CreateRoom.roomId
await mutate({
mutation: createMessageMutation(),
variables: {
roomId: otherRoomId,
content: 'Other message to not chatting user',
},
})
})
describe('as chatting user', () => {
it('has 0 unread rooms', async () => {
authenticatedUser = await chattingUser.toJson()
await expect(
query({
query: unreadRoomsQuery(),
}),
).resolves.toMatchObject({
data: {
UnreadRooms: 0,
},
})
})
})
describe('as other chatting user', () => {
it('has 1 unread rooms', async () => {
authenticatedUser = await otherChattingUser.toJson()
await expect(
query({
query: unreadRoomsQuery(),
}),
).resolves.toMatchObject({
data: {
UnreadRooms: 1,
},
})
})
})
describe('as not chatting user', () => {
it('has 2 unread rooms', async () => {
authenticatedUser = await notChattingUser.toJson()
await expect(
query({
query: unreadRoomsQuery(),
}),
).resolves.toMatchObject({
data: {
UnreadRooms: 2,
},
})
})
})
})
})
describe('query several rooms', () => {
beforeAll(async () => {
authenticatedUser = await chattingUser.toJson()
await mutate({
mutation: createRoomMutation(),
variables: {
userId: 'second-chatting-user',
},
})
await mutate({
mutation: createRoomMutation(),
variables: {
userId: 'third-chatting-user',
},
})
})
it('returns the rooms paginated', async () => {
await expect(
query({ query: roomQuery(), variables: { first: 3, offset: 0 } }),
).resolves.toMatchObject({
errors: undefined,
data: {
Room: expect.arrayContaining([
expect.objectContaining({
id: expect.any(String),
roomId: expect.any(String),
roomName: 'Third Chatting User',
lastMessageAt: null,
unreadCount: 0,
lastMessage: null,
users: expect.arrayContaining([
expect.objectContaining({
_id: 'chatting-user',
id: 'chatting-user',
name: 'Chatting User',
avatar: {
url: expect.any(String),
},
}),
expect.objectContaining({
_id: 'third-chatting-user',
id: 'third-chatting-user',
name: 'Third Chatting User',
avatar: {
url: expect.any(String),
},
}),
]),
}),
expect.objectContaining({
id: expect.any(String),
roomId: expect.any(String),
roomName: 'Second Chatting User',
lastMessageAt: null,
unreadCount: 0,
lastMessage: null,
users: expect.arrayContaining([
expect.objectContaining({
_id: 'chatting-user',
id: 'chatting-user',
name: 'Chatting User',
avatar: {
url: expect.any(String),
},
}),
expect.objectContaining({
_id: 'second-chatting-user',
id: 'second-chatting-user',
name: 'Second Chatting User',
avatar: {
url: expect.any(String),
},
}),
]),
}),
expect.objectContaining({
id: expect.any(String),
roomId: expect.any(String),
roomName: 'Other Chatting User',
lastMessageAt: expect.any(String),
unreadCount: 0,
lastMessage: {
_id: expect.any(String),
id: expect.any(String),
content: '2nd message to other chatting user',
senderId: 'chatting-user',
username: 'Chatting User',
avatar: expect.any(String),
date: expect.any(String),
saved: true,
distributed: false,
seen: false,
},
users: expect.arrayContaining([
expect.objectContaining({
_id: 'chatting-user',
id: 'chatting-user',
name: 'Chatting User',
avatar: {
url: expect.any(String),
},
}),
expect.objectContaining({
_id: 'other-chatting-user',
id: 'other-chatting-user',
name: 'Other Chatting User',
avatar: {
url: expect.any(String),
},
}),
]),
}),
]),
},
})
await expect(
query({ query: roomQuery(), variables: { first: 3, offset: 3 } }),
).resolves.toMatchObject({
errors: undefined,
data: {
Room: [
expect.objectContaining({
id: expect.any(String),
roomId: expect.any(String),
roomName: 'Not Chatting User',
users: expect.arrayContaining([
{
_id: 'chatting-user',
id: 'chatting-user',
name: 'Chatting User',
avatar: {
url: expect.any(String),
},
},
{
_id: 'not-chatting-user',
id: 'not-chatting-user',
name: 'Not Chatting User',
avatar: {
url: expect.any(String),
},
},
]),
}),
],
},
})
})
})
describe('query single room', () => {
let result: any = null
beforeAll(async () => {
authenticatedUser = await chattingUser.toJson()
result = await query({ query: roomQuery() })
})
describe('as chatter of room', () => {
it('returns the room', async () => {
expect(
await query({
query: roomQuery(),
variables: { first: 2, offset: 0, id: result.data.Room[0].id },
}),
).toMatchObject({
errors: undefined,
data: {
Room: [
{
id: expect.any(String),
roomId: expect.any(String),
roomName: result.data.Room[0].roomName,
users: expect.any(Array),
},
],
},
})
})
describe('as not chatter of room', () => {
beforeAll(async () => {
authenticatedUser = await notChattingUser.toJson()
})
it('returns no room', async () => {
authenticatedUser = await notChattingUser.toJson()
expect(
await query({
query: roomQuery(),
variables: { first: 2, offset: 0, id: result.data.Room[0].id },
}),
).toMatchObject({
errors: undefined,
data: {
Room: [],
},
})
})
})
}) })
}) })
}) })

View File

@ -1,31 +1,61 @@
import { neo4jgraphql } from 'neo4j-graphql-js' import { neo4jgraphql } from 'neo4j-graphql-js'
import Resolver from './helpers/Resolver' import Resolver from './helpers/Resolver'
import { pubsub, ROOM_COUNT_UPDATED } from '../../server'
import { withFilter } from 'graphql-subscriptions'
export const getUnreadRoomsCount = async (userId, session) => {
return session.readTransaction(async (transaction) => {
const unreadRoomsCypher = `
MATCH (:User { id: $userId })-[:CHATS_IN]->(room:Room)<-[:INSIDE]-(message:Message)<-[:CREATED]-(sender:User)
WHERE NOT sender.id = $userId AND NOT message.seen
RETURN toString(COUNT(DISTINCT room)) AS count
`
const unreadRoomsTxResponse = await transaction.run(unreadRoomsCypher, { userId })
return unreadRoomsTxResponse.records.map((record) => record.get('count'))[0]
})
}
export default { export default {
Subscription: {
roomCountUpdated: {
subscribe: withFilter(
() => pubsub.asyncIterator(ROOM_COUNT_UPDATED),
(payload, variables, context) => {
return payload.userId === context.user?.id
},
),
},
},
Query: { Query: {
Room: async (object, params, context, resolveInfo) => { Room: async (object, params, context, resolveInfo) => {
if (!params.filter) params.filter = {} if (!params.filter) params.filter = {}
params.filter.users_some = { params.filter.users_some = {
id: context.user.id, id: context.user.id,
} }
const resolved = await neo4jgraphql(object, params, context, resolveInfo) return neo4jgraphql(object, params, context, resolveInfo)
if (resolved) { },
resolved.forEach((room) => { UnreadRooms: async (object, params, context, resolveInfo) => {
if (room.users) { const {
room.roomName = room.users.filter((user) => user.id !== context.user.id)[0].name user: { id: currentUserId },
room.users.forEach((user) => { } = context
user._id = user.id const session = context.driver.session()
}) try {
} const count = await getUnreadRoomsCount(currentUserId, session)
}) return count
} finally {
session.close()
} }
return resolved
}, },
}, },
Mutation: { Mutation: {
CreateRoom: async (_parent, params, context, _resolveInfo) => { CreateRoom: async (_parent, params, context, _resolveInfo) => {
const { userId } = params const { userId } = params
const { user: { id: currentUserId } } = context const {
user: { id: currentUserId },
} = context
if (userId === currentUserId) {
throw new Error('Cannot create a room with self')
}
const session = context.driver.session() const session = context.driver.session()
const writeTxResultPromise = session.writeTransaction(async (transaction) => { const writeTxResultPromise = session.writeTransaction(async (transaction) => {
const createRoomCypher = ` const createRoomCypher = `
@ -35,15 +65,23 @@ export default {
ON CREATE SET ON CREATE SET
room.createdAt = toString(datetime()), room.createdAt = toString(datetime()),
room.id = apoc.create.uuid() room.id = apoc.create.uuid()
RETURN room { .* } WITH room, user, currentUser
OPTIONAL MATCH (room)<-[:INSIDE]-(message:Message)<-[:CREATED]-(sender:User)
WHERE NOT sender.id = $currentUserId AND NOT message.seen
WITH room, user, currentUser, message,
user.name AS roomName
RETURN room {
.*,
users: [properties(currentUser), properties(user)],
roomName: roomName,
unreadCount: toString(COUNT(DISTINCT message))
}
` `
const createRommTxResponse = await transaction.run( const createRommTxResponse = await transaction.run(createRoomCypher, {
createRoomCypher, userId,
{ userId, currentUserId } currentUserId,
) })
const [room] = await createRommTxResponse.records.map((record) => const [room] = await createRommTxResponse.records.map((record) => record.get('room'))
record.get('room'),
)
return room return room
}) })
try { try {
@ -56,14 +94,15 @@ export default {
throw new Error(error) throw new Error(error)
} finally { } finally {
session.close() session.close()
} }
}, },
}, },
Room: { Room: {
...Resolver('Room', { ...Resolver('Room', {
undefinedToNull: ['lastMessageAt'],
hasMany: { hasMany: {
users: '<-[:CHATS_IN]-(related:User)', users: '<-[:CHATS_IN]-(related:User)',
} },
}), }),
} },
} }

View File

@ -590,7 +590,7 @@ describe('save category settings', () => {
beforeEach(async () => { beforeEach(async () => {
await Promise.all( await Promise.all(
categories.map(({ icon, name }, index) => { categories.map(({ icon, name }, index) => {
Factory.build('category', { return Factory.build('category', {
id: `cat${index + 1}`, id: `cat${index + 1}`,
slug: name, slug: name,
name, name,

View File

@ -144,7 +144,7 @@ export default {
params.locationName = params.locationName === '' ? null : params.locationName params.locationName = params.locationName === '' ? null : params.locationName
const { termsAndConditionsAgreedVersion } = params const { termsAndConditionsAgreedVersion } = params
if (termsAndConditionsAgreedVersion) { if (termsAndConditionsAgreedVersion) {
const regEx = new RegExp(/^[0-9]+\.[0-9]+\.[0-9]+$/g) const regEx = /^[0-9]+\.[0-9]+\.[0-9]+$/g
if (!regEx.test(termsAndConditionsAgreedVersion)) { if (!regEx.test(termsAndConditionsAgreedVersion)) {
throw new ForbiddenError('Invalid version format!') throw new ForbiddenError('Invalid version format!')
} }

View File

@ -25,4 +25,3 @@ type LocationMapBox {
type Query { type Query {
queryLocations(place: String!, lang: String!): [LocationMapBox]! queryLocations(place: String!, lang: String!): [LocationMapBox]!
} }

View File

@ -2,8 +2,13 @@
# room: _RoomFilter # room: _RoomFilter
# } # }
enum _MessageOrdering {
indexId_desc
}
type Message { type Message {
id: ID! id: ID!
indexId: Int!
createdAt: String createdAt: String
updatedAt: String updatedAt: String
@ -16,6 +21,10 @@ type Message {
username: String! @cypher(statement: "MATCH (this)<-[:CREATED]-(user:User) RETURN user.name") username: String! @cypher(statement: "MATCH (this)<-[:CREATED]-(user:User) RETURN user.name")
avatar: String @cypher(statement: "MATCH (this)<-[:CREATED]-(:User)-[:AVATAR_IMAGE]->(image:Image) RETURN image.url") avatar: String @cypher(statement: "MATCH (this)<-[:CREATED]-(:User)-[:AVATAR_IMAGE]->(image:Image) RETURN image.url")
date: String! @cypher(statement: "RETURN this.createdAt") date: String! @cypher(statement: "RETURN this.createdAt")
saved: Boolean
distributed: Boolean
seen: Boolean
} }
type Mutation { type Mutation {
@ -23,8 +32,19 @@ type Mutation {
roomId: ID! roomId: ID!
content: String! content: String!
): Message ): Message
MarkMessagesAsSeen(messageIds: [String!]): Boolean
} }
type Query { type Query {
Message(roomId: ID!): [Message] Message(
roomId: ID!,
first: Int
offset: Int
orderBy: [_MessageOrdering]
): [Message]
}
type Subscription {
chatMessageAdded: Message
} }

View File

@ -38,5 +38,5 @@ type Mutation {
} }
type Subscription { type Subscription {
notificationAdded(userId: ID!): NOTIFIED notificationAdded: NOTIFIED
} }

View File

@ -84,8 +84,8 @@ input _PostFilter {
group: _GroupFilter group: _GroupFilter
postsInMyGroups: Boolean postsInMyGroups: Boolean
postType_in: [PostType] postType_in: [PostType]
eventStart_gte: String eventStart_gte: String
eventEnd_gte: String eventEnd_gte: String
} }
enum _PostOrdering { enum _PostOrdering {

View File

@ -5,6 +5,12 @@
# users_some: _UserFilter # users_some: _UserFilter
# } # }
# TODO change this to last message date
enum _RoomOrdering {
lastMessageAt_desc
createdAt_desc
}
type Room { type Room {
id: ID! id: ID!
createdAt: String createdAt: String
@ -13,7 +19,28 @@ type Room {
users: [User]! @relation(name: "CHATS_IN", direction: "IN") users: [User]! @relation(name: "CHATS_IN", direction: "IN")
roomId: String! @cypher(statement: "RETURN this.id") roomId: String! @cypher(statement: "RETURN this.id")
roomName: String! ## @cypher(statement: "MATCH (this)<-[:CHATS_IN]-(user:User) WHERE NOT user.id = $cypherParams.currentUserId RETURN user[0].name") roomName: String! @cypher(statement: "MATCH (this)<-[:CHATS_IN]-(user:User) WHERE NOT user.id = $cypherParams.currentUserId RETURN user.name")
avatar: String @cypher(statement: """
MATCH (this)<-[:CHATS_IN]-(user:User)
WHERE NOT user.id = $cypherParams.currentUserId
OPTIONAL MATCH (user)-[:AVATAR_IMAGE]->(image:Image)
RETURN image.url
""")
lastMessageAt: String
lastMessage: Message @cypher(statement: """
MATCH (this)<-[:INSIDE]-(message:Message)
WITH message ORDER BY message.indexId DESC LIMIT 1
RETURN message
""")
unreadCount: Int @cypher(statement: """
MATCH (this)<-[:INSIDE]-(message:Message)<-[:CREATED]-(user:User)
WHERE NOT user.id = $cypherParams.currentUserId
AND NOT message.seen
RETURN count(message)
""")
} }
type Mutation { type Mutation {
@ -23,5 +50,13 @@ type Mutation {
} }
type Query { type Query {
Room: [Room] Room(
id: ID
orderBy: [_RoomOrdering]
): [Room]
UnreadRooms: Int
}
type Subscription {
roomCountUpdated: Int
} }

View File

@ -14,6 +14,8 @@ import bodyParser from 'body-parser'
import { graphqlUploadExpress } from 'graphql-upload' import { graphqlUploadExpress } from 'graphql-upload'
export const NOTIFICATION_ADDED = 'NOTIFICATION_ADDED' export const NOTIFICATION_ADDED = 'NOTIFICATION_ADDED'
export const CHAT_MESSAGE_ADDED = 'CHAT_MESSAGE_ADDED'
export const ROOM_COUNT_UPDATED = 'ROOM_COUNT_UPDATED'
const { REDIS_DOMAIN, REDIS_PORT, REDIS_PASSWORD } = CONFIG const { REDIS_DOMAIN, REDIS_PORT, REDIS_PASSWORD } = CONFIG
let prodPubsub, devPubsub let prodPubsub, devPubsub
const options = { const options = {

View File

@ -106,6 +106,4 @@
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */ "skipLibCheck": true /* Skip type checking all .d.ts files. */
}, },
"include": ["./src/**/*"],
"exclude": ["./src/**/*.spec.ts"]
} }

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,12 @@
const report = require("multiple-cucumber-html-reporter");
const reportTitle = "Ocelot webapp end-to-end test report"
report.generate({
jsonDir: "reports/json_logs",
reportPath: "./reports/cucumber_html_report",
pageTitle: reportTitle,
reportName: reportTitle,
pageFooter: "<div></div>",
hideMetadata: true
});

View File

@ -21,13 +21,7 @@ async function setupNodeEvents(on, config) {
return testStore[name] return testStore[name]
}, },
}); });
on("after:run", (results) => {
if (results) {
console.log(results.status);
}
});
return config; return config;
} }
@ -42,10 +36,7 @@ module.exports = defineConfig({
baseUrl: "http://localhost:3000", baseUrl: "http://localhost:3000",
specPattern: "cypress/e2e/**/*.feature", specPattern: "cypress/e2e/**/*.feature",
supportFile: "cypress/support/e2e.js", supportFile: "cypress/support/e2e.js",
retries: { retries: 0,
runMode: 2,
openMode: 0,
},
video: false, video: false,
setupNodeEvents, setupNodeEvents,
}, },

View File

@ -13,9 +13,8 @@
// Cypress.Commands.add('login', (email, password) => { ... }) // Cypress.Commands.add('login', (email, password) => { ... })
/* globals Cypress cy */ /* globals Cypress cy */
import "cypress-file-upload";
import { GraphQLClient, request } from 'graphql-request' import { GraphQLClient, request } from 'graphql-request'
import CONFIG from '../../backend/build/config' import CONFIG from '../../backend/build/src/config'
const authenticatedHeaders = (variables) => { const authenticatedHeaders = (variables) => {
const mutation = ` const mutation = `

View File

@ -1,5 +1,5 @@
import Factory from '../../backend/build/db/factories' import Factory from '../../backend/build/src/db/factories'
import { getNeode } from '../../backend/build/db/neo4j' import { getNeode } from '../../backend/build/src/db/neo4j'
const neodeInstance = getNeode() const neodeInstance = getNeode()

View File

@ -1,28 +1,27 @@
import { Then } from "@badeball/cypress-cucumber-preprocessor"; import { Then } from "@badeball/cypress-cucumber-preprocessor";
Then("I should be able to {string} a teaser image", condition => { Then("I should be able to {string} a teaser image", condition => {
// cy.reload() let postTeaserImage = ""
switch(condition){ switch(condition){
case 'change': case "change":
cy.get('.delete-image-button') postTeaserImage = "humanconnection.png"
cy.get(".delete-image-button")
.click() .click()
cy.fixture('humanconnection.png').as('postTeaserImage').then(function() { cy.get("#postdropzone").selectFile(
cy.get("#postdropzone").upload( { contents: `cypress/fixtures/${postTeaserImage}`, fileName: postTeaserImage, mimeType: "image/png" },
{ fileContent: this.postTeaserImage, fileName: 'humanconnection.png', mimeType: "image/png" }, { action: "drag-drop", force: true }
{ subjectType: "drag-n-drop", force: true } ).wait(750);
).wait(750);
})
break; break;
case 'add': case "add":
cy.fixture('onourjourney.png').as('postTeaserImage').then(function() { postTeaserImage = "onourjourney.png"
cy.get("#postdropzone").upload( cy.get("#postdropzone").selectFile(
{ fileContent: this.postTeaserImage, fileName: 'onourjourney.png', mimeType: "image/png" }, { contents: `cypress/fixtures/${postTeaserImage}`, fileName: postTeaserImage, mimeType: "image/png" },
{ subjectType: "drag-n-drop", force: true } { action: "drag-drop", force: true }
).wait(750); ).wait(750);
})
break; break;
case 'remove': case "remove":
cy.get('.delete-image-button') cy.get(".delete-image-button")
.click() .click()
break; break;
} }

View File

@ -2,13 +2,11 @@ import { Then } from "@badeball/cypress-cucumber-preprocessor";
Then("I should be able to change my profile picture", () => { Then("I should be able to change my profile picture", () => {
const avatarUpload = "onourjourney.png"; const avatarUpload = "onourjourney.png";
cy.fixture(avatarUpload, "base64").then(fileContent => { cy.get("#customdropzone").selectFile(
cy.get("#customdropzone").upload( { contents: `cypress/fixtures/${avatarUpload}`, fileName: avatarUpload, mimeType: "image/png" },
{ fileContent, fileName: avatarUpload, mimeType: "image/png" }, { action: "drag-drop" }
{ subjectType: "drag-n-drop", force: true } );
);
});
cy.get(".profile-page-avatar img") cy.get(".profile-page-avatar img")
.should("have.attr", "src") .should("have.attr", "src")
.and("contains", "onourjourney"); .and("contains", "onourjourney");

View File

@ -1,5 +1,5 @@
import { Given } from "@badeball/cypress-cucumber-preprocessor"; import { Given } from "@badeball/cypress-cucumber-preprocessor";
import encode from '../../../../backend/build/jwt/encode' import encode from '../../../../backend/build/src/jwt/encode'
Given("I am logged in as {string}", slug => { Given("I am logged in as {string}", slug => {
cy.neode() cy.neode()

View File

@ -2,6 +2,10 @@
When you overtake this deploy and rebrand repo to your network you have to recognize the following changes and doings: When you overtake this deploy and rebrand repo to your network you have to recognize the following changes and doings:
## Version >= 2.7.0 with 'ocelotDockerVersionTag' 2.7.0-470
- You have to rename all `.js` files to `.ts` in `branding/constants`
## Version >= 2.4.0 with 'ocelotDockerVersionTag' 2.4.0-298 ## Version >= 2.4.0 with 'ocelotDockerVersionTag' 2.4.0-298
- You have to set `SHOW_CONTENT_FILTER_HEADER_MENU` and `SHOW_CONTENT_FILTER_MASONRY_GRID` in `branding/constants/filter.js` originally in main code file `webapp/constants/filter.js` to your preferred value. - You have to set `SHOW_CONTENT_FILTER_HEADER_MENU` and `SHOW_CONTENT_FILTER_MASONRY_GRID` in `branding/constants/filter.js` originally in main code file `webapp/constants/filter.js` to your preferred value.

@ -1 +1 @@
Subproject commit 3056eec040cf7a052a5d08ab4cac7129355ab652 Subproject commit fdc2e52fa444b300e1c4736600bc0e9ae3314222

View File

@ -15,4 +15,4 @@ echo "Using CONFIGURATION=${CONFIGURATION}"
KUBECONFIG=${KUBECONFIG:-${SCRIPT_DIR}/../configurations/${CONFIGURATION}/kubeconfig.yaml} KUBECONFIG=${KUBECONFIG:-${SCRIPT_DIR}/../configurations/${CONFIGURATION}/kubeconfig.yaml}
# clean & seed # clean & seed
kubectl --kubeconfig=${KUBECONFIG} -n default exec -it $(kubectl --kubeconfig=${KUBECONFIG} -n default get pods | grep ocelot-backend | awk '{ print $1 }') -- /bin/sh -c "node --experimental-repl-await build/db/clean.js && node --experimental-repl-await build/db/seed.js" kubectl --kubeconfig=${KUBECONFIG} -n default exec -it $(kubectl --kubeconfig=${KUBECONFIG} -n default get pods | grep ocelot-backend | awk '{ print $1 }') -- /bin/sh -c "node --experimental-repl-await build/src/db/clean.js && node --experimental-repl-await build/src/db/seed.js"

View File

@ -12,9 +12,9 @@ FROM $APP_IMAGE_CODE as code
ARG CONFIGURATION=example ARG CONFIGURATION=example
# copy public constants and email templates into the Docker image to brand it # copy public constants and email templates into the Docker image to brand it
COPY configurations/${CONFIGURATION}/branding/constants/emails.js src/config/ COPY configurations/${CONFIGURATION}/branding/constants/emails.ts src/config/
COPY configurations/${CONFIGURATION}/branding/constants/logos.js src/config/ COPY configurations/${CONFIGURATION}/branding/constants/logos.ts src/config/
COPY configurations/${CONFIGURATION}/branding/constants/metadata.js src/config/ COPY configurations/${CONFIGURATION}/branding/constants/metadata.ts src/config/
COPY configurations/${CONFIGURATION}/branding/email/ src/middleware/helpers/email/ COPY configurations/${CONFIGURATION}/branding/email/ src/middleware/helpers/email/
################################################################################## ##################################################################################
@ -38,7 +38,7 @@ COPY --from=build ${DOCKER_WORKDIR}/build ./build
COPY --from=build ${DOCKER_WORKDIR}/node_modules ./node_modules COPY --from=build ${DOCKER_WORKDIR}/node_modules ./node_modules
# TODO - externalize the uploads so we can copy the whole folder # 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/img/ ./public/img/
COPY --from=build ${DOCKER_WORKDIR}/public/providers.json ./public/providers.json COPY --from=build ${DOCKER_WORKDIR}/public/providers.json ./build/public/providers.json
# Copy package.json for script definitions (lock file should not be needed) # Copy package.json for script definitions (lock file should not be needed)
COPY --from=build ${DOCKER_WORKDIR}/package.json ./package.json COPY --from=build ${DOCKER_WORKDIR}/package.json ./package.json

View File

@ -14,6 +14,7 @@ ARG CONFIGURATION=example
# copy public constants into the Docker image to brand it # copy public constants into the Docker image to brand it
COPY configurations/${CONFIGURATION}/branding/static/ static/ COPY configurations/${CONFIGURATION}/branding/static/ static/
COPY configurations/${CONFIGURATION}/branding/constants/ constants/ COPY configurations/${CONFIGURATION}/branding/constants/ constants/
RUN /bin/sh -c 'cd constants && for f in *.ts; do mv -- "$f" "${f%.ts}.js"; done'
# locales # locales
COPY configurations/${CONFIGURATION}/branding/locales/*.json locales/tmp/ COPY configurations/${CONFIGURATION}/branding/locales/*.json locales/tmp/

View File

@ -14,6 +14,7 @@ ARG CONFIGURATION=example
# copy public constants into the Docker image to brand it # copy public constants into the Docker image to brand it
COPY configurations/${CONFIGURATION}/branding/static/ static/ COPY configurations/${CONFIGURATION}/branding/static/ static/
COPY configurations/${CONFIGURATION}/branding/constants/ constants/ COPY configurations/${CONFIGURATION}/branding/constants/ constants/
RUN /bin/sh -c 'cd constants && for f in *.ts; do mv -- "$f" "${f%.ts}.js"; done'
COPY configurations/${CONFIGURATION}/branding/locales/html/ locales/html/ COPY configurations/${CONFIGURATION}/branding/locales/html/ locales/html/
COPY configurations/${CONFIGURATION}/branding/assets/styles/imports/ assets/styles/imports/ COPY configurations/${CONFIGURATION}/branding/assets/styles/imports/ assets/styles/imports/
COPY configurations/${CONFIGURATION}/branding/assets/fonts/ assets/fonts/ COPY configurations/${CONFIGURATION}/branding/assets/fonts/ assets/fonts/

View File

@ -293,7 +293,7 @@ $ kubectl -n default rollout status deployment/ocelot-neo4j --timeout=240s
$ kubectl config get-contexts $ kubectl config get-contexts
# reset and seed Neo4j database via backend for staging # reset and seed Neo4j database via backend for staging
$ kubectl -n default exec -it $(kubectl -n default get pods | grep ocelot-backend | awk '{ print $1 }') -- /bin/sh -c "node --experimental-repl-await build/db/clean.js && node --experimental-repl-await build/db/seed.js" $ kubectl -n default exec -it $(kubectl -n default get pods | grep ocelot-backend | awk '{ print $1 }') -- /bin/sh -c "node --experimental-repl-await build/src/db/clean.js && node --experimental-repl-await build/src/db/seed.js"
``` ```

View File

@ -10,13 +10,25 @@
"url": "https://github.com/Ocelot-Social-Community/Ocelot-Social.git" "url": "https://github.com/Ocelot-Social-Community/Ocelot-Social.git"
}, },
"cypress-cucumber-preprocessor": { "cypress-cucumber-preprocessor": {
"nonGlobalStepDefinitions": true "stepDefinitions": "cypress/support/step_definitions/**/*.js",
"json": {
"enabled": true,
"output": "cypress/reports/json_logs/cucumber_log.json",
"formatter": "cucumber-json-formatter"
},
"messages": {
"enabled": true,
"output": "cypress/reports/json_logs/messages.ndjson"
},
"html": {
"enabled": false
}
}, },
"scripts": { "scripts": {
"db:seed": "cd backend && yarn run db:seed", "db:seed": "cd backend && yarn run db:seed",
"db:reset": "cd backend && yarn run db:reset", "db:reset": "cd backend && yarn run db:reset",
"cypress:run": "cypress run --browser electron --config-file ./cypress/cypress.config.js", "cypress:run": "cypress run --e2e --browser electron --config-file ./cypress/cypress.config.js",
"cypress:open": "cypress open --browser electron --config-file ./cypress/cypress.config.js", "cypress:open": "cypress open --e2e --browser electron --config-file ./cypress/cypress.config.js",
"cucumber:setup": "cd backend && yarn run dev", "cucumber:setup": "cd backend && yarn run dev",
"cucumber": "wait-on tcp:4000 && cucumber-js --require-module @babel/register --exit", "cucumber": "wait-on tcp:4000 && cucumber-js --require-module @babel/register --exit",
"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\") && cd ../webapp/maintenance/source && yarn version --no-git-tag-version --no-commit-hooks --no-commit --new-version $(node -p -e \"require('./../../../package.json').version\")" "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\") && cd ../webapp/maintenance/source && yarn version --no-git-tag-version --no-commit-hooks --no-commit --new-version $(node -p -e \"require('./../../../package.json').version\")"
@ -27,13 +39,12 @@
"@babel/register": "^7.12.10", "@babel/register": "^7.12.10",
"@badeball/cypress-cucumber-preprocessor": "^15.1.4", "@badeball/cypress-cucumber-preprocessor": "^15.1.4",
"@cypress/browserify-preprocessor": "^3.0.2", "@cypress/browserify-preprocessor": "^3.0.2",
"@faker-js/faker": "7.6.0", "@faker-js/faker": "8.0.2",
"auto-changelog": "^2.3.0", "auto-changelog": "^2.3.0",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"cucumber": "^6.0.5", "cucumber": "^6.0.5",
"cypress": "^12.14.0", "cypress": "^12.17.0",
"cypress-file-upload": "^3.5.3",
"cypress-network-idle": "^1.14.2", "cypress-network-idle": "^1.14.2",
"date-fns": "^2.25.0", "date-fns": "^2.25.0",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
@ -42,6 +53,7 @@
"import": "^0.0.6", "import": "^0.0.6",
"jsonwebtoken": "^8.5.1", "jsonwebtoken": "^8.5.1",
"mock-socket": "^9.0.3", "mock-socket": "^9.0.3",
"multiple-cucumber-html-reporter": "^3.4.0",
"neo4j-driver": "^4.3.4", "neo4j-driver": "^4.3.4",
"neode": "^0.4.8", "neode": "^0.4.8",
"rosie": "^2.1.0", "rosie": "^2.1.0",

View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<title>align-center</title>
<path d="M3 7h26v2h-26v-2zM7 11h18v2h-18v-2zM3 15h26v2h-26v-2zM7 19h18v2h-18v-2zM3 23h26v2h-26v-2z"></path>
</svg>

After

Width:  |  Height:  |  Size: 274 B

View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<title>align-justify</title>
<path d="M3 7h26v2h-26v-2zM3 11h26v2h-26v-2zM3 15h26v2h-26v-2zM3 19h26v2h-26v-2zM3 23h26v2h-26v-2z"></path>
</svg>

After

Width:  |  Height:  |  Size: 275 B

View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<title>align-left</title>
<path d="M3 7h26v2h-26v-2zM3 11h18v2h-18v-2zM3 15h26v2h-26v-2zM3 19h18v2h-18v-2zM3 23h26v2h-26v-2z"></path>
</svg>

After

Width:  |  Height:  |  Size: 272 B

View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<title>align-right</title>
<path d="M3 7h26v2h-26v-2zM11 11h18v2h-18v-2zM3 15h26v2h-26v-2zM11 19h18v2h-18v-2zM3 23h26v2h-26v-2z"></path>
</svg>

After

Width:  |  Height:  |  Size: 275 B

View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<title>angle-left</title>
<path d="M19.031 4.281l1.438 1.438-10.281 10.281 10.281 10.281-1.438 1.438-11-11-0.688-0.719 0.688-0.719z"></path>
</svg>

After

Width:  |  Height:  |  Size: 279 B

View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<title>angle-right</title>
<path d="M12.969 4.281l11 11 0.688 0.719-0.688 0.719-11 11-1.438-1.438 10.281-10.281-10.281-10.281z"></path>
</svg>

After

Width:  |  Height:  |  Size: 274 B

View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<title>archive</title>
<path d="M4 5h24v6h-1v16h-22v-16h-1v-6zM6 7v2h20v-2h-20zM7 11v14h18v-14h-18zM12.813 13c0.013-0.001 0.034-0.001 0.047-0.001s0.034 0.001 0.047 0.001c0.013-0.001 0.034-0.001 0.047-0.001s0.034 0.001 0.047 0.001l6.014-0c0.552 0 1 0.448 1 1s-0.448 1-1 1l-0.014-0h-6c-0.026 0.002-0.068 0.004-0.094 0.004-0.554 0-1.004-0.45-1.004-1.004 0-0.505 0.408-0.953 0.911-1z"></path>
</svg>

After

Width:  |  Height:  |  Size: 527 B

View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<title>arrow-up</title>
<path d="M16 4.094l0.719 0.688 8.5 8.5-1.438 1.438-6.781-6.781v20.063h-2v-20.063l-6.781 6.781-1.438-1.438 8.5-8.5z"></path>
</svg>

After

Width:  |  Height:  |  Size: 286 B

View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<title>bar-chart</title>
<path d="M21 4h8v24h-8v-24zM23 6v20h4v-20h-4zM3 10h8v18h-8v-18zM5 12v14h4v-14h-4zM12 16h8v12h-8v-12zM14 18v8h4v-8h-4z"></path>
</svg>

After

Width:  |  Height:  |  Size: 290 B

View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<title>briefcase</title>
<path d="M16 3c1.864 0 3.399 1.275 3.844 3h9.156v20h-26v-20h9.156c0.445-1.725 1.98-3 3.844-3zM16 5c-0.81 0-1.428 0.385-1.75 1h3.5c-0.322-0.615-0.94-1-1.75-1zM5 8v9h22v-9h-22zM16 14c0.552 0 1 0.448 1 1s-0.448 1-1 1-1-0.448-1-1 0.448-1 1-1zM5 19v5h22v-5h-22z"></path>
</svg>

After

Width:  |  Height:  |  Size: 429 B

View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<title>bug</title>
<path d="M10.719 3.281l2.313 2.313c0.923-0.39 1.922-0.594 2.969-0.594s2.046 0.203 2.969 0.594l2.313-2.313 1.438 1.438-1.938 1.938c1.462 1.119 2.61 2.755 3.344 4.656l2.438-1.219 0.875 1.813-2.75 1.375c0.183 0.876 0.313 1.782 0.313 2.719 0 0.34-0.006 0.666-0.031 1h3.031v2h-3.375c-0.242 1.043-0.561 2.039-1.031 2.938l3 2.25-1.188 1.625-2.938-2.188c-1.618 2.056-3.885 3.375-6.469 3.375s-4.851-1.319-6.469-3.375l-2.938 2.188-1.188-1.625 3-2.25c-0.47-0.898-0.789-1.894-1.031-2.938h-3.375v-2h3.031c-0.025-0.334-0.031-0.66-0.031-1 0-0.937 0.13-1.843 0.313-2.719l-2.75-1.375 0.875-1.813 2.438 1.219c0.734-1.901 1.882-3.538 3.344-4.656l-1.938-1.938zM16 7c-1.978 0-3.827 1.094-5.125 2.875 1.134 0.511 2.924 1.125 5.125 1.125s3.991-0.614 5.125-1.125c-1.298-1.781-3.147-2.875-5.125-2.875zM9.906 11.594c-0.569 1.292-0.906 2.788-0.906 4.406 0 4.629 2.698 8.282 6 8.906v-11.969c-2.17-0.162-3.941-0.801-5.094-1.344zM22.094 11.594c-1.153 0.542-2.924 1.182-5.094 1.344v11.969c3.302-0.625 6-4.278 6-8.906 0-1.618-0.337-3.115-0.906-4.406z"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<title>calculator</title>
<path d="M5 3h20v26h-20v-26zM7 5v22h16v-22h-16zM9 7h12v6h-12v-6zM11 9v2h8v-2h-8zM10 15h2v2h-2v-2zM14 15h2v2h-2v-2zM18 15h2v2h-2v-2zM10 19h2v2h-2v-2zM14 19h2v2h-2v-2zM18 19h2v2h-2v-2zM10 23h2v2h-2v-2zM14 23h2v2h-2v-2zM18 23h2v2h-2v-2z"></path>
</svg>

After

Width:  |  Height:  |  Size: 407 B

View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<title>camera</title>
<path d="M11.5 6h9l0.313 0.406 1.188 1.594h7v18h-26v-18h7l1.188-1.594zM12.5 8l-1.188 1.594-0.313 0.406h-6v14h22v-14h-6l-0.313-0.406-1.188-1.594h-7zM8 11c0.552 0 1 0.448 1 1s-0.448 1-1 1-1-0.448-1-1 0.448-1 1-1zM16 11c3.302 0 6 2.698 6 6s-2.698 6-6 6-6-2.698-6-6 2.698-6 6-6zM16 13c-2.221 0-4 1.779-4 4s1.779 4 4 4 4-1.779 4-4-1.779-4-4-4z"></path>
</svg>

After

Width:  |  Height:  |  Size: 508 B

View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<title>cart-plus</title>
<path d="M4 7h2.219c0.918 0 1.716 0.61 1.938 1.5l2.625 10.5h12.469l2.406-9h2.094l-2.594 9.531c-0.238 0.87-1.004 1.469-1.906 1.469h-12.469c-0.918 0-1.714-0.61-1.938-1.5l-2.625-10.5h-2.219c-0.552 0-1-0.448-1-1s0.448-1 1-1zM22 21c1.645 0 3 1.355 3 3s-1.355 3-3 3-3-1.355-3-3 1.355-3 3-3zM13 21c1.645 0 3 1.355 3 3s-1.355 3-3 3-3-1.355-3-3 1.355-3 3-3zM16 7h2v3h3v2h-3v3h-2v-3h-3v-2h3v-3zM13 23c-0.564 0-1 0.436-1 1s0.436 1 1 1 1-0.436 1-1-0.436-1-1-1zM22 23c-0.564 0-1 0.436-1 1s0.436 1 1 1 1-0.436 1-1-0.436-1-1-1z"></path>
</svg>

After

Width:  |  Height:  |  Size: 685 B

View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<title>certificate</title>
<path d="M16 3c0.624 0 1.248 0.213 1.781 0.594l1.656 1.156 1.875 0.25h0.031c1.314 0.16 2.352 1.223 2.531 2.531 0.003 0.024 0.029 0.038 0.031 0.063h-0.031l0.375 1.875 1.156 1.656c0.762 1.067 0.73 2.476 0.031 3.594v0.031l-0.031 0.031-1.156 1.656-0.25 1.875 3.125 4.75 1.031 1.531h-4.781l-1.156 2.688-0.719 1.719-1.031-1.563-3.156-4.75c-0.818 0.379-1.779 0.349-2.625 0l-3.156 4.75-1.031 1.563-0.719-1.719-1.156-2.688h-4.781l1.031-1.531 3.219-4.906-0.313-1.719-1.188-1.656c-0.762-1.067-0.73-2.507-0.031-3.625v-0.031l0.031-0.031 1.156-1.5 0.25-1.938v-0.031l0.031-0.031c0.283-1.275 1.287-2.279 2.563-2.563l0.031-0.031h0.031l1.906-0.25 1.656-1.156c0.533-0.381 1.157-0.594 1.781-0.594zM16 5.031c-0.229 0-0.458 0.068-0.625 0.188l-2 1.438-0.25 0.031-2.094 0.281c-0.015 0.003-0.016 0.027-0.031 0.031-0.487 0.125-0.875 0.513-1 1-0.004 0.015-0.028 0.016-0.031 0.031l-0.281 2.094-0.031 0.281-0.156 0.188-1.25 1.625c-0.301 0.482-0.269 1.073-0.031 1.406l1.281 1.781 0.156 0.188 0.031 0.25 0.406 2.281v0.063c0.015 0.138 0.063 0.266 0.125 0.375 0.139 0.244 0.378 0.403 0.688 0.438h0.031l2.188 0.313 0.281 0.031 0.188 0.156 1.625 1.25c0.482 0.302 1.073 0.269 1.406 0.031l1.781-1.281 0.188-0.156 0.25-0.031 2.281-0.406h0.063c0.25-0.028 0.45-0.142 0.594-0.313v-0.031l0.063-0.031c0.084-0.122 0.138-0.273 0.156-0.438v-0.031l0.313-2.188 0.031-0.25 1.406-1.969c0.302-0.482 0.269-1.042 0.031-1.375l-1.281-1.781-0.156-0.188-0.031-0.219-0.406-2.219v-0.063c-0.050-0.447-0.365-0.763-0.813-0.813h-0.031l-2.188-0.313-0.25-0.031-0.219-0.156-1.781-1.281c-0.167-0.119-0.396-0.188-0.625-0.188zM22.906 20.25c-0.409 0.323-0.9 0.552-1.438 0.625-0.024 0.003-0.038 0.029-0.063 0.031v-0.031l-1.969 0.344-0.469 0.344 2.125 3.25 0.688-1.594 0.25-0.625h2.406zM9.094 20.281l-1.531 2.313h2.406l0.25 0.625 0.688 1.594 2.125-3.219-0.438-0.344-1.906-0.25c-0.010-0.001-0.021 0.001-0.031 0-0.595-0.072-1.135-0.338-1.563-0.719z"></path>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<title>chain-broken</title>
<path d="M21.75 4c1.603 0 3.189 0.626 4.406 1.844 2.435 2.435 2.435 6.409 0 8.844l-1.469 1.469c-1.015 1.016-2.304 1.618-3.625 1.781l-0.25-2c0.901-0.111 1.745-0.494 2.438-1.188h0.031l1.469-1.469c1.671-1.671 1.671-4.36 0-6.031s-4.36-1.671-6.031 0l-1.469 1.469c-0.692 0.692-1.076 1.564-1.188 2.469l-2-0.25c0.162-1.319 0.765-2.609 1.781-3.625l1.469-1.469c1.218-1.218 2.834-1.844 4.438-1.844zM7.719 6.281l4 4-1.438 1.438-4-4zM10.938 14.063l0.25 2c-0.901 0.111-1.745 0.494-2.438 1.188h-0.031l-1.469 1.469c-1.671 1.671-1.671 4.36 0 6.031s4.36 1.671 6.031 0l1.469-1.469c0.692-0.692 1.076-1.564 1.188-2.469l2 0.25c-0.162 1.319-0.765 2.609-1.781 3.625l-1.469 1.469c-2.435 2.435-6.409 2.435-8.844 0s-2.435-6.409 0-8.844l1.469-1.469c1.015-1.016 2.304-1.618 3.625-1.781zM21.719 20.281l4 4-1.438 1.438-4-4z"></path>
</svg>

After

Width:  |  Height:  |  Size: 968 B

View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<title>chain</title>
<path d="M21.75 4c1.671 0 3.225 0.661 4.406 1.844s1.844 2.735 1.844 4.406-0.662 3.255-1.844 4.438l-1.469 1.469c-1.181 1.183-2.766 1.844-4.438 1.844-0.793 0-1.565-0.153-2.281-0.438l1.625-1.625c0.215 0.038 0.432 0.063 0.656 0.063 1.138 0 2.226-0.445 3.031-1.25l1.469-1.469c1.66-1.66 1.66-4.372 0-6.031-0.804-0.805-1.863-1.25-3-1.25s-2.227 0.444-3.031 1.25l-1.469 1.469c-0.997 0.996-1.391 2.393-1.188 3.688l-1.625 1.625c-0.285-0.716-0.438-1.487-0.438-2.281 0-1.671 0.662-3.255 1.844-4.438l1.469-1.469c1.181-1.183 2.766-1.844 4.438-1.844zM19.281 11.281l1.438 1.438-8 8-1.438-1.438zM11.75 14c0.793 0 1.565 0.153 2.281 0.438l-1.625 1.625c-0.215-0.038-0.432-0.063-0.656-0.063-1.138 0-2.226 0.445-3.031 1.25l-1.469 1.469c-1.66 1.66-1.66 4.372 0 6.031 0.804 0.805 1.863 1.25 3 1.25s2.227-0.444 3.031-1.25l1.469-1.469c0.997-0.996 1.391-2.393 1.188-3.688l1.625-1.625c0.285 0.716 0.438 1.487 0.438 2.281 0 1.671-0.662 3.256-1.844 4.438l-1.469 1.469c-1.181 1.183-2.766 1.844-4.438 1.844s-3.225-0.661-4.406-1.844c-1.182-1.182-1.844-2.735-1.844-4.406s0.662-3.256 1.844-4.438l1.469-1.469c1.181-1.183 2.766-1.844 4.438-1.844z"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,4 @@
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<title>chat-bubble</title>
<g data-name="Chat"><path d="M27.23,24.91A9,9,0,0,0,22.44,9.52,8.57,8.57,0,0,0,21,9.41a8.94,8.94,0,0,0-8.92,10.14c0,.1,0,.2,0,.29a9,9,0,0,0,.22,1l0,.11a8.93,8.93,0,0,0,.38,1l.13.28a9,9,0,0,0,.45.83l.07.14a9.13,9.13,0,0,1-2.94-.36,1,1,0,0,0-.68,0L7,24.13l.54-1.9a1,1,0,0,0-.32-1,9,9,0,0,1,11.27-14,1,1,0,0,0,1.23-1.58A10.89,10.89,0,0,0,13,3.25a11,11,0,0,0-7.5,19l-1,3.34A1,1,0,0,0,5.9,26.82l4.35-1.93a11,11,0,0,0,4.68.16A9,9,0,0,0,21,27.41a8.81,8.81,0,0,0,2.18-.27l3.41,1.52A1,1,0,0,0,28,27.48Zm-1.77-1.1a1,1,0,0,0-.32,1L25.45,26l-1.79-.8a1,1,0,0,0-.41-.09,1,1,0,0,0-.29,0,6.64,6.64,0,0,1-2,.29,7,7,0,0,1,0-14,6.65,6.65,0,0,1,1.11.09,7,7,0,0,1,3.35,12.31Z"></path><path d="M17.82 17.08H17a1 1 0 0 0 0 2h.82a1 1 0 0 0 0-2zM21.41 17.08h-.82a1 1 0 0 0 0 2h.82a1 1 0 0 0 0-2zM25 17.08h-.82a1 1 0 0 0 0 2H25a1 1 0 0 0 0-2z"></path></g>
</svg>

After

Width:  |  Height:  |  Size: 961 B

View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<title>cloud-download</title>
<path d="M16 5c3.378 0 6.14 2.131 7.344 5.063 3.527 0.182 6.33 2.986 6.563 6.5 1.239 1.102 2.094 2.677 2.094 4.438 0 3.324-2.676 6-6 6h-20c-3.324 0-6-2.676-6-6 0-2.751 1.884-4.944 4.344-5.656 0.597-1.699 2.050-2.919 3.844-3.219 0.454-3.994 3.694-7.125 7.813-7.125zM16 7c-3.37 0-6 2.63-6 6v1h-1c-1.444 0-2.638 0.964-2.938 2.313l-0.125 0.656-0.656 0.125c-1.832 0.319-3.281 1.886-3.281 3.906 0 2.276 1.724 4 4 4h20c2.276 0 4-1.724 4-4 0-1.267-0.65-2.48-1.594-3.188l-0.406-0.313v-0.5c0-2.755-2.245-5-5-5h-1.031l-0.219-0.719c-0.779-2.51-2.988-4.281-5.75-4.281zM15 12h2v6.563l2.281-2.281 1.438 1.438-4 4-0.719 0.688-0.719-0.688-4-4 1.438-1.438 2.281 2.281v-6.563z"></path>
</svg>

After

Width:  |  Height:  |  Size: 835 B

View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<title>cloud-upload</title>
<path d="M16 4c3.378 0 6.14 2.131 7.344 5.063 3.527 0.182 6.33 2.986 6.563 6.5 1.239 1.102 2.094 2.677 2.094 4.438 0 3.324-2.676 6-6 6h-20c-3.324 0-6-2.676-6-6 0-2.751 1.884-4.944 4.344-5.656 0.597-1.699 2.050-2.919 3.844-3.219 0.454-3.994 3.694-7.125 7.813-7.125zM16 6c-3.37 0-6 2.63-6 6v1h-1c-1.444 0-2.638 0.964-2.938 2.313l-0.125 0.656-0.656 0.125c-1.832 0.319-3.281 1.886-3.281 3.906 0 2.276 1.724 4 4 4h20c2.276 0 4-1.724 4-4 0-1.267-0.65-2.48-1.594-3.188l-0.406-0.313v-0.5c0-2.755-2.245-5-5-5h-1.031l-0.219-0.719c-0.779-2.51-2.988-4.281-5.75-4.281zM16 11.594l0.719 0.688 4 4-1.438 1.438-2.281-2.281v6.563h-2v-6.563l-2.281 2.281-1.438-1.438 4-4z"></path>
</svg>

After

Width:  |  Height:  |  Size: 827 B

View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<title>cloud</title>
<path d="M16 5c2.451 0 4.563 1.302 5.813 3.219 0.392-0.089 0.755-0.219 1.188-0.219 3.302 0 6 2.698 6 6 1.73 1.055 3 2.835 3 5 0 3.302-2.698 6-6 6h-20c-3.302 0-6-2.698-6-6s2.698-6 6-6c0.211 0 0.394 0.040 0.594 0.063 0.531-1.191 1.439-2.083 2.656-2.563 0.698-3.129 3.419-5.5 6.75-5.5zM16 7c-2.522 0-4.581 1.836-4.938 4.25l-0.094 0.688-0.656 0.156c-1.11 0.265-2.002 1.136-2.25 2.25l-0.188 0.969-1-0.219c-0.298-0.067-0.584-0.094-0.875-0.094-2.22 0-4 1.78-4 4s1.78 4 4 4h20c2.22 0 4-1.78 4-4 0-1.662-1.009-3.078-2.438-3.688l-0.656-0.281 0.063-0.719c0.018-0.235 0.031-0.321 0.031-0.313 0-2.22-1.78-4-4-4-0.444 0-0.875 0.096-1.313 0.25l-0.844 0.281-0.375-0.781c-0.824-1.631-2.511-2.75-4.469-2.75z"></path>
</svg>

After

Width:  |  Height:  |  Size: 858 B

View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<title>code</title>
<path d="M18 5h2l-6 22h-2zM7.938 6.406l1.625 1.188-6.313 8.406 6.313 8.406-1.625 1.188-6.75-9-0.438-0.594 0.438-0.594zM24.063 6.406l6.75 9 0.438 0.594-0.438 0.594-6.75 9-1.625-1.188 6.313-8.406-6.313-8.406z"></path>
</svg>

After

Width:  |  Height:  |  Size: 374 B

View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<title>coffee</title>
<path d="M16-2h2v4h-2v-4zM20-1h2v3h-2v-3zM10 3h18v4h2c1.645 0 3 1.355 3 3v3c0 1.645-1.355 3-3 3h-2v5c0 1.645-1.355 3-3 3h-12c-1.645 0-3-1.355-3-3v-18zM12 5v16c0 0.555 0.445 1 1 1h12c0.555 0 1-0.445 1-1v-16h-14zM28 9v5h2c0.555 0 1-0.445 1-1v-3c0-0.555-0.445-1-1-1h-2z"></path>
</svg>

After

Width:  |  Height:  |  Size: 436 B

View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<title>columns</title>
<path d="M5 5h22v22h-22v-22zM7 7v18h8v-18h-8zM17 7v18h8v-18h-8z"></path>
</svg>

After

Width:  |  Height:  |  Size: 234 B

View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<title>compass</title>
<path d="M16 3c7.168 0 13 5.832 13 13s-5.832 13-13 13-13-5.832-13-13 5.832-13 13-13zM14.875 5.063c-5.226 0.529-9.341 4.695-9.813 9.938h0.938v2h-0.938c0.475 5.284 4.653 9.462 9.938 9.938v-0.938h2v0.938c5.284-0.475 9.462-4.653 9.938-9.938h-0.938v-2h0.938c-0.475-5.284-4.653-9.462-9.938-9.938v0.938h-2v-0.938c-0.041 0.004-0.084-0.004-0.125 0zM22.094 9.906l-3.688 8.5-8.5 3.688 3.688-8.5zM16 14.5c-0.8 0-1.5 0.7-1.5 1.5s0.7 1.5 1.5 1.5 1.5-0.7 1.5-1.5-0.7-1.5-1.5-1.5z"></path>
</svg>

After

Width:  |  Height:  |  Size: 635 B

View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<title>credit-card</title>
<path d="M5 5h18c1.645 0 3 1.355 3 3v1h1c1.645 0 3 1.355 3 3v12c0 1.645-1.355 3-3 3h-18c-1.645 0-3-1.355-3-3v-1h-1c-1.645 0-3-1.355-3-3v-12c0-1.645 1.355-3 3-3zM5 7c-0.565 0-1 0.435-1 1v12c0 0.565 0.435 1 1 1h18c0.565 0 1-0.435 1-1v-9h-19v-2h19v-1c0-0.565-0.435-1-1-1h-18zM26 11v2h2v-1c0-0.565-0.435-1-1-1h-1zM26 15v5c0 1.645-1.355 3-3 3h-15v1c0 0.565 0.435 1 1 1h18c0.565 0 1-0.435 1-1v-9h-2z"></path>
</svg>

After

Width:  |  Height:  |  Size: 568 B

View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<title>crop</title>
<path d="M8 4h2v16.563l10.563-10.563h-9.563v-2h11.563l3.719-3.719 1.438 1.438-3.719 3.719v11.563h-2v-9.563l-10.563 10.563h16.563v2h-4v4h-2v-4h-14v-14h-4v-2h4v-4z"></path>
</svg>

After

Width:  |  Height:  |  Size: 329 B

View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<title>crosshairs</title>
<path d="M15 3h2v2.063c5.268 0.477 9.46 4.67 9.938 9.938h2.063v2h-2.063c-0.477 5.268-4.67 9.46-9.938 9.938v2.063h-2v-2.063c-5.268-0.477-9.46-4.67-9.938-9.938h-2.063v-2h2.063c0.477-5.268 4.67-9.46 9.938-9.938v-2.063zM15 7.031c-4.193 0.453-7.515 3.776-7.969 7.969h1.969v2h-1.969c0.453 4.193 3.776 7.515 7.969 7.969v-1.969h2v1.969c4.193-0.453 7.515-3.776 7.969-7.969h-1.969v-2h1.969c-0.453-4.193-3.776-7.515-7.969-7.969v1.969h-2v-1.969z"></path>
</svg>

After

Width:  |  Height:  |  Size: 607 B

View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<title>cube</title>
<path d="M16 4.406l0.406 0.188 10 4.5 0.594 0.25v12.688l-0.5 0.281-10.5 5.844-0.5-0.281-10.5-5.844v-12.688l0.594-0.25 10-4.5zM16 6.594l-7.688 3.438 7.688 3.844 7.688-3.844zM7 11.625v9.219l8 4.438v-9.656zM25 11.625l-8 4v9.656l8-4.438v-9.219z"></path>
</svg>

After

Width:  |  Height:  |  Size: 408 B

View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<title>cubes</title>
<path d="M16 4l0.375 0.156 6.625 2.656v6.719l5.406 2.344 0.594 0.281v8.063l-0.5 0.313-6 3.344-0.469 0.25-0.469-0.219-5.563-2.781-5.563 2.781-0.469 0.219-0.469-0.25-6-3.344-0.5-0.313v-8.063l0.594-0.281 5.406-2.344v-6.719l6.625-2.656zM16 6.188l-3.281 1.281 3.281 1.281 3.281-1.281zM11 8.938v4.625l4 1.781v-4.875zM21 8.938l-4 1.531v4.875l4-1.781v-4.625zM10 15.313l-3.625 1.563 3.625 1.813 3.625-1.781zM22 15.313l-2.5 1.094-1.125 0.5 3.625 1.781 3.625-1.813zM5 18.406v4.656l4 2.25v-4.906zM27 18.406l-4 2v4.906l4-2.25v-4.656zM15 18.469l-4 1.938v4.969l4-2v-4.906zM17 18.469v4.906l4 2v-4.969z"></path>
</svg>

After

Width:  |  Height:  |  Size: 754 B

View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<title>cut</title>
<path d="M6 6c2.197 0 4 1.803 4 4 0 0.494-0.115 0.969-0.281 1.406l6.063 3.438 10.219-5.844h4l-20.281 11.594c0.166 0.438 0.281 0.913 0.281 1.406 0 2.197-1.803 4-4 4s-4-1.803-4-4 1.803-4 4-4c0.981 0 1.864 0.375 2.563 0.969l5.156-2.938-5.219-2.969c-0.691 0.568-1.543 0.938-2.5 0.938-2.197 0-4-1.803-4-4s1.803-4 4-4zM6 8c-0.977 0-1.784 0.677-1.969 1.594-0.026 0.131-0.031 0.267-0.031 0.406 0 1.116 0.884 2 2 2s2-0.884 2-2-0.884-2-2-2zM19.094 16.813l10.906 6.188h-4l-8.906-5.094zM6 20c-0.977 0-1.784 0.677-1.969 1.594-0.026 0.131-0.031 0.267-0.031 0.406 0 1.116 0.884 2 2 2s2-0.884 2-2-0.884-2-2-2z"></path>
</svg>

After

Width:  |  Height:  |  Size: 760 B

View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<title>dashboard</title>
<path d="M16 4c6.616 0 12 5.384 12 12s-5.384 12-12 12-12-5.384-12-12 5.384-12 12-12zM16 6c-5.535 0-10 4.465-10 10s4.465 10 10 10 10-4.465 10-10-4.465-10-10-10zM16 7c0.552 0 1 0.448 1 1s-0.448 1-1 1-1-0.448-1-1 0.448-1 1-1zM10.344 9.344c0.552 0 1 0.448 1 1s-0.448 1-1 1-1-0.448-1-1 0.448-1 1-1zM20.938 9.656l1.438 1.406-4.438 4.438c0.041 0.16 0.063 0.327 0.063 0.5 0 1.105-0.895 2-2 2s-2-0.895-2-2 0.895-2 2-2c0.173 0 0.34 0.021 0.5 0.063zM8 15c0.552 0 1 0.448 1 1s-0.448 1-1 1-1-0.448-1-1 0.448-1 1-1zM24 15c0.552 0 1 0.448 1 1s-0.448 1-1 1-1-0.448-1-1 0.448-1 1-1zM10.344 20.656c0.552 0 1 0.448 1 1s-0.448 1-1 1-1-0.448-1-1 0.448-1 1-1zM21.656 20.656c0.552 0 1 0.448 1 1s-0.448 1-1 1-1-0.448-1-1 0.448-1 1-1z"></path>
</svg>

After

Width:  |  Height:  |  Size: 882 B

View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<title>diamond</title>
<path d="M9.531 6h12.938l5.313 6.375 0.5 0.594-0.5 0.656-11.781 15-0.781-1-11-14-0.5-0.656 0.5-0.594 5-6zM10.469 8l-3.344 4h4.313l2.688-4h-3.656zM17.875 8l2.688 4h4.313l-3.344-4h-3.656zM16 8.844l-2.125 3.156h4.25zM7.031 14l6.594 8.406-2.375-8.406h-4.219zM13.313 14l2.688 9.313 2.656-9.313h-5.344zM20.75 14l-2.375 8.375 6.594-8.375h-4.219z"></path>
</svg>

After

Width:  |  Height:  |  Size: 509 B

View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<title>exchange</title>
<path d="M26.188-1.719l6.719 6.719-6.719 6.719-1.406-1.438 4.281-4.281h-21.063v-2h21.063l-4.281-4.281zM13.813 12.281l1.406 1.438-4.281 4.281h21.063v2h-21.063l4.281 4.281-1.406 1.438-6.719-6.719 0.719-0.719z"></path>
</svg>

After

Width:  |  Height:  |  Size: 378 B

View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<title>exclamation-triangle</title>
<path d="M16 3.219l0.875 1.5 12 20.781 0.844 1.5h-27.438l0.844-1.5 12-20.781zM16 7.219l-10.25 17.781h20.5zM15 14h2v6h-2v-6zM15 21h2v2h-2v-2z"></path>
</svg>

After

Width:  |  Height:  |  Size: 324 B

View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<title>expand</title>
<path d="M14 5h13v13h-2v-9.563l-16.563 16.563h9.563v2h-13v-13h2v9.563l16.563-16.563h-9.563v-2z"></path>
</svg>

After

Width:  |  Height:  |  Size: 264 B

View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<title>external-link</title>
<path d="M18 5h9v9h-2v-5.563l-12.281 12.281-1.438-1.438 12.281-12.281h-5.563v-2zM5 9h13l-2 2h-9v14h14v-9l2-2v13h-18v-18z"></path>
</svg>

After

Width:  |  Height:  |  Size: 297 B

View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<title>eyedropper</title>
<path d="M24.656 3.031c1.108 0 2.222 0.41 3.063 1.25 1.681 1.681 1.681 4.444 0 6.125l-2.813 2.781 1 1-1.406 1.406-1-1-9.5 9.5c-1.064 1.064-1.845 1.684-2.531 2.063s-1.277 0.493-1.688 0.563-0.636 0.113-1.063 0.344-1.040 0.696-2 1.656l-0.719 0.688-0.719-0.688-2-2-0.688-0.719 0.688-0.719c0.986-0.986 1.475-1.621 1.719-2.063s0.276-0.66 0.344-1.063 0.196-1.011 0.563-1.688 0.96-1.429 2-2.469l9.5-9.5-1-1 1.406-1.406 1 1 2.781-2.813c0.84-0.84 1.954-1.25 3.063-1.25zM24.656 5.031c-0.592 0-1.197 0.228-1.656 0.688l-2.781 2.781 3.281 3.281 2.781-2.781c0.919-0.919 0.919-2.362 0-3.281-0.46-0.46-1.033-0.688-1.625-0.688zM18.813 9.906l-9.5 9.5c-0.96 0.96-1.426 1.605-1.656 2.031s-0.274 0.621-0.344 1.031-0.184 1.033-0.563 1.719c-0.259 0.469-0.859 1.1-1.406 1.719l0.75 0.75c0.601-0.529 1.227-1.126 1.688-1.375 0.677-0.366 1.254-0.463 1.656-0.531s0.621-0.1 1.063-0.344 1.108-0.733 2.094-1.719l9.5-9.5z"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<title>facebook</title>
<path d="M7 5h18c1.093 0 2 0.907 2 2v18c0 1.093-0.907 2-2 2h-18c-1.093 0-2-0.907-2-2v-18c0-1.093 0.907-2 2-2zM7 7v18h9.688v-6.75h-2.625v-3h2.625v-2.25c0-2.583 1.571-3.969 3.875-3.969 1.104 0 2.067 0.057 2.344 0.094v2.719h-1.625c-1.253 0-1.469 0.595-1.469 1.469v1.938h2.969l-0.375 3h-2.594v6.75h5.188v-18h-18z"></path>
</svg>

After

Width:  |  Height:  |  Size: 480 B

Some files were not shown because too many files have changed in this diff Show More