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
- '.github/workflows/test-backend.yml'
- 'backend/**/*'
- 'neo4j/**/*'
@ -6,4 +7,5 @@ docker: &docker
- 'docker-compose.*'
webapp: &webapp
- '.github/workflows/test-webapp.yml'
- '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
on: [push]
on: push
jobs:
files-changed:
@ -13,7 +13,7 @@ jobs:
steps:
- 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
id: changes
with:
@ -34,12 +34,13 @@ jobs:
run: |
docker build --target community -t "ocelotsocialnetwork/neo4j-community:test" neo4j/
docker save "ocelotsocialnetwork/neo4j-community:test" > /tmp/neo4j.tar
- name: Upload Artifact
uses: actions/upload-artifact@v3
- name: Cache docker images
id: cache-neo4j
uses: actions/cache/save@v3.3.1
with:
name: docker-neo4j-image
path: /tmp/neo4j.tar
key: ${{ github.run_id }}-backend-neo4j-cache
build_test_backend:
name: Docker Build Test - Backend
@ -54,12 +55,13 @@ jobs:
run: |
docker build --target test -t "ocelotsocialnetwork/backend:test" backend/
docker save "ocelotsocialnetwork/backend:test" > /tmp/backend.tar
- name: Upload Artifact
uses: actions/upload-artifact@v3
- name: Cache docker images
id: cache-backend
uses: actions/cache/save@v3.3.1
with:
name: docker-backend-test
path: /tmp/backend.tar
key: ${{ github.run_id }}-backend-cache
lint_backend:
name: Lint Backend
@ -84,28 +86,29 @@ jobs:
- name: Checkout code
uses: actions/checkout@v3
- name: Download Docker Image (Neo4J)
uses: actions/download-artifact@v3
- name: Restore Neo4J cache
uses: actions/cache/restore@v3.3.1
with:
name: docker-neo4j-image
path: /tmp
path: /tmp/neo4j.tar
key: ${{ github.run_id }}-backend-neo4j-cache
fail-on-cache-miss: true
- name: Load Docker Image
run: docker load < /tmp/neo4j.tar
- name: Download Docker Image (Backend)
uses: actions/download-artifact@v3
- name: Restore Backend cache
uses: actions/cache/restore@v3.3.1
with:
name: docker-backend-test
path: /tmp
path: /tmp/backend.tar
key: ${{ github.run_id }}-backend-cache
fail-on-cache-miss: true
- name: Load Docker Image
run: docker load < /tmp/backend.tar
- name: Load Docker Images
run: |
docker load < /tmp/neo4j.tar
docker load < /tmp/backend.tar
- name: backend | copy env files webapp
run: cp webapp/.env.template webapp/.env
- name: backend | copy env files backend
run: cp backend/.env.template backend/.env
- name: backend | copy env files
run: |
cp webapp/.env.template webapp/.env
cp backend/.env.template backend/.env
- name: backend | docker-compose
run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps neo4j backend
@ -118,3 +121,20 @@ jobs:
- name: backend | Unit test incl. coverage check
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
on: push
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:
name: Fullstack tests
if: success()
needs: docker_preparation
runs-on: ubuntu-latest
env:
jobs: 8
@ -12,34 +57,56 @@ jobs:
# run copies of the current job in parallel
job: [1, 2, 3, 4, 5, 6, 7, 8]
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Restore cache
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
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
- name: Boot up test system | docker-compose
run: |
cd backend
yarn install
yarn build
cd ..
yarn install
yarn run cypress:run --spec $(cypress/parallel-features.sh ${{ matrix.job }} ${{ env.jobs }} )
chmod +x /opt/cucumber-json-formatter
sudo ln -fs /opt/cucumber-json-formatter /usr/bin/cucumber-json-formatter
docker load < /tmp/images/neo4j.tar
docker load < /tmp/images/backend.tar
docker load < /tmp/images/webapp.tar
docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps webapp neo4j backend
sleep 90s
##########################################################################
# UPLOAD SCREENSHOTS - IF TESTS FAIL #####################################
##########################################################################
- name: Full stack tests | if any test failed, upload screenshots
- name: Full stack tests | run tests
id: e2e-tests
run: yarn run cypress:run --spec $(cypress/parallel-features.sh ${{ matrix.job }} ${{ env.jobs }} )
- 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' }}
uses: actions/upload-artifact@v3
with:
name: cypress-screenshots
path: cypress/screenshots/
name: ocelot-e2e-test-report-pr${{ needs.docker_preparation.outputs.pr-number }}
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
on: [push]
on: push
jobs:
files-changed:
@ -23,7 +23,7 @@ jobs:
prepare:
name: Prepare
if: needs.files-changed.outputs.webapp
if: needs.files-changed.outputs.webapp == 'true'
needs: files-changed
runs-on: ubuntu-latest
steps:
@ -34,30 +34,30 @@ jobs:
run: |
scripts/translations/sort.sh
scripts/translations/missing-keys.sh
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]
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: webapp | Build 'test' image
- name: Webapp | Build 'test' image
run: |
docker build --target test -t "ocelotsocialnetwork/webapp:test" webapp/
docker save "ocelotsocialnetwork/webapp:test" > /tmp/webapp.tar
- name: Upload Artifact
uses: actions/upload-artifact@v3
- name: Cache docker image
uses: actions/cache/save@v3.3.1
with:
name: docker-webapp-test
path: /tmp/webapp.tar
key: ${{ github.run_id }}-webapp-cache
lint_webapp:
name: Lint Webapp
if: needs.files-changed.outputs.webapp
if: needs.files-changed.outputs.webapp == 'true'
needs: files-changed
runs-on: ubuntu-latest
steps:
@ -69,7 +69,7 @@ jobs:
unit_test_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]
runs-on: ubuntu-latest
permissions:
@ -78,20 +78,19 @@ jobs:
- name: Checkout code
uses: actions/checkout@v3
- name: Download Docker Image (Webapp)
uses: actions/download-artifact@v3
- name: Restore webapp cache
uses: actions/cache/restore@v3.3.1
with:
name: docker-webapp-test
path: /tmp
path: /tmp/webapp.tar
key: ${{ github.run_id }}-webapp-cache
- name: Load Docker Image
run: docker load < /tmp/webapp.tar
- name: backend | copy env files webapp
run: cp webapp/.env.template webapp/.env
- name: backend | copy env files backend
run: cp backend/.env.template backend/.env
- name: Copy env files
run: |
cp webapp/.env.template webapp/.env
cp backend/.env.template backend/.env
- name: backend | docker-compose
run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps webapp
@ -99,3 +98,18 @@ jobs:
- name: webapp | Unit tests incl. coverage check
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 = {
root: true,
env: {
es6: true,
// es6: true,
node: true,
jest: true
},
parserOptions: {
/* parserOptions: {
parser: 'babel-eslint'
},
},*/
parser: '@typescript-eslint/parser',
plugins: ['prettier', '@typescript-eslint' /*, 'import', 'n', 'promise'*/],
extends: [
'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: [
'jest'
],
rules: {
settings: {
'import/parsers': {
'@typescript-eslint/parser': ['.ts', '.tsx'],
},
'import/resolver': {
typescript: {
project: ['./tsconfig.json'],
},
node: true,
},
},
/* rules: {
//'indent': [ 'error', 2 ],
//'quotes': [ "error", "single"],
// 'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off',
'no-console': ['error'],
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
'prettier/prettier': ['error'],
> 'no-console': ['error'],
> 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
> '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: {
global: {
lines: 70,
lines: 67,
},
},
testMatch: ['**/src/**/?(*.)+(spec|test).ts?(x)'],

View File

@ -9,12 +9,12 @@
"main": "src/index.ts",
"scripts": {
"__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",
"start": "node build/",
"prod:migrate": "migrate --migrations-dir ./build/src/db/migrations --store ./build/src/db/migrate/store.js",
"start": "node build/src/",
"build": "tsc && ./scripts/build.copy.files.sh",
"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",
"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",
"db:clean": "ts-node src/db/clean.ts",
"db:reset": "yarn run db:clean",
@ -45,7 +45,6 @@
"cheerio": "~1.0.0-rc.3",
"cors": "~2.8.5",
"cross-env": "~7.0.3",
"debug": "~4.1.1",
"dotenv": "~8.2.0",
"express": "^4.17.1",
"graphql": "^14.6.0",
@ -97,27 +96,30 @@
},
"devDependencies": {
"@faker-js/faker": "7.6.0",
"@types/jest": "^29.5.2",
"@types/jest": "^27.0.2",
"@types/node": "^20.2.5",
"@typescript-eslint/eslint-plugin": "^5.57.1",
"@typescript-eslint/parser": "^5.57.1",
"apollo-server-testing": "~2.11.0",
"chai": "~4.2.0",
"cucumber": "~6.0.5",
"eslint": "~6.8.0",
"eslint-config-prettier": "~6.15.0",
"eslint-config-standard": "~14.1.1",
"eslint-plugin-import": "~2.20.2",
"eslint-plugin-jest": "~23.8.2",
"eslint-plugin-node": "~11.1.0",
"eslint-plugin-prettier": "~3.4.1",
"eslint-plugin-promise": "~4.3.1",
"eslint-plugin-standard": "~4.0.1",
"jest": "29.4",
"eslint": "^8.37.0",
"eslint-config-prettier": "^8.8.0",
"eslint-config-standard": "^17.0.0",
"eslint-import-resolver-typescript": "^3.5.4",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-jest": "^27.2.1",
"eslint-plugin-n": "^15.7.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-promise": "^6.1.1",
"eslint-plugin-security": "^1.7.1",
"prettier": "^2.8.7",
"jest": "^27.2.4",
"nodemon": "~2.0.2",
"prettier": "~2.3.2",
"rosie": "^2.0.1",
"ts-jest": "^29.1.0",
"ts-jest": "^27.0.5",
"ts-node": "^10.9.1",
"typescript": "^5.0.4"
"typescript": "^4.9.4"
},
"resolutions": {
"**/**/fs-capacitor": "^6.2.0",

View File

@ -1,24 +1,24 @@
#!/bin/sh
# html files
mkdir -p build/middleware/helpers/email/templates/
cp -r src/middleware/helpers/email/templates/*.html build/middleware/helpers/email/templates/
mkdir -p build/src/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/
cp -r src/middleware/helpers/email/templates/en/*.html 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/src/middleware/helpers/email/templates/en/
mkdir -p build/middleware/helpers/email/templates/de/
cp -r src/middleware/helpers/email/templates/de/*.html 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/src/middleware/helpers/email/templates/de/
# gql files
mkdir -p build/schema/types/
cp -r src/schema/types/*.gql build/schema/types/
mkdir -p build/src/schema/types/
cp -r src/schema/types/*.gql build/src/schema/types/
mkdir -p build/schema/types/enum/
cp -r src/schema/types/enum/*.gql build/schema/types/enum/
mkdir -p build/src/schema/types/enum/
cp -r src/schema/types/enum/*.gql build/src/schema/types/enum/
mkdir -p build/schema/types/scalar/
cp -r src/schema/types/scalar/*.gql build/schema/types/scalar/
mkdir -p build/src/schema/types/scalar/
cp -r src/schema/types/scalar/*.gql build/src/schema/types/scalar/
mkdir -p build/schema/types/type/
cp -r src/schema/types/type/*.gql build/schema/types/type/
mkdir -p build/src/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
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 environment = {
@ -95,6 +95,7 @@ Object.entries(required).map((entry) => {
if (!entry[1]) {
throw new Error(`ERROR: "${entry[0]}" env variable is missing.`)
}
return entry
})
export default {

View File

@ -1,2 +1,2 @@
const tsNode = require('ts-node');
module.exports = tsNode.register;
const tsNode = require('ts-node')
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 = () => {
return gql`
mutation (
$roomId: ID!
$content: String!
) {
CreateMessage(
roomId: $roomId
content: $content
) {
mutation ($roomId: ID!, $content: String!) {
CreateMessage(roomId: $roomId, content: $content) {
id
content
senderId
username
avatar
date
saved
distributed
seen
}
}
`
@ -19,16 +20,31 @@ export const createMessageMutation = () => {
export const messageQuery = () => {
return gql`
query($roomId: ID!) {
Message(roomId: $roomId) {
query ($roomId: ID!, $first: Int, $offset: Int) {
Message(roomId: $roomId, first: $first, offset: $offset, orderBy: indexId_desc) {
_id
id
indexId
content
senderId
author {
id
}
username
avatar
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 = () => {
return gql`
mutation (
$userId: ID!
) {
CreateRoom(
userId: $userId
) {
id
roomId
}
}
`
}
export const roomQuery = () => {
return gql`
query {
Room {
mutation ($userId: ID!) {
CreateRoom(userId: $userId) {
id
roomId
roomName
lastMessageAt
unreadCount
#avatar
users {
_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)) {
throw new Error('please provide an fields array for the walkRecursive helper')
}
if (data && typeof data === 'string' && fields.includes(_key)) {
// well we found what we searched for, lets replace the value with our callback result
const key = _key.split('!')
if (key.length === 1 || key[1] !== fieldName) data = callback(data, key[0])
const fieldDef = fields.find((f) => f.field === _key)
if (data && typeof data === 'string' && fieldDef) {
if (!fieldDef.excludes?.includes(fieldName)) data = callback(data, _key)
} else if (data && Array.isArray(data)) {
// go into the rabbit hole and dig through that array
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',
'span',
'blockquote',
'usertag',
],
allowedAttributes: {
a: ['href', 'class', 'target', 'data-*', 'contenteditable'],

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,22 +1,22 @@
import uniqueSlug from './uniqueSlug'
describe('uniqueSlug', () => {
it('slugifies given string', () => {
it('slugifies given string', async () => {
const string = 'Hello World'
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 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 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 () => {

View File

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

View File

@ -1,38 +1,29 @@
// 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
declare var Cypress: any | undefined
declare let Cypress: any | undefined
export default {
Image: typeof Cypress !== 'undefined' ? require('./Image') : require('./Image').default,
Badge: typeof Cypress !== 'undefined' ? require('./Badge') : require('./Badge').default,
User: typeof Cypress !== 'undefined' ? require('./User') : require('./User').default,
Group: typeof Cypress !== 'undefined' ? require('./Group') : require('./Group').default,
EmailAddress:
typeof Cypress !== 'undefined'
? require('./EmailAddress')
: require('./EmailAddress').default,
typeof Cypress !== 'undefined' ? require('./EmailAddress') : require('./EmailAddress').default,
UnverifiedEmailAddress:
typeof Cypress !== 'undefined'
? require('./UnverifiedEmailAddress')
: require('./UnverifiedEmailAddress').default,
SocialMedia:
typeof Cypress !== 'undefined'
? require('./SocialMedia')
: require('./SocialMedia').default,
typeof Cypress !== 'undefined' ? require('./SocialMedia') : require('./SocialMedia').default,
Post: typeof Cypress !== 'undefined' ? require('./Post') : require('./Post').default,
Comment:
typeof Cypress !== 'undefined' ? require('./Comment') : require('./Comment').default,
Category:
typeof Cypress !== 'undefined' ? require('./Category') : require('./Category').default,
Comment: typeof Cypress !== 'undefined' ? require('./Comment') : require('./Comment').default,
Category: typeof Cypress !== 'undefined' ? require('./Category') : require('./Category').default,
Tag: typeof Cypress !== 'undefined' ? require('./Tag') : require('./Tag').default,
Location:
typeof Cypress !== 'undefined' ? require('./Location') : require('./Location').default,
Location: typeof Cypress !== 'undefined' ? require('./Location') : require('./Location').default,
Donations:
typeof Cypress !== 'undefined' ? require('./Donations') : require('./Donations').default,
Report: typeof Cypress !== 'undefined' ? require('./Report') : require('./Report').default,
Migration:
typeof Cypress !== 'undefined' ? require('./Migration') : require('./Migration').default,
InviteCode:
typeof Cypress !== 'undefined'
? require('./InviteCode')
: require('./InviteCode').default,
typeof Cypress !== 'undefined' ? 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 () => {
await expect(
mergeImage(post, 'HERO_IMAGE', imageInput, { uploadCallback, deleteCallback }),

View File

@ -1,13 +1,15 @@
import { createTestClient } from 'apollo-server-testing'
import Factory, { cleanDatabase } from '../../db/factories'
import { getNeode, getDriver } from '../../db/neo4j'
import { createRoomMutation } from '../../graphql/rooms'
import { createMessageMutation, messageQuery } from '../../graphql/messages'
import createServer from '../../server'
import { createRoomMutation, roomQuery } from '../../graphql/rooms'
import { createMessageMutation, messageQuery, markMessagesAsSeen } from '../../graphql/messages'
import createServer, { pubsub } from '../../server'
const driver = getDriver()
const neode = getNeode()
const pubsubSpy = jest.spyOn(pubsub, 'publish')
let query
let mutate
let authenticatedUser
@ -22,6 +24,9 @@ beforeAll(async () => {
driver,
neode,
user: authenticatedUser,
cypherParams: {
currentUserId: authenticatedUser ? authenticatedUser.id : null,
},
}
},
})
@ -34,43 +39,44 @@ afterAll(async () => {
driver.close()
})
describe('Message', () => {
let roomId: string
beforeAll(async () => {
[chattingUser, otherChattingUser, notChattingUser] = await Promise.all([
Factory.build(
'user',
{
id: 'chatting-user',
name: 'Chatting User',
},
),
Factory.build(
'user',
{
id: 'other-chatting-user',
name: 'Other Chatting User',
},
),
Factory.build(
'user',
{
id: 'not-chatting-user',
name: 'Not Chatting User',
},
),
;[chattingUser, otherChattingUser, notChattingUser] = await Promise.all([
Factory.build('user', {
id: 'chatting-user',
name: 'Chatting User',
}),
Factory.build('user', {
id: 'other-chatting-user',
name: 'Other Chatting User',
}),
Factory.build('user', {
id: 'not-chatting-user',
name: 'Not Chatting User',
}),
])
})
describe('create message', () => {
beforeEach(() => {
jest.clearAllMocks()
})
describe('unauthenticated', () => {
it('throws authorization error', async () => {
await expect(mutate({ mutation: createMessageMutation(), variables: {
roomId: 'some-id', content: 'Some bla bla bla', } })).resolves.toMatchObject({
errors: [{ message: 'Not Authorized!' }],
})
await expect(
mutate({
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', () => {
it('returns null', async () => {
await expect(mutate({ mutation: createMessageMutation(), variables: {
roomId: 'some-id', content: 'Some bla bla bla', } })).resolves.toMatchObject({
errors: undefined,
data: {
CreateMessage: null,
it('returns null and does not publish subscription', async () => {
await expect(
mutate({
mutation: createMessageMutation(),
variables: {
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', () => {
it('returns the message', async () => {
await expect(mutate({
mutation: createMessageMutation(),
variables: {
roomId,
it('returns the message and publishes subscriptions', async () => {
await expect(
mutate({
mutation: createMessageMutation(),
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',
} })).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,
data: {
CreateMessage: {
id: expect.any(String),
content: 'Some nice message to other chatting user',
},
Room: [
expect.objectContaining({
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 () => {
authenticatedUser = await notChattingUser.toJson()
})
it('returns null', async () => {
await expect(mutate({
mutation: createMessageMutation(),
variables: {
roomId,
content: 'I have no access to this room!',
} })).resolves.toMatchObject({
errors: undefined,
data: {
CreateMessage: null,
await expect(
mutate({
mutation: createMessageMutation(),
variables: {
roomId,
content: 'I have no access to this room!',
},
})
}),
).resolves.toMatchObject({
errors: undefined,
data: {
CreateMessage: null,
},
})
})
})
})
@ -151,14 +254,17 @@ describe('Message', () => {
})
it('throws authorization error', async () => {
await expect(query({
query: messageQuery(),
variables: {
roomId: 'some-id' }
})).resolves.toMatchObject({
await expect(
query({
query: messageQuery(),
variables: {
roomId: 'some-id',
},
}),
).resolves.toMatchObject({
errors: [{ message: 'Not Authorized!' }],
})
})
})
})
describe('authenticated', () => {
@ -168,12 +274,14 @@ describe('Message', () => {
describe('room does not exists', () => {
it('returns null', async () => {
await expect(query({
query: messageQuery(),
variables: {
roomId: 'some-id'
},
})).resolves.toMatchObject({
await expect(
query({
query: messageQuery(),
variables: {
roomId: 'some-id',
},
}),
).resolves.toMatchObject({
errors: undefined,
data: {
Message: [],
@ -193,15 +301,21 @@ describe('Message', () => {
expect(result).toMatchObject({
errors: undefined,
data: {
Message: [{
id: expect.any(String),
_id: result.data.Message[0].id,
content: 'Some nice message to other chatting user',
senderId: 'chatting-user',
username: 'Chatting User',
avatar: expect.any(String),
date: expect.any(String),
}],
Message: [
{
id: expect.any(String),
_id: result.data.Message[0].id,
indexId: 0,
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: true,
seen: false,
},
],
},
})
})
@ -213,7 +327,7 @@ describe('Message', () => {
variables: {
roomId,
content: 'A nice response message to chatting user',
}
},
})
authenticatedUser = await chattingUser.toJson()
await mutate({
@ -221,49 +335,126 @@ describe('Message', () => {
variables: {
roomId,
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,
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),
},
{
saved: true,
distributed: true,
seen: false,
}),
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),
},
{
saved: true,
distributed: true,
seen: false,
}),
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),
},
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', () => {
@ -272,19 +463,91 @@ describe('Message', () => {
})
it('returns null', async () => {
await expect(query({
query: messageQuery(),
variables: {
roomId,
},
})).resolves.toMatchObject({
await expect(
query({
query: messageQuery(),
variables: {
roomId,
},
}),
).resolves.toMatchObject({
errors: undefined,
data: {
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 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 {
Subscription: {
chatMessageAdded: {
subscribe: withFilter(
() => pubsub.asyncIterator(CHAT_MESSAGE_ADDED),
(payload, variables, context) => {
return payload.userId === context.user?.id
},
),
},
},
Query: {
Message: async (object, params, context, resolveInfo) => {
const { roomId } = params
@ -13,47 +42,121 @@ export default {
id: context.user.id,
},
}
const resolved = await neo4jgraphql(object, params, context, resolveInfo)
if (resolved) {
resolved.forEach((message) => {
message._id = message.id
})
const undistributedMessagesIds = resolved
.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: {
CreateMessage: async (_parent, params, context, _resolveInfo) => {
const { roomId, content } = params
const { user: { id: currentUserId } } = context
const {
user: { id: currentUserId },
} = context
const session = context.driver.session()
const writeTxResultPromise = session.writeTransaction(async (transaction) => {
const createMessageCypher = `
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 {
createdAt: toString(datetime()),
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)
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(
createMessageCypher,
{ currentUserId, roomId, content }
)
const createMessageTxResponse = await transaction.run(createMessageCypher, {
currentUserId,
roomId,
content,
})
const [message] = await createMessageTxResponse.records.map((record) =>
record.get('message'),
)
record.get('message'),
)
return message
})
try {
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
} catch (error) {
throw new Error(error)
} finally {
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: {
@ -61,7 +164,7 @@ export default {
hasOne: {
author: '<-[:CREATED]-(related:User)',
room: '-[:INSIDE]->(related:Room)',
}
},
}),
}
},
}

View File

@ -238,7 +238,7 @@ describe('given some notifications', () => {
variables: { ...variables, read: false },
})
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', () => {

View File

@ -7,8 +7,8 @@ export default {
notificationAdded: {
subscribe: withFilter(
() => pubsub.asyncIterator(NOTIFICATION_ADDED),
(payload, variables) => {
return payload.notificationAdded.to.id === variables.userId
(payload, variables, context) => {
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('is object', () => {
beforeEach(() => {

View File

@ -28,7 +28,7 @@ export default {
},
SignupVerification: async (_parent, args, context) => {
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)) {
throw new UserInputError('Invalid version format!')
}

View File

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

View File

@ -1,7 +1,8 @@
import { createTestClient } from 'apollo-server-testing'
import Factory, { cleanDatabase } from '../../db/factories'
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'
const driver = getDriver()
@ -21,6 +22,9 @@ beforeAll(async () => {
driver,
neode,
user: authenticatedUser,
cypherParams: {
currentUserId: authenticatedUser ? authenticatedUser.id : null,
},
}
},
})
@ -34,57 +38,64 @@ afterAll(async () => {
})
describe('Room', () => {
let roomId: string
beforeAll(async () => {
[chattingUser, otherChattingUser, notChattingUser] = await Promise.all([
Factory.build(
'user',
{
id: 'chatting-user',
name: 'Chatting User',
},
),
Factory.build(
'user',
{
id: 'other-chatting-user',
name: 'Other Chatting User',
},
),
Factory.build(
'user',
{
id: 'not-chatting-user',
name: 'Not Chatting User',
},
),
;[chattingUser, otherChattingUser, notChattingUser] = await Promise.all([
Factory.build('user', {
id: 'chatting-user',
name: 'Chatting User',
}),
Factory.build('user', {
id: 'other-chatting-user',
name: 'Other Chatting User',
}),
Factory.build('user', {
id: 'not-chatting-user',
name: 'Not Chatting User',
}),
Factory.build('user', {
id: 'second-chatting-user',
name: 'Second Chatting User',
}),
Factory.build('user', {
id: 'third-chatting-user',
name: 'Third Chatting User',
}),
])
})
describe('create room', () => {
describe('unauthenticated', () => {
it('throws authorization error', async () => {
await expect(mutate({ mutation: createRoomMutation(), variables: {
userId: 'some-id' } })).resolves.toMatchObject({
errors: [{ message: 'Not Authorized!' }],
})
await expect(
mutate({
mutation: createRoomMutation(),
variables: {
userId: 'some-id',
},
}),
).resolves.toMatchObject({
errors: [{ message: 'Not Authorized!' }],
})
})
})
describe('authenticated', () => {
let roomId: string
beforeAll(async () => {
authenticatedUser = await chattingUser.toJson()
})
describe('user id does not exist', () => {
it('returns null', async () => {
await expect(mutate({
mutation: createRoomMutation(),
variables: {
userId: 'not-existing-user',
},
})).resolves.toMatchObject({
await expect(
mutate({
mutation: createRoomMutation(),
variables: {
userId: 'not-existing-user',
},
}),
).resolves.toMatchObject({
errors: undefined,
data: {
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', () => {
it('returns the id of the room', async () => {
const result = await mutate({
@ -108,6 +134,26 @@ describe('Room', () => {
CreateRoom: {
id: expect.any(String),
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', () => {
it('returns the id of the room', async () => {
await expect(mutate({
mutation: createRoomMutation(),
variables: {
userId: 'other-chatting-user',
},
})).resolves.toMatchObject({
await expect(
mutate({
mutation: createRoomMutation(),
variables: {
userId: 'other-chatting-user',
},
}),
).resolves.toMatchObject({
errors: undefined,
data: {
CreateRoom: {
@ -130,7 +178,7 @@ describe('Room', () => {
},
})
})
})
})
})
})
@ -139,11 +187,11 @@ describe('Room', () => {
beforeAll(() => {
authenticatedUser = null
})
it('throws authorization error', async () => {
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 () => {
const result = await query({ query: roomQuery() })
const result = await query({ query: roomQuery() })
expect(result).toMatchObject({
errors: undefined,
data: {
@ -203,6 +251,7 @@ describe('Room', () => {
id: expect.any(String),
roomId: result.data.Room[0].id,
roomName: 'Chatting User',
unreadCount: 0,
users: expect.arrayContaining([
{
_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 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 {
Subscription: {
roomCountUpdated: {
subscribe: withFilter(
() => pubsub.asyncIterator(ROOM_COUNT_UPDATED),
(payload, variables, context) => {
return payload.userId === context.user?.id
},
),
},
},
Query: {
Room: async (object, params, context, resolveInfo) => {
Room: async (object, params, context, resolveInfo) => {
if (!params.filter) params.filter = {}
params.filter.users_some = {
id: context.user.id,
}
const resolved = await neo4jgraphql(object, params, context, resolveInfo)
if (resolved) {
resolved.forEach((room) => {
if (room.users) {
room.roomName = room.users.filter((user) => user.id !== context.user.id)[0].name
room.users.forEach((user) => {
user._id = user.id
})
}
})
return neo4jgraphql(object, params, context, resolveInfo)
},
UnreadRooms: async (object, params, context, resolveInfo) => {
const {
user: { id: currentUserId },
} = context
const session = context.driver.session()
try {
const count = await getUnreadRoomsCount(currentUserId, session)
return count
} finally {
session.close()
}
return resolved
},
},
Mutation: {
CreateRoom: async (_parent, params, context, _resolveInfo) => {
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 writeTxResultPromise = session.writeTransaction(async (transaction) => {
const createRoomCypher = `
@ -35,15 +65,23 @@ export default {
ON CREATE SET
room.createdAt = toString(datetime()),
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(
createRoomCypher,
{ userId, currentUserId }
)
const [room] = await createRommTxResponse.records.map((record) =>
record.get('room'),
)
const createRommTxResponse = await transaction.run(createRoomCypher, {
userId,
currentUserId,
})
const [room] = await createRommTxResponse.records.map((record) => record.get('room'))
return room
})
try {
@ -56,14 +94,15 @@ export default {
throw new Error(error)
} finally {
session.close()
}
}
},
},
Room: {
...Resolver('Room', {
undefinedToNull: ['lastMessageAt'],
hasMany: {
users: '<-[:CHATS_IN]-(related:User)',
}
},
}),
}
},
}

View File

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

View File

@ -144,7 +144,7 @@ export default {
params.locationName = params.locationName === '' ? null : params.locationName
const { termsAndConditionsAgreedVersion } = params
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)) {
throw new ForbiddenError('Invalid version format!')
}

View File

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

View File

@ -2,8 +2,13 @@
# room: _RoomFilter
# }
enum _MessageOrdering {
indexId_desc
}
type Message {
id: ID!
indexId: Int!
createdAt: String
updatedAt: String
@ -16,6 +21,10 @@ type Message {
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")
date: String! @cypher(statement: "RETURN this.createdAt")
saved: Boolean
distributed: Boolean
seen: Boolean
}
type Mutation {
@ -23,8 +32,19 @@ type Mutation {
roomId: ID!
content: String!
): Message
MarkMessagesAsSeen(messageIds: [String!]): Boolean
}
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 {
notificationAdded(userId: ID!): NOTIFIED
notificationAdded: NOTIFIED
}

View File

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

View File

@ -5,6 +5,12 @@
# users_some: _UserFilter
# }
# TODO change this to last message date
enum _RoomOrdering {
lastMessageAt_desc
createdAt_desc
}
type Room {
id: ID!
createdAt: String
@ -13,7 +19,28 @@ type Room {
users: [User]! @relation(name: "CHATS_IN", direction: "IN")
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 {
@ -23,5 +50,13 @@ type Mutation {
}
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'
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
let prodPubsub, devPubsub
const options = {

View File

@ -106,6 +106,4 @@
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"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]
},
});
on("after:run", (results) => {
if (results) {
console.log(results.status);
}
});
return config;
}
@ -42,10 +36,7 @@ module.exports = defineConfig({
baseUrl: "http://localhost:3000",
specPattern: "cypress/e2e/**/*.feature",
supportFile: "cypress/support/e2e.js",
retries: {
runMode: 2,
openMode: 0,
},
retries: 0,
video: false,
setupNodeEvents,
},

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
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 => {
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:
## 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
- 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}
# 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
# 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/logos.js src/config/
COPY configurations/${CONFIGURATION}/branding/constants/metadata.js src/config/
COPY configurations/${CONFIGURATION}/branding/constants/emails.ts src/config/
COPY configurations/${CONFIGURATION}/branding/constants/logos.ts src/config/
COPY configurations/${CONFIGURATION}/branding/constants/metadata.ts src/config/
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
# TODO - externalize the uploads so we can copy the whole folder
COPY --from=build ${DOCKER_WORKDIR}/public/img/ ./public/img/
COPY --from=build ${DOCKER_WORKDIR}/public/providers.json ./public/providers.json
COPY --from=build ${DOCKER_WORKDIR}/public/providers.json ./build/public/providers.json
# Copy package.json for script definitions (lock file should not be needed)
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 configurations/${CONFIGURATION}/branding/static/ static/
COPY configurations/${CONFIGURATION}/branding/constants/ constants/
RUN /bin/sh -c 'cd constants && for f in *.ts; do mv -- "$f" "${f%.ts}.js"; done'
# locales
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 configurations/${CONFIGURATION}/branding/static/ static/
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/assets/styles/imports/ assets/styles/imports/
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
# 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"
},
"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": {
"db:seed": "cd backend && yarn run db:seed",
"db:reset": "cd backend && yarn run db:reset",
"cypress:run": "cypress run --browser electron --config-file ./cypress/cypress.config.js",
"cypress:open": "cypress open --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 --e2e --browser electron --config-file ./cypress/cypress.config.js",
"cucumber:setup": "cd backend && yarn run dev",
"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\")"
@ -27,13 +39,12 @@
"@babel/register": "^7.12.10",
"@badeball/cypress-cucumber-preprocessor": "^15.1.4",
"@cypress/browserify-preprocessor": "^3.0.2",
"@faker-js/faker": "7.6.0",
"@faker-js/faker": "8.0.2",
"auto-changelog": "^2.3.0",
"bcryptjs": "^2.4.3",
"cross-env": "^7.0.3",
"cucumber": "^6.0.5",
"cypress": "^12.14.0",
"cypress-file-upload": "^3.5.3",
"cypress": "^12.17.0",
"cypress-network-idle": "^1.14.2",
"date-fns": "^2.25.0",
"dotenv": "^16.3.1",
@ -42,6 +53,7 @@
"import": "^0.0.6",
"jsonwebtoken": "^8.5.1",
"mock-socket": "^9.0.3",
"multiple-cucumber-html-reporter": "^3.4.0",
"neo4j-driver": "^4.3.4",
"neode": "^0.4.8",
"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