diff --git a/.github_changelog_generator b/.github_changelog_generator new file mode 100644 index 000000000..d53ec7430 --- /dev/null +++ b/.github_changelog_generator @@ -0,0 +1,2 @@ +since-tag=0.1.5 +release-branch=master \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index f03686c50..f12704c70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,13 +6,19 @@ **Fixed bugs:** +- π \[Bug\] Error when saving changed article [\#1992](https://github.com/Human-Connection/Human-Connection/issues/1992) +- π \[Bug\] Translations in german for pin a post missing [\#1971](https://github.com/Human-Connection/Human-Connection/issues/1971) +- π \[Bug\] Comments β both editors are visible at the same time [\#1970](https://github.com/Human-Connection/Human-Connection/issues/1970) +- π \[Bug\] Cropped images, at the moment, are much larger [\#1933](https://github.com/Human-Connection/Human-Connection/issues/1933) - π \[Bug\] All images uploaded since introducing image cropping are named ...-blob [\#1932](https://github.com/Human-Connection/Human-Connection/issues/1932) - π \[Bug\] Moderators reports list does not sort [\#1924](https://github.com/Human-Connection/Human-Connection/issues/1924) +- π \[Bug\] Double ellipses for truncated strings [\#1893](https://github.com/Human-Connection/Human-Connection/issues/1893) - π \[Bug\] Login page translation error DE [\#1874](https://github.com/Human-Connection/Human-Connection/issues/1874) - π \[Bug\] Add Reference ToS and Data privacy Statement to Register Dialogue [\#1873](https://github.com/Human-Connection/Human-Connection/issues/1873) - π \[Bug\] extend Title to 100 characters [\#1812](https://github.com/Human-Connection/Human-Connection/issues/1812) - π \[Bug\] Shortened text is shown longer than full text [\#1795](https://github.com/Human-Connection/Human-Connection/issues/1795) - π \[Bug\] Order contributions chronologically on profile page [\#1754](https://github.com/Human-Connection/Human-Connection/issues/1754) +- π \[Bug\] Sorting is not maintained after page navigation [\#1733](https://github.com/Human-Connection/Human-Connection/issues/1733) - π \[Bug\] Update maintenance page email [\#1731](https://github.com/Human-Connection/Human-Connection/issues/1731) - π \[Bug\] Editing comments is not reactive again [\#1718](https://github.com/Human-Connection/Human-Connection/issues/1718) - π \[Bug\] Comments with mentions in the end not displayed [\#1665](https://github.com/Human-Connection/Human-Connection/issues/1665) @@ -31,6 +37,7 @@ - π \[Bug\] One cypress test fails but it does not fail the build [\#1312](https://github.com/Human-Connection/Human-Connection/issues/1312) - π \[Bug\] ContributionForm CreatePost is throwing Vue errors [\#1311](https://github.com/Human-Connection/Human-Connection/issues/1311) - π \[Bug\] TypeError: Cannot read property 'offsetTop' of null [\#1273](https://github.com/Human-Connection/Human-Connection/issues/1273) +- π° Translate texts for pinning a post to German [\#1972](https://github.com/Human-Connection/Human-Connection/pull/1972) ([Tirokk](https://github.com/Tirokk)) - π° Add Cypher statement for ordering [\#1926](https://github.com/Human-Connection/Human-Connection/pull/1926) ([Tirokk](https://github.com/Tirokk)) **Closed issues:** @@ -47,6 +54,7 @@ - π \[Feature\] Report with reason [\#1469](https://github.com/Human-Connection/Human-Connection/issues/1469) - π \[Feature\] Image cropping [\#1466](https://github.com/Human-Connection/Human-Connection/issues/1466) - π \[Feature\] Update `lastActiveAt` on every JWT token check [\#1305](https://github.com/Human-Connection/Human-Connection/issues/1305) +- π \[Feature\] Create a pinned Post for administration [\#1205](https://github.com/Human-Connection/Human-Connection/issues/1205) - π \[Feature\] Expand and mark comment if one jumps to it by URL hashtag [\#1204](https://github.com/Human-Connection/Human-Connection/issues/1204) - π \[Feature\] Make Invite an Registration E-Mails translatable and pretty [\#1186](https://github.com/Human-Connection/Human-Connection/issues/1186) - π \[Feature\] @Username: Unique user identification if identical profile names exist [\#1069](https://github.com/Human-Connection/Human-Connection/issues/1069) @@ -54,21 +62,43 @@ **Merged pull requests:** +- build\(deps\): bump nuxt from 2.10.1 to 2.10.2 in /webapp [\#1987](https://github.com/Human-Connection/Human-Connection/pull/1987) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) +- build\(deps-dev\): bump @storybook/vue from 5.2.4 to 5.2.5 in /webapp [\#1981](https://github.com/Human-Connection/Human-Connection/pull/1981) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) +- build\(deps-dev\): bump eslint-plugin-jest from 22.17.0 to 22.20.0 in /backend [\#1978](https://github.com/Human-Connection/Human-Connection/pull/1978) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) +- build\(deps\): bump node from 12.12.0-alpine to 12.13.0-alpine in /webapp [\#1977](https://github.com/Human-Connection/Human-Connection/pull/1977) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) +- Hide new CommentForm while editing a comment [\#1973](https://github.com/Human-Connection/Human-Connection/pull/1973) ([mattwr18](https://github.com/mattwr18)) +- fix: Only one ellipse is displayed [\#1968](https://github.com/Human-Connection/Human-Connection/pull/1968) ([Mogge](https://github.com/Mogge)) +- build\(deps-dev\): bump @vue/cli-shared-utils from 3.12.0 to 4.0.4 in /webapp [\#1965](https://github.com/Human-Connection/Human-Connection/pull/1965) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) +- build\(deps\): bump neo4j from 3.5.11-enterprise to 3.5.12-enterprise in /neo4j [\#1963](https://github.com/Human-Connection/Human-Connection/pull/1963) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) +- build\(deps-dev\): bump date-fns from 2.5.0 to 2.5.1 [\#1962](https://github.com/Human-Connection/Human-Connection/pull/1962) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) +- build\(deps\): bump faker from `10bfb9f` to `9fd8d7d` in /backend [\#1961](https://github.com/Human-Connection/Human-Connection/pull/1961) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) +- build\(deps-dev\): bump cypress-file-upload from 3.3.4 to 3.4.0 [\#1960](https://github.com/Human-Connection/Human-Connection/pull/1960) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) +- build\(deps\): bump date-fns from 2.5.0 to 2.5.1 in /backend [\#1959](https://github.com/Human-Connection/Human-Connection/pull/1959) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) +- build\(deps-dev\): bump faker from `10bfb9f` to `9fd8d7d` [\#1958](https://github.com/Human-Connection/Human-Connection/pull/1958) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) +- Refactor reports resolver spec [\#1957](https://github.com/Human-Connection/Human-Connection/pull/1957) ([aonomike](https://github.com/aonomike)) +- Update to version 0.1.5 [\#1951](https://github.com/Human-Connection/Human-Connection/pull/1951) ([mattwr18](https://github.com/mattwr18)) +- build\(deps\): bump tiptap-extensions from 1.28.3 to 1.28.4 in /webapp [\#1946](https://github.com/Human-Connection/Human-Connection/pull/1946) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) +- build\(deps\): bump metascraper-soundcloud from 5.7.6 to 5.7.7 in /backend [\#1943](https://github.com/Human-Connection/Human-Connection/pull/1943) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) +- fix: console warnings during frontend tests [\#1942](https://github.com/Human-Connection/Human-Connection/pull/1942) ([roschaefer](https://github.com/roschaefer)) - Fix vue errors for Contribution form [\#1941](https://github.com/Human-Connection/Human-Connection/pull/1941) ([vbelolapotkov](https://github.com/vbelolapotkov)) - Follow @alina-beck and @Tirokk PR suggestions [\#1940](https://github.com/Human-Connection/Human-Connection/pull/1940) ([mattwr18](https://github.com/mattwr18)) - Maintain filename for cropped images [\#1935](https://github.com/Human-Connection/Human-Connection/pull/1935) ([mattwr18](https://github.com/mattwr18)) +- Language will be saved in the database of the user during registration [\#1927](https://github.com/Human-Connection/Human-Connection/pull/1927) ([ogerly](https://github.com/ogerly)) - Improved comment truncation [\#1925](https://github.com/Human-Connection/Human-Connection/pull/1925) ([alina-beck](https://github.com/alina-beck)) - build\(deps\): bump styleguide from `808b3c5` to `d46fc15` [\#1923](https://github.com/Human-Connection/Human-Connection/pull/1923) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) - build\(deps\): bump date-fns from 2.4.1 to 2.5.0 in /webapp [\#1921](https://github.com/Human-Connection/Human-Connection/pull/1921) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) +- build\(deps-dev\): bump async-validator from 3.1.0 to 3.2.0 in /webapp [\#1920](https://github.com/Human-Connection/Human-Connection/pull/1920) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) - build\(deps-dev\): bump date-fns from 2.4.1 to 2.5.0 [\#1919](https://github.com/Human-Connection/Human-Connection/pull/1919) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) - build\(deps-dev\): bump neode from 0.3.3 to 0.3.6 [\#1918](https://github.com/Human-Connection/Human-Connection/pull/1918) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) - build\(deps-dev\): bump dotenv from 8.1.0 to 8.2.0 [\#1917](https://github.com/Human-Connection/Human-Connection/pull/1917) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) - build\(deps-dev\): bump nodemon from 1.19.3 to 1.19.4 in /backend [\#1916](https://github.com/Human-Connection/Human-Connection/pull/1916) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) - build\(deps\): bump date-fns from 2.4.1 to 2.5.0 in /backend [\#1915](https://github.com/Human-Connection/Human-Connection/pull/1915) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) +- build\(deps\): bump @sentry/node from 5.7.0 to 5.7.1 in /backend [\#1914](https://github.com/Human-Connection/Human-Connection/pull/1914) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) - build\(deps\): bump dotenv from 8.1.0 to 8.2.0 in /backend [\#1912](https://github.com/Human-Connection/Human-Connection/pull/1912) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) - fix: typo in German translation [\#1911](https://github.com/Human-Connection/Human-Connection/pull/1911) ([roschaefer](https://github.com/roschaefer)) - Add missing translations for Title placeholder [\#1910](https://github.com/Human-Connection/Human-Connection/pull/1910) ([mattwr18](https://github.com/mattwr18)) - Confirm privacy policy minimum age at registration [\#1907](https://github.com/Human-Connection/Human-Connection/pull/1907) ([ogerly](https://github.com/ogerly)) +- Add storybook stories for our university students [\#1906](https://github.com/Human-Connection/Human-Connection/pull/1906) ([roschaefer](https://github.com/roschaefer)) - 1873 -WITH CHECKBOX [\#1905](https://github.com/Human-Connection/Human-Connection/pull/1905) ([ogerly](https://github.com/ogerly)) - refactor: improve locale imports [\#1904](https://github.com/Human-Connection/Human-Connection/pull/1904) ([roschaefer](https://github.com/roschaefer)) - Highlight and expand linked comment [\#1903](https://github.com/Human-Connection/Human-Connection/pull/1903) ([alina-beck](https://github.com/alina-beck)) @@ -86,8 +116,8 @@ - build\(deps-dev\): bump eslint-plugin-jest from 22.17.0 to 22.19.0 in /backend [\#1888](https://github.com/Human-Connection/Human-Connection/pull/1888) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) - Fix translation error in login page [\#1885](https://github.com/Human-Connection/Human-Connection/pull/1885) ([janethavi](https://github.com/janethavi)) - 1773 refactor rewards spec [\#1884](https://github.com/Human-Connection/Human-Connection/pull/1884) ([aonomike](https://github.com/aonomike)) -- π° Refactor Database for Reporting with specific information [\#1878](https://github.com/Human-Connection/Human-Connection/pull/1878) ([Tirokk](https://github.com/Tirokk)) - Fix embeds settings page [\#1877](https://github.com/Human-Connection/Human-Connection/pull/1877) ([mattwr18](https://github.com/mattwr18)) +- π° Fix - maintaining sorting after navigation [\#1872](https://github.com/Human-Connection/Human-Connection/pull/1872) ([nimitbhargava](https://github.com/nimitbhargava)) - build\(deps\): bump @sentry/node from 5.6.2 to 5.7.0 in /backend [\#1871](https://github.com/Human-Connection/Human-Connection/pull/1871) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) - build\(deps-dev\): bump apollo-server-testing from 2.9.5 to 2.9.6 in /backend [\#1870](https://github.com/Human-Connection/Human-Connection/pull/1870) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) - build\(deps-dev\): bump @vue/cli-shared-utils from 3.11.0 to 3.12.0 in /webapp [\#1869](https://github.com/Human-Connection/Human-Connection/pull/1869) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) @@ -103,6 +133,7 @@ - Add Hall of Fame to README [\#1859](https://github.com/Human-Connection/Human-Connection/pull/1859) ([roschaefer](https://github.com/roschaefer)) - build\(deps-dev\): bump cypress-cucumber-preprocessor from 1.16.1 to 1.16.2 [\#1855](https://github.com/Human-Connection/Human-Connection/pull/1855) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) - build\(deps\): bump nodemailer from 6.3.0 to 6.3.1 in /backend [\#1854](https://github.com/Human-Connection/Human-Connection/pull/1854) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) +- Added createdAt date for follow and shout [\#1853](https://github.com/Human-Connection/Human-Connection/pull/1853) ([KapilJ22](https://github.com/KapilJ22)) - Save user setting to show embed code II [\#1852](https://github.com/Human-Connection/Human-Connection/pull/1852) ([ogerly](https://github.com/ogerly)) - Title character increased from 64 to 100 [\#1850](https://github.com/Human-Connection/Human-Connection/pull/1850) ([ogerly](https://github.com/ogerly)) - build\(deps-dev\): bump @babel/preset-env from 7.6.2 to 7.6.3 in /webapp [\#1849](https://github.com/Human-Connection/Human-Connection/pull/1849) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) @@ -110,6 +141,7 @@ - build\(deps-dev\): bump @babel/cli from 7.6.2 to 7.6.3 in /backend [\#1847](https://github.com/Human-Connection/Human-Connection/pull/1847) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) - build\(deps-dev\): bump @babel/preset-env from 7.6.2 to 7.6.3 in /backend [\#1846](https://github.com/Human-Connection/Human-Connection/pull/1846) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) - build\(deps-dev\): bump @babel/node from 7.6.2 to 7.6.3 in /backend [\#1843](https://github.com/Human-Connection/Human-Connection/pull/1843) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) +- Allow admins to pin a post [\#1840](https://github.com/Human-Connection/Human-Connection/pull/1840) ([mattwr18](https://github.com/mattwr18)) - build\(deps\): bump styleguide from `808b3c5` to `d46fc15` [\#1839](https://github.com/Human-Connection/Human-Connection/pull/1839) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) - build\(deps-dev\): bump @storybook/addon-actions from 5.2.1 to 5.2.3 in /webapp [\#1838](https://github.com/Human-Connection/Human-Connection/pull/1838) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) - build\(deps\): bump node from 12.11.0-alpine to 12.11.1-alpine in /backend [\#1837](https://github.com/Human-Connection/Human-Connection/pull/1837) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) @@ -177,7 +209,6 @@ - Bump metascraper-title from 5.7.5 to 5.7.6 in /backend [\#1759](https://github.com/Human-Connection/Human-Connection/pull/1759) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) - Bump nodemon from 1.19.2 to 1.19.3 in /backend [\#1758](https://github.com/Human-Connection/Human-Connection/pull/1758) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) - fix email middleware transport config [\#1757](https://github.com/Human-Connection/Human-Connection/pull/1757) ([vbelolapotkov](https://github.com/vbelolapotkov)) -- 1273 fix post page nav suggestions [\#1756](https://github.com/Human-Connection/Human-Connection/pull/1756) ([roschaefer](https://github.com/roschaefer)) - docs: moves storybook into webapp/README.md [\#1755](https://github.com/Human-Connection/Human-Connection/pull/1755) ([roschaefer](https://github.com/roschaefer)) - Bump date-fns from 2.2.1 to 2.4.0 in /webapp [\#1752](https://github.com/Human-Connection/Human-Connection/pull/1752) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) - fix: Github's security vulnerability warning [\#1751](https://github.com/Human-Connection/Human-Connection/pull/1751) ([roschaefer](https://github.com/roschaefer)) diff --git a/VERSION b/VERSION index b1e80bb24..c946ee616 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.3 +0.1.6 diff --git a/backend/package-lock.json b/backend/package-lock.json index ceec1cc8e..8bcf65e51 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -779,30 +779,6 @@ "regexpu-core": "^4.6.0" } }, - "@babel/polyfill": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/@babel/polyfill/-/polyfill-7.6.0.tgz", - "integrity": "sha512-q5BZJI0n/B10VaQQvln1IlDK3BTBJFbADx7tv+oXDPIDZuTo37H5Adb9jhlXm/fEN4Y7/64qD9mnrJJG7rmaTw==", - "dev": true, - "requires": { - "core-js": "^2.6.5", - "regenerator-runtime": "^0.13.2" - }, - "dependencies": { - "core-js": { - "version": "2.6.10", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.10.tgz", - "integrity": "sha512-I39t74+4t+zau64EN1fE5v2W31Adtc/REhzWN+gWRRXg6WH5qAsZm62DHpQ1+Yhe4047T55jvzz7MUqF/dBBlA==", - "dev": true - }, - "regenerator-runtime": { - "version": "0.13.3", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.3.tgz", - "integrity": "sha512-naKIZz2GQ8JWh///G7L3X6LaQUAMp2lvb1rvwwsURe/VXwD6VMfr+/1NuNw3ag8v2kY1aQ/go5SNn79O9JU7yw==", - "dev": true - } - } - }, "@babel/preset-env": { "version": "7.6.3", "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.6.3.tgz", @@ -2204,12 +2180,12 @@ "dev": true }, "assertion-error-formatter": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error-formatter/-/assertion-error-formatter-2.0.1.tgz", - "integrity": "sha512-cjC3jUCh9spkroKue5PDSKH5RFQ/KNuZJhk3GwHYmB/8qqETxLOmMdLH+ohi/VukNzxDlMvIe7zScvLoOdhb6Q==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/assertion-error-formatter/-/assertion-error-formatter-3.0.0.tgz", + "integrity": "sha512-6YyAVLrEze0kQ7CmJfUgrLHb+Y7XghmL2Ie7ijVa2Y9ynP3LV+VDiwFk62Dn0qtqbmY0BT0ss6p1xxpiF2PYbQ==", "dev": true, "requires": { - "diff": "^3.0.0", + "diff": "^4.0.1", "pad-right": "^0.2.2", "repeat-string": "^1.6.1" } @@ -2499,9 +2475,9 @@ "dev": true }, "bluebird": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.0.tgz", - "integrity": "sha512-aBQ1FxIa7kSWCcmKHlcHFlT2jt6J/l4FzC7KcPELkOJOsPOb/bccdhmIrKDfXhwFrmc7vDoDrrepFvGqjyXGJg==", + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.1.tgz", + "integrity": "sha512-DdmyoGCleJnkbp3nkbxTLJ18rjDsE4yCggEwKNXkeV123sPNfOCYeDoeuOY+F2FrSjO1YXcTU+dsy96KMy+gcg==", "dev": true }, "body-parser": { @@ -3823,100 +3799,97 @@ } }, "cucumber": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/cucumber/-/cucumber-5.1.0.tgz", - "integrity": "sha512-zrl2VYTBRgvxucwV2GKAvLqcfA1Naeax8plPvWgPEzl3SCJiuPPv3WxBHIRHtPYcEdbHDR6oqLpZP4bJ8UIdmA==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/cucumber/-/cucumber-6.0.2.tgz", + "integrity": "sha512-yEwPYGvgS2KG6ODdUXQwWcxjyr/l31dmpGJsZSkJIXNLNNmieKVefTpf8zLj6+0V2TCPwkmUZt4+OIXv97duEw==", "dev": true, "requires": { - "@babel/polyfill": "^7.2.3", - "assertion-error-formatter": "^2.0.1", + "assertion-error-formatter": "^3.0.0", "bluebird": "^3.4.1", "cli-table3": "^0.5.1", "colors": "^1.1.2", - "commander": "^2.9.0", - "cross-spawn": "^6.0.5", - "cucumber-expressions": "^6.0.0", - "cucumber-tag-expressions": "^1.1.1", + "commander": "^3.0.1", + "cucumber-expressions": "^8.0.1", + "cucumber-tag-expressions": "^2.0.2", "duration": "^0.2.1", - "escape-string-regexp": "^1.0.5", - "figures": "2.0.0", - "gherkin": "^5.0.0", + "escape-string-regexp": "^2.0.0", + "figures": "^3.0.0", + "gherkin": "5.0.0", "glob": "^7.1.3", - "indent-string": "^3.1.0", + "indent-string": "^4.0.0", "is-generator": "^1.0.2", - "is-stream": "^1.1.0", + "is-stream": "^2.0.0", "knuth-shuffle-seeded": "^1.0.6", - "lodash": "^4.17.10", + "lodash": "^4.17.14", "mz": "^2.4.0", "progress": "^2.0.0", "resolve": "^1.3.3", - "serialize-error": "^3.0.0", + "serialize-error": "^4.1.0", "stack-chain": "^2.0.0", "stacktrace-js": "^2.0.0", - "string-argv": "0.1.1", + "string-argv": "^0.3.0", "title-case": "^2.1.1", "util-arity": "^1.0.2", "verror": "^1.9.0" }, "dependencies": { - "cross-spawn": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", - "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", - "dev": true, - "requires": { - "nice-try": "^1.0.4", - "path-key": "^2.0.1", - "semver": "^5.5.0", - "shebang-command": "^1.2.0", - "which": "^1.2.9" - } - }, - "path-key": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", - "integrity": "sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=", + "commander": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/commander/-/commander-3.0.2.tgz", + "integrity": "sha512-Gar0ASD4BDyKC4hl4DwHqDrmvjoxWKZigVnAbn5H1owvm4CxCPdb0HQDehwNYMJpla5+M2tPmPARzhtYuwpHow==", "dev": true }, - "shebang-command": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", - "integrity": "sha1-RKrGW2lbAzmJaMOfNj/uXer98eo=", - "dev": true, - "requires": { - "shebang-regex": "^1.0.0" - } - }, - "shebang-regex": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", - "integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=", + "escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", "dev": true }, - "which": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", - "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "figures": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-3.1.0.tgz", + "integrity": "sha512-ravh8VRXqHuMvZt/d8GblBeqDMkdJMBdv/2KntFH+ra5MXkO7nxNKpzQ3n6QD/2da1kH0aWmNISdvhM7gl2gVg==", "dev": true, "requires": { - "isexe": "^2.0.0" + "escape-string-regexp": "^1.0.5" + }, + "dependencies": { + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "dev": true + } } + }, + "indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true + }, + "is-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.0.tgz", + "integrity": "sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw==", + "dev": true } } }, "cucumber-expressions": { - "version": "6.6.2", - "resolved": "https://registry.npmjs.org/cucumber-expressions/-/cucumber-expressions-6.6.2.tgz", - "integrity": "sha512-WcFSVBiWNLJbIcAAC3t/ACU46vaOKfe1UIF5H3qveoq+Y4XQm9j3YwHurQNufRKBBg8nCnpU7Ttsx7egjS3hwA==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cucumber-expressions/-/cucumber-expressions-8.0.1.tgz", + "integrity": "sha512-g+A+tUEafNofe6ErwvOkqaMvDj9NuOr0GouGotpw4r5yK2d4144o9/6sQpXBr2YXbRy5ItmER/2bzAyDAzhPyQ==", "dev": true, "requires": { - "becke-ch--regex--s0-0-v1--base--pl--lib": "^1.2.0" + "becke-ch--regex--s0-0-v1--base--pl--lib": "^1.4.0", + "xregexp": "^4.2.4" } }, "cucumber-tag-expressions": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cucumber-tag-expressions/-/cucumber-tag-expressions-1.1.1.tgz", - "integrity": "sha1-f1x7cACbwrZmWRv+ZIVFeL7e6Fo=", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/cucumber-tag-expressions/-/cucumber-tag-expressions-2.0.2.tgz", + "integrity": "sha512-DohmT4X641KX/sb96bdb7J2kXNcQBPrYmf3Oc5kiHCLfzFMWx/o2kB4JvjvQPZnYuA9lRt6pqtArM5gvUn4uzw==", "dev": true }, "d": { @@ -3967,9 +3940,9 @@ } }, "date-fns": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.4.1.tgz", - "integrity": "sha512-2RhmH/sjDSCYW2F3ZQxOUx/I7PvzXpi89aQL2d3OAxSTwLx6NilATeUbe0menFE3Lu5lFkOFci36ivimwYHHxw==" + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.5.1.tgz", + "integrity": "sha512-ZBrQmuaqH9YqIejbgu8f09ki7wdD2JxWsRTZ/+HnnLNmkI56ty0evnWzKY+ihLT0xX5VdUX0vDNZCxJJGKX2+Q==" }, "debug": { "version": "4.1.1", @@ -4114,9 +4087,9 @@ } }, "diff": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz", - "integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.1.tgz", + "integrity": "sha512-s2+XdvhPCOF01LRQBC8hf4vhbVmI2CGS5aZnxLJlT5FtdhPCDFq80q++zK2KlrVorVDdL5BOGZ/VfLrVtYNF+Q==", "dev": true }, "diff-sequences": { @@ -4194,9 +4167,9 @@ } }, "dotenv": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.1.0.tgz", - "integrity": "sha512-GUE3gqcDCaMltj2++g6bRQ5rBJWtkWTmqmD0fo1RnnMuUqHNCt2oTPeDnS9n6fKYvlhn7AeBkb38lymBtWBQdA==" + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.2.0.tgz", + "integrity": "sha512-8sJ78ElpbDJBHNeBzUbUVLsqKdccaa/BXF1uPTw3GrvQTBgrQrtObr2mUrE38vzYd8cEv+m/JBfDLioYcfXoaw==" }, "duplexer3": { "version": "0.1.4", @@ -4514,9 +4487,9 @@ } }, "eslint-config-prettier": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-6.3.0.tgz", - "integrity": "sha512-EWaGjlDAZRzVFveh2Jsglcere2KK5CJBhkNSa1xs3KfMUGdRiT7lG089eqPdvlzWHpAqaekubOsOMu8W8Yk71A==", + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-6.4.0.tgz", + "integrity": "sha512-YrKucoFdc7SEko5Sxe4r6ixqXPDP1tunGw91POeZTTRKItf/AMFYt/YLEQtZMkR2LVpAVhcAcZgcWpm1oGPW7w==", "dev": true, "requires": { "get-stdin": "^6.0.0" @@ -4799,9 +4772,9 @@ } }, "eslint-plugin-jest": { - "version": "22.17.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-22.17.0.tgz", - "integrity": "sha512-WT4DP4RoGBhIQjv+5D0FM20fAdAUstfYAf/mkufLNTojsfgzc5/IYW22cIg/Q4QBavAZsROQlqppiWDpFZDS8Q==", + "version": "22.20.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-22.20.0.tgz", + "integrity": "sha512-UwHGXaYprxwd84Wer8H7jZS+5C3LeEaU8VD7NqORY6NmPJrs+9Ugbq3wyjqO3vWtSsDaLar2sqEB8COmOZA4zw==", "dev": true, "requires": { "@typescript-eslint/experimental-utils": "^1.13.0" @@ -5553,9 +5526,9 @@ } }, "gherkin": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/gherkin/-/gherkin-5.1.0.tgz", - "integrity": "sha1-aEu7A63STq9731RPWAM+so+zxtU=", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/gherkin/-/gherkin-5.0.0.tgz", + "integrity": "sha1-lt70EZjsOQgli1Ea909lWidk0qE=", "dev": true }, "glob": { @@ -9034,13 +9007,13 @@ } }, "metascraper-soundcloud": { - "version": "5.7.6", - "resolved": "https://registry.npmjs.org/metascraper-soundcloud/-/metascraper-soundcloud-5.7.6.tgz", - "integrity": "sha512-fBxX5mYPFf8rWhhEX2XZD5QrmvtUI5IIPzryGuwEWsbPuMGuUkvFA9JjHJiC46uYXoi6UuKLXwSmYHcAACG3Jg==", + "version": "5.7.7", + "resolved": "https://registry.npmjs.org/metascraper-soundcloud/-/metascraper-soundcloud-5.7.7.tgz", + "integrity": "sha512-TDJxUwFJCxU4bTrrx3GWiGeZdNhvRhlI61JiprLkYBriM65uzCfaJ5FjS5uzZy1CfMYhvQgxLZ7XRq1bgbPpTg==", "requires": { "@metascraper/helpers": "^5.7.6", "memoize-one": "~5.1.1", - "tldts": "~5.5.0" + "tldts": "~5.6.1" }, "dependencies": { "@metascraper/helpers": { @@ -9831,18 +9804,18 @@ } }, "nodemon": { - "version": "1.19.3", - "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-1.19.3.tgz", - "integrity": "sha512-TBNKRmJykEbxpTniZBusqRrUTHIEqa2fpecbTQDQj1Gxjth7kKAPP296ztR0o5gPUWsiYbuEbt73/+XMYab1+w==", + "version": "1.19.4", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-1.19.4.tgz", + "integrity": "sha512-VGPaqQBNk193lrJFotBU8nvWZPqEZY2eIzymy2jjY0fJ9qIsxA0sxQ8ATPl0gZC645gijYEc1jtZvpS8QWzJGQ==", "dev": true, "requires": { - "chokidar": "^2.1.5", - "debug": "^3.1.0", + "chokidar": "^2.1.8", + "debug": "^3.2.6", "ignore-by-default": "^1.0.1", "minimatch": "^3.0.4", - "pstree.remy": "^1.1.6", - "semver": "^5.5.0", - "supports-color": "^5.2.0", + "pstree.remy": "^1.1.7", + "semver": "^5.7.1", + "supports-color": "^5.5.0", "touch": "^3.1.0", "undefsafe": "^2.0.2", "update-notifier": "^2.5.0" @@ -11446,10 +11419,13 @@ } }, "serialize-error": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-3.0.0.tgz", - "integrity": "sha512-+y3nkkG/go1Vdw+2f/+XUXM1DXX1XcxTl99FfiD/OEPUNw4uo0i6FKABfTAN5ZcgGtjTRZcEbxcE/jtXbEY19A==", - "dev": true + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-4.1.0.tgz", + "integrity": "sha512-5j9GgyGsP9vV9Uj1S0lDCvlsd+gc2LEPVK7HHHte7IyPwOD4lVQFeaX143gx3U5AnoCi+wbcb3mvaxVysjpxEw==", + "dev": true, + "requires": { + "type-fest": "^0.3.0" + } }, "serve-static": { "version": "1.14.1", @@ -11894,9 +11870,9 @@ "integrity": "sha1-gIudDlb8Jz2Am6VzOOkpkZoanxo=" }, "string-argv": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.1.1.tgz", - "integrity": "sha512-El1Va5ehZ0XTj3Ekw4WFidXvTmt9SrC0+eigdojgtJMVtPkF0qbBe9fyNSl9eQf+kUHnTSQxdQYzuHfZy8V+DQ==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.1.tgz", + "integrity": "sha512-a1uQGz7IyVy9YwhqjZIZu1c8JO8dNIe20xBmSS6qu9kv++k3JGzCVmprbNN5Kn+BgzD5E7YYwg1CcjuJMRNsvg==", "dev": true }, "string-length": { @@ -12303,11 +12279,11 @@ "integrity": "sha512-7MUlYyGJ6rSitEZ3r1Q1QNV8uSIzapS8SmmhSusBuIc7uIxPPwsKllEP0GRp1NS6Ik6F+fRZvnjDWm3ecv2hDw==" }, "tldts": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-5.5.0.tgz", - "integrity": "sha512-CZ/d7Y4k8onxwerMWz/mTCeKJtX3VAMiL+ajXVFnxsKhH4BV+QavjnZ1Mb9OeCHo3jX0S3Dw6ERNRXqOMVsDvw==", + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-5.6.1.tgz", + "integrity": "sha512-I+imSP592J9GUYApIoiDdJk3KlroHY4zmDmpAp+TlIDZZAPxx192yOUViMB2QmlcRtZUz5XLEM3cS2F0V7P1Fw==", "requires": { - "tldts-core": "^5.5.0" + "tldts-core": "^5.6.1" } }, "tldts-core": { diff --git a/backend/package.json b/backend/package.json index b30bbfc0e..98ba80a61 100644 --- a/backend/package.json +++ b/backend/package.json @@ -42,7 +42,7 @@ }, "dependencies": { "@hapi/joi": "^16.1.7", - "@sentry/node": "^5.7.0", + "@sentry/node": "^5.7.1", "apollo-cache-inmemory": "~1.6.3", "apollo-client": "~2.6.4", "apollo-link-context": "~1.0.19", @@ -121,7 +121,7 @@ "eslint-config-prettier": "~6.4.0", "eslint-config-standard": "~14.1.0", "eslint-plugin-import": "~2.18.2", - "eslint-plugin-jest": "~22.19.0", + "eslint-plugin-jest": "~22.20.0", "eslint-plugin-node": "~10.0.0", "eslint-plugin-prettier": "~3.1.1", "eslint-plugin-promise": "~4.2.1", diff --git a/backend/src/middleware/permissionsMiddleware.js b/backend/src/middleware/permissionsMiddleware.js index 31efb9316..a0116a439 100644 --- a/backend/src/middleware/permissionsMiddleware.js +++ b/backend/src/middleware/permissionsMiddleware.js @@ -134,6 +134,7 @@ const permissions = shield( PostsEmotionsByCurrentUser: isAuthenticated, blockedUsers: isAuthenticated, notifications: isAuthenticated, + profilePagePosts: or(onlyEnabledContent, isModerator), }, Mutation: { '*': deny, @@ -174,6 +175,8 @@ const permissions = shield( markAsRead: isAuthenticated, AddEmailAddress: isAuthenticated, VerifyEmailAddress: isAuthenticated, + pinPost: isAdmin, + unpinPost: isAdmin, }, User: { email: or(isMyOwn, isAdmin), diff --git a/backend/src/models/User.js b/backend/src/models/User.js index ec096d10e..b24148f00 100644 --- a/backend/src/models/User.js +++ b/backend/src/models/User.js @@ -114,6 +114,15 @@ module.exports = { target: 'Location', direction: 'out', }, + pinned: { + type: 'relationship', + relationship: 'PINNED', + target: 'Post', + direction: 'out', + properties: { + createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() }, + }, + }, allowEmbedIframes: { type: 'boolean', default: false, diff --git a/backend/src/schema/resolvers/helpers/Resolver.js b/backend/src/schema/resolvers/helpers/Resolver.js index 9a6f77513..03c0d4176 100644 --- a/backend/src/schema/resolvers/helpers/Resolver.js +++ b/backend/src/schema/resolvers/helpers/Resolver.js @@ -86,6 +86,7 @@ export default function Resolver(type, options = {}) { } return resolvers } + const result = { ...undefinedToNullResolver(undefinedToNull), ...booleanResolver(boolean), diff --git a/backend/src/schema/resolvers/posts.js b/backend/src/schema/resolvers/posts.js index e65fa9b76..3b3065277 100644 --- a/backend/src/schema/resolvers/posts.js +++ b/backend/src/schema/resolvers/posts.js @@ -2,10 +2,9 @@ import uuid from 'uuid/v4' import { neo4jgraphql } from 'neo4j-graphql-js' import fileUpload from './fileUpload' import { getBlockedUsers, getBlockedByUsers } from './users.js' -import { mergeWith, isArray } from 'lodash' +import { mergeWith, isArray, isEmpty } from 'lodash' import { UserInputError } from 'apollo-server' import Resolver from './helpers/Resolver' - const filterForBlockedUsers = async (params, context) => { if (!context.user) return params const [blockedUsers, blockedByUsers] = await Promise.all([ @@ -29,16 +28,31 @@ const filterForBlockedUsers = async (params, context) => { return params } +const maintainPinnedPosts = params => { + const pinnedPostFilter = { pinnedBy_in: { role_in: ['admin'] } } + if (isEmpty(params.filter)) { + params.filter = { OR: [pinnedPostFilter, {}] } + } else { + params.filter = { OR: [pinnedPostFilter, { ...params.filter }] } + } + return params +} + export default { Query: { Post: async (object, params, context, resolveInfo) => { params = await filterForBlockedUsers(params, context) + params = await maintainPinnedPosts(params) return neo4jgraphql(object, params, context, resolveInfo, false) }, findPosts: async (object, params, context, resolveInfo) => { params = await filterForBlockedUsers(params, context) return neo4jgraphql(object, params, context, resolveInfo, false) }, + profilePagePosts: async (object, params, context, resolveInfo) => { + params = await filterForBlockedUsers(params, context) + return neo4jgraphql(object, params, context, resolveInfo, false) + }, PostsEmotionsCountByEmotion: async (object, params, context, resolveInfo) => { const session = context.driver.session() const { postId, data } = params @@ -115,10 +129,10 @@ export default { delete params.categoryIds params = await fileUpload(params, { file: 'imageUpload', url: 'image' }) const session = context.driver.session() - let updatePostCypher = `MATCH (post:Post {id: $params.id}) SET post += $params SET post.updatedAt = toString(datetime()) + WITH post ` if (categoryIds && categoryIds.length) { @@ -131,10 +145,10 @@ export default { await session.run(cypherDeletePreviousRelations, { params }) updatePostCypher += ` - WITH post UNWIND $categoryIds AS categoryId MATCH (category:Category {id: categoryId}) MERGE (post)-[:CATEGORIZED]->(category) + WITH post ` } @@ -211,10 +225,78 @@ export default { }) return emoted }, + pinPost: async (_parent, params, context, _resolveInfo) => { + let pinnedPostWithNestedAttributes + const { driver, user } = context + const session = driver.session() + const { id: userId } = user + let writeTxResultPromise = session.writeTransaction(async transaction => { + const deletePreviousRelationsResponse = await transaction.run( + ` + MATCH (:User)-[previousRelations:PINNED]->(post:Post) + REMOVE post.pinned + DELETE previousRelations + RETURN post + `, + ) + return deletePreviousRelationsResponse.records.map(record => record.get('post').properties) + }) + await writeTxResultPromise + + writeTxResultPromise = session.writeTransaction(async transaction => { + const pinPostTransactionResponse = await transaction.run( + ` + MATCH (user:User {id: $userId}) WHERE user.role = 'admin' + MATCH (post:Post {id: $params.id}) + MERGE (user)-[pinned:PINNED {createdAt: toString(datetime())}]->(post) + SET post.pinned = true + RETURN post, pinned.createdAt as pinnedAt + `, + { userId, params }, + ) + return pinPostTransactionResponse.records.map(record => ({ + pinnedPost: record.get('post').properties, + pinnedAt: record.get('pinnedAt'), + })) + }) + try { + const [transactionResult] = await writeTxResultPromise + const { pinnedPost, pinnedAt } = transactionResult + pinnedPostWithNestedAttributes = { + ...pinnedPost, + pinnedAt, + } + } finally { + session.close() + } + return pinnedPostWithNestedAttributes + }, + unpinPost: async (_parent, params, context, _resolveInfo) => { + let unpinnedPost + const session = context.driver.session() + const writeTxResultPromise = session.writeTransaction(async transaction => { + const unpinPostTransactionResponse = await transaction.run( + ` + MATCH (:User)-[previousRelations:PINNED]->(post:Post {id: $params.id}) + REMOVE post.pinned + DELETE previousRelations + RETURN post + `, + { params }, + ) + return unpinPostTransactionResponse.records.map(record => record.get('post').properties) + }) + try { + ;[unpinnedPost] = await writeTxResultPromise + } finally { + session.close() + } + return unpinnedPost + }, }, Post: { ...Resolver('Post', { - undefinedToNull: ['activityId', 'objectId', 'image', 'language'], + undefinedToNull: ['activityId', 'objectId', 'image', 'language', 'pinnedAt', 'pinned'], hasMany: { tags: '-[:TAGGED]->(related:Tag)', categories: '-[:CATEGORIZED]->(related:Category)', @@ -225,6 +307,7 @@ export default { hasOne: { author: '<-[:WROTE]-(related:User)', disabledBy: '<-[:DISABLED]-(related:User)', + pinnedBy: '<-[:PINNED]-(related:User)', }, count: { commentsCount: diff --git a/backend/src/schema/resolvers/posts.spec.js b/backend/src/schema/resolvers/posts.spec.js index 0e7272e8e..9106e4eb9 100644 --- a/backend/src/schema/resolvers/posts.spec.js +++ b/backend/src/schema/resolvers/posts.spec.js @@ -39,7 +39,8 @@ const createPostMutation = gql` } ` -beforeAll(() => { +beforeAll(async () => { + await factory.cleanDatabase() const { server } = createServer({ context: () => { return { @@ -269,7 +270,10 @@ describe('CreatePost', () => { }) it('creates a post', async () => { - const expected = { data: { CreatePost: { title: 'I am a title', content: 'Some content' } } } + const expected = { + data: { CreatePost: { title: 'I am a title', content: 'Some content' } }, + errors: undefined, + } await expect(mutate({ mutation: createPostMutation, variables })).resolves.toMatchObject( expected, ) @@ -285,6 +289,7 @@ describe('CreatePost', () => { }, }, }, + errors: undefined, } await expect(mutate({ mutation: createPostMutation, variables })).resolves.toMatchObject( expected, @@ -366,7 +371,12 @@ describe('UpdatePost', () => { mutation($id: ID!, $title: String!, $content: String!, $categoryIds: [ID]) { UpdatePost(id: $id, title: $title, content: $content, categoryIds: $categoryIds) { id + title content + author { + name + slug + } categories { id } @@ -386,7 +396,6 @@ describe('UpdatePost', () => { }) variables = { - ...variables, id: 'p9876', title: 'New title', content: 'New content', @@ -395,8 +404,11 @@ describe('UpdatePost', () => { describe('unauthenticated', () => { it('throws authorization error', async () => { - const { errors } = await mutate({ mutation: updatePostMutation, variables }) - expect(errors[0]).toHaveProperty('message', 'Not Authorised!') + authenticatedUser = null + expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject({ + errors: [{ message: 'Not Authorised!' }], + data: { UpdatePost: null }, + }) }) }) @@ -550,6 +562,399 @@ describe('UpdatePost', () => { }) }) }) + + describe('pin posts', () => { + const pinPostMutation = gql` + mutation($id: ID!) { + pinPost(id: $id) { + id + title + content + author { + name + slug + } + pinnedBy { + id + name + role + } + createdAt + updatedAt + pinnedAt + pinned + } + } + ` + beforeEach(async () => { + variables = { ...variables } + }) + + describe('unauthenticated', () => { + it('throws authorization error', async () => { + authenticatedUser = null + await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject({ + errors: [{ message: 'Not Authorised!' }], + data: { pinPost: null }, + }) + }) + }) + + describe('ordinary users', () => { + it('throws authorization error', async () => { + await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject({ + errors: [{ message: 'Not Authorised!' }], + data: { pinPost: null }, + }) + }) + }) + + describe('moderators', () => { + let moderator + beforeEach(async () => { + moderator = await user.update({ role: 'moderator', updatedAt: new Date().toISOString() }) + authenticatedUser = await moderator.toJson() + }) + + it('throws authorization error', async () => { + await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject({ + errors: [{ message: 'Not Authorised!' }], + data: { pinPost: null }, + }) + }) + }) + + describe('admins', () => { + let admin + beforeEach(async () => { + admin = await user.update({ + role: 'admin', + name: 'Admin', + updatedAt: new Date().toISOString(), + }) + authenticatedUser = await admin.toJson() + }) + + describe('are allowed to pin posts', () => { + beforeEach(async () => { + await factory.create('Post', { + id: 'created-and-pinned-by-same-admin', + author: admin, + }) + variables = { ...variables, id: 'created-and-pinned-by-same-admin' } + }) + + it('responds with the updated Post', async () => { + const expected = { + data: { + pinPost: { + id: 'created-and-pinned-by-same-admin', + author: { + name: 'Admin', + }, + pinnedBy: { + id: 'current-user', + name: 'Admin', + role: 'admin', + }, + }, + }, + errors: undefined, + } + + await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject( + expected, + ) + }) + + it('sets createdAt date for PINNED', async () => { + const expected = { + data: { + pinPost: { + id: 'created-and-pinned-by-same-admin', + pinnedAt: expect.any(String), + }, + }, + errors: undefined, + } + await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject( + expected, + ) + }) + + it('sets redundant `pinned` property for performant ordering', async () => { + variables = { ...variables, id: 'created-and-pinned-by-same-admin' } + const expected = { + data: { pinPost: { pinned: true } }, + errors: undefined, + } + await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject( + expected, + ) + }) + }) + + describe('post created by another admin', () => { + let otherAdmin + beforeEach(async () => { + otherAdmin = await factory.create('User', { + role: 'admin', + name: 'otherAdmin', + }) + authenticatedUser = await otherAdmin.toJson() + await factory.create('Post', { + id: 'created-by-one-admin-pinned-by-different-one', + author: otherAdmin, + }) + }) + + it('responds with the updated Post', async () => { + authenticatedUser = await admin.toJson() + variables = { ...variables, id: 'created-by-one-admin-pinned-by-different-one' } + const expected = { + data: { + pinPost: { + id: 'created-by-one-admin-pinned-by-different-one', + author: { + name: 'otherAdmin', + }, + pinnedBy: { + id: 'current-user', + name: 'Admin', + role: 'admin', + }, + }, + }, + errors: undefined, + } + + await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject( + expected, + ) + }) + }) + + describe('post created by another user', () => { + it('responds with the updated Post', async () => { + const expected = { + data: { + pinPost: { + id: 'p9876', + author: { + slug: 'the-author', + }, + pinnedBy: { + id: 'current-user', + name: 'Admin', + role: 'admin', + }, + }, + }, + errors: undefined, + } + + await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject( + expected, + ) + }) + }) + + describe('pinned post already exists', () => { + let pinnedPost + beforeEach(async () => { + await factory.create('Post', { + id: 'only-pinned-post', + author: admin, + }) + await mutate({ mutation: pinPostMutation, variables }) + }) + + it('removes previous `pinned` attribute', async () => { + const cypher = 'MATCH (post:Post) WHERE post.pinned IS NOT NULL RETURN post' + pinnedPost = await neode.cypher(cypher) + expect(pinnedPost.records).toHaveLength(1) + variables = { ...variables, id: 'only-pinned-post' } + await mutate({ mutation: pinPostMutation, variables }) + pinnedPost = await neode.cypher(cypher) + expect(pinnedPost.records).toHaveLength(1) + }) + + it('removes previous PINNED relationship', async () => { + variables = { ...variables, id: 'only-pinned-post' } + await mutate({ mutation: pinPostMutation, variables }) + pinnedPost = await neode.cypher( + `MATCH (:User)-[pinned:PINNED]->(post:Post) RETURN post, pinned`, + ) + expect(pinnedPost.records).toHaveLength(1) + }) + }) + + describe('PostOrdering', () => { + let pinnedPost, admin + beforeEach(async () => { + ;[pinnedPost] = await Promise.all([ + neode.create('Post', { + id: 'im-a-pinned-post', + pinned: true, + }), + neode.create('Post', { + id: 'i-was-created-after-pinned-post', + createdAt: '2019-10-22T17:26:29.070Z', // this should always be 3rd + }), + ]) + admin = await user.update({ + role: 'admin', + name: 'Admin', + updatedAt: new Date().toISOString(), + }) + await admin.relateTo(pinnedPost, 'pinned') + }) + + it('pinned post appear first even when created before other posts', async () => { + const postOrderingQuery = gql` + query($orderBy: [_PostOrdering]) { + Post(orderBy: $orderBy) { + id + pinnedAt + } + } + ` + const expected = { + data: { + Post: [ + { + id: 'im-a-pinned-post', + pinnedAt: expect.any(String), + }, + { + id: 'p9876', + pinnedAt: null, + }, + { + id: 'i-was-created-after-pinned-post', + pinnedAt: null, + }, + ], + }, + } + variables = { orderBy: ['pinned_desc', 'createdAt_desc'] } + await expect(query({ query: postOrderingQuery, variables })).resolves.toMatchObject( + expected, + ) + }) + }) + }) + }) + + describe('unpin posts', () => { + const unpinPostMutation = gql` + mutation($id: ID!) { + unpinPost(id: $id) { + id + title + content + author { + name + slug + } + pinnedBy { + id + name + role + } + createdAt + updatedAt + pinned + pinnedAt + } + } + ` + beforeEach(async () => { + variables = { ...variables } + }) + + describe('unauthenticated', () => { + it('throws authorization error', async () => { + authenticatedUser = null + await expect(mutate({ mutation: unpinPostMutation, variables })).resolves.toMatchObject({ + errors: [{ message: 'Not Authorised!' }], + data: { unpinPost: null }, + }) + }) + }) + + describe('users cannot unpin posts', () => { + it('throws authorization error', async () => { + await expect(mutate({ mutation: unpinPostMutation, variables })).resolves.toMatchObject({ + errors: [{ message: 'Not Authorised!' }], + data: { unpinPost: null }, + }) + }) + }) + + describe('moderators cannot unpin posts', () => { + let moderator + beforeEach(async () => { + moderator = await user.update({ role: 'moderator', updatedAt: new Date().toISOString() }) + authenticatedUser = await moderator.toJson() + }) + + it('throws authorization error', async () => { + await expect(mutate({ mutation: unpinPostMutation, variables })).resolves.toMatchObject({ + errors: [{ message: 'Not Authorised!' }], + data: { unpinPost: null }, + }) + }) + }) + + describe('admin can unpin posts', () => { + let admin, pinnedPost + beforeEach(async () => { + pinnedPost = await factory.create('Post', { id: 'post-to-be-unpinned' }) + admin = await user.update({ + role: 'admin', + name: 'Admin', + updatedAt: new Date().toISOString(), + }) + authenticatedUser = await admin.toJson() + await admin.relateTo(pinnedPost, 'pinned', { createdAt: new Date().toISOString() }) + variables = { ...variables, id: 'post-to-be-unpinned' } + }) + + it('responds with the unpinned Post', async () => { + authenticatedUser = await admin.toJson() + const expected = { + data: { + unpinPost: { + id: 'post-to-be-unpinned', + pinnedBy: null, + pinnedAt: null, + }, + }, + errors: undefined, + } + + await expect(mutate({ mutation: unpinPostMutation, variables })).resolves.toMatchObject( + expected, + ) + }) + + it('unsets `pinned` property', async () => { + const expected = { + data: { + unpinPost: { + id: 'post-to-be-unpinned', + pinned: null, + }, + }, + errors: undefined, + } + await expect(mutate({ mutation: unpinPostMutation, variables })).resolves.toMatchObject( + expected, + ) + }) + }) + }) }) describe('DeletePost', () => { diff --git a/backend/src/schema/resolvers/reports.spec.js b/backend/src/schema/resolvers/reports.spec.js index 4022be1b1..21b1b4a7b 100644 --- a/backend/src/schema/resolvers/reports.spec.js +++ b/backend/src/schema/resolvers/reports.spec.js @@ -1,315 +1,16 @@ -import { GraphQLClient } from 'graphql-request' -import Factory from '../../seed/factories' -import { host, login, gql } from '../../jest/helpers' -import { getDriver, neode } from '../../bootstrap/neo4j' import { createTestClient } from 'apollo-server-testing' import createServer from '../.././server' +import Factory from '../../seed/factories' +import { gql } from '../../jest/helpers' +import { getDriver, neode as getNeode } from '../../bootstrap/neo4j' const factory = Factory() -const instance = neode() +const instance = getNeode() const driver = getDriver() -describe('report mutation', () => { - let reportMutation - let headers - let client - let variables - let createPostVariables - let user +describe('report resources', () => { + let authenticatedUser, currentUser, mutate, query, moderator, abusiveUser const categoryIds = ['cat9'] - - const action = () => { - reportMutation = gql` - mutation($resourceId: ID!, $reasonCategory: ReasonCategory!, $reasonDescription: String!) { - report( - resourceId: $resourceId - reasonCategory: $reasonCategory - reasonDescription: $reasonDescription - ) { - createdAt - reasonCategory - reasonDescription - type - submitter { - email - } - user { - name - } - post { - title - } - comment { - content - } - } - } - ` - client = new GraphQLClient(host, { - headers, - }) - return client.request(reportMutation, variables) - } - - beforeEach(async () => { - variables = { - resourceId: 'whatever', - reasonCategory: 'other', - reasonDescription: 'Violates code of conduct !!!', - } - headers = {} - user = await factory.create('User', { - id: 'u1', - role: 'user', - email: 'test@example.org', - password: '1234', - }) - await factory.create('User', { - id: 'u2', - role: 'user', - name: 'abusive-user', - email: 'abusive-user@example.org', - }) - await instance.create('Category', { - id: 'cat9', - name: 'Democracy & Politics', - icon: 'university', - }) - }) - - afterEach(async () => { - await factory.cleanDatabase() - }) - - describe('unauthenticated', () => { - it('throws authorization error', async () => { - await expect(action()).rejects.toThrow('Not Authorised') - }) - }) - - describe('authenticated', () => { - beforeEach(async () => { - headers = await login({ - email: 'test@example.org', - password: '1234', - }) - }) - - describe('invalid resource id', () => { - it('returns null', async () => { - await expect(action()).resolves.toEqual({ - report: null, - }) - }) - }) - - describe('valid resource id', () => { - describe('reported resource is a user', () => { - beforeEach(async () => { - variables = { - ...variables, - resourceId: 'u2', - } - }) - - it('returns type "User"', async () => { - await expect(action()).resolves.toMatchObject({ - report: { - type: 'User', - }, - }) - }) - - it('returns resource in user attribute', async () => { - await expect(action()).resolves.toMatchObject({ - report: { - user: { - name: 'abusive-user', - }, - }, - }) - }) - - it('returns the submitter', async () => { - await expect(action()).resolves.toMatchObject({ - report: { - submitter: { - email: 'test@example.org', - }, - }, - }) - }) - - it('returns a date', async () => { - await expect(action()).resolves.toMatchObject({ - report: { - createdAt: expect.any(String), - }, - }) - }) - - it('returns the reason category', async () => { - variables = { - ...variables, - reasonCategory: 'criminal_behavior_violation_german_law', - } - await expect(action()).resolves.toMatchObject({ - report: { - reasonCategory: 'criminal_behavior_violation_german_law', - }, - }) - }) - - it('gives an error if the reason category is not in enum "ReasonCategory"', async () => { - variables = { - ...variables, - reasonCategory: 'my_category', - } - await expect(action()).rejects.toThrow( - 'got invalid value "my_category"; Expected type ReasonCategory', - ) - }) - - it('returns the reason description', async () => { - variables = { - ...variables, - reasonDescription: 'My reason!', - } - await expect(action()).resolves.toMatchObject({ - report: { - reasonDescription: 'My reason!', - }, - }) - }) - - it('sanitize the reason description', async () => { - variables = { - ...variables, - reasonDescription: 'My reason !', - } - await expect(action()).resolves.toMatchObject({ - report: { - reasonDescription: 'My reason !', - }, - }) - }) - }) - - describe('reported resource is a post', () => { - beforeEach(async () => { - await factory.create('Post', { - author: user, - id: 'p23', - title: 'Matt and Robert having a pair-programming', - categoryIds, - }) - variables = { - ...variables, - resourceId: 'p23', - } - }) - - it('returns type "Post"', async () => { - await expect(action()).resolves.toMatchObject({ - report: { - type: 'Post', - }, - }) - }) - - it('returns resource in post attribute', async () => { - await expect(action()).resolves.toMatchObject({ - report: { - post: { - title: 'Matt and Robert having a pair-programming', - }, - }, - }) - }) - - it('returns null in user attribute', async () => { - await expect(action()).resolves.toMatchObject({ - report: { - user: null, - }, - }) - }) - }) - - /* An der Stelle wΓΌrde ich den p23 noch mal prΓΌfen, diesmal muss aber eine error meldung kommen. - At this point I would check the p23 again, but this time there must be an error message. */ - - describe('reported resource is a comment', () => { - beforeEach(async () => { - createPostVariables = { - id: 'p1', - title: 'post to comment on', - content: 'please comment on me', - categoryIds, - } - await factory.create('Post', { ...createPostVariables, author: user }) - await factory.create('Comment', { - author: user, - postId: 'p1', - id: 'c34', - content: 'Robert getting tired.', - }) - variables = { - ...variables, - resourceId: 'c34', - } - }) - - it('returns type "Comment"', async () => { - await expect(action()).resolves.toMatchObject({ - report: { - type: 'Comment', - }, - }) - }) - - it('returns resource in comment attribute', async () => { - await expect(action()).resolves.toMatchObject({ - report: { - comment: { - content: 'Robert getting tired.', - }, - }, - }) - }) - }) - - /* An der Stelle wΓΌrde ich den c34 noch mal prΓΌfen, diesmal muss aber eine error meldung kommen. - At this point I would check the c34 again, but this time there must be an error message. */ - - describe('reported resource is a tag', () => { - beforeEach(async () => { - await factory.create('Tag', { - id: 't23', - }) - variables = { - ...variables, - resourceId: 't23', - } - }) - - it('returns null', async () => { - await expect(action()).resolves.toMatchObject({ - report: null, - }) - }) - }) - - /* An der Stelle wΓΌrde ich den t23 noch mal prΓΌfen, diesmal muss aber eine error meldung kommen. - At this point I would check the t23 again, but this time there must be an error message. */ - }) - }) -}) - -describe('reports query', () => { - let query, mutate, authenticatedUser, moderator, user, author - const categoryIds = ['cat9'] - const reportMutation = gql` mutation($resourceId: ID!, $reasonCategory: ReasonCategory!, $reasonDescription: String!) { report( @@ -317,32 +18,30 @@ describe('reports query', () => { reasonCategory: $reasonCategory reasonDescription: $reasonDescription ) { - type - } - } - ` - const reportsQuery = gql` - query { - reports(orderBy: createdAt_desc) { createdAt reasonCategory reasonDescription - submitter { - id - } type + submitter { + email + } user { - id + name } post { - id + title } comment { - id + content } } } ` + const variables = { + resourceId: 'whatever', + reasonCategory: 'other', + reasonDescription: 'Violates code of conduct !!!', + } beforeAll(async () => { await factory.cleanDatabase() @@ -350,172 +49,545 @@ describe('reports query', () => { context: () => { return { driver, + neode: instance, user: authenticatedUser, } }, }) - query = createTestClient(server).query mutate = createTestClient(server).mutate - }) - - beforeEach(async () => { - authenticatedUser = null - - moderator = await factory.create('User', { - id: 'mod1', - role: 'moderator', - email: 'moderator@example.org', - password: '1234', - }) - user = await factory.create('User', { - id: 'user1', - role: 'user', - email: 'test@example.org', - password: '1234', - }) - author = await factory.create('User', { - id: 'auth1', - role: 'user', - name: 'abusive-user', - email: 'abusive-user@example.org', - }) - await instance.create('Category', { - id: 'cat9', - name: 'Democracy & Politics', - icon: 'university', - }) - - await Promise.all([ - factory.create('Post', { - author, - id: 'p1', - categoryIds, - content: 'Interesting Knowledge', - }), - factory.create('Post', { - author: moderator, - id: 'p2', - categoryIds, - content: 'More things to do β¦', - }), - factory.create('Post', { - author: user, - id: 'p3', - categoryIds, - content: 'I am at school β¦', - }), - ]) - await Promise.all([ - factory.create('Comment', { - author: user, - id: 'c1', - postId: 'p1', - }), - ]) - - authenticatedUser = await user.toJson() - await Promise.all([ - mutate({ - mutation: reportMutation, - variables: { - resourceId: 'p1', - reasonCategory: 'other', - reasonDescription: 'This comment is bigoted', - }, - }), - mutate({ - mutation: reportMutation, - variables: { - resourceId: 'c1', - reasonCategory: 'discrimination_etc', - reasonDescription: 'This post is bigoted', - }, - }), - mutate({ - mutation: reportMutation, - variables: { - resourceId: 'auth1', - reasonCategory: 'doxing', - reasonDescription: 'This user is harassing me with bigoted remarks', - }, - }), - ]) - authenticatedUser = null + query = createTestClient(server).query }) afterEach(async () => { await factory.cleanDatabase() }) - describe('unauthenticated', () => { - it('throws authorization error', async () => { + describe('report a resource', () => { + describe('unauthenticated', () => { + it('throws authorization error', async () => { + authenticatedUser = null + await expect(mutate({ mutation: reportMutation, variables })).resolves.toMatchObject({ + data: { report: null }, + errors: [{ message: 'Not Authorised!' }], + }) + }) + }) + + describe('authenticated', () => { + beforeEach(async () => { + currentUser = await factory.create('User', { + id: 'current-user-id', + role: 'user', + email: 'test@example.org', + password: '1234', + }) + await factory.create('User', { + id: 'abusive-user-id', + role: 'user', + name: 'abusive-user', + email: 'abusive-user@example.org', + }) + await instance.create('Category', { + id: 'cat9', + name: 'Democracy & Politics', + icon: 'university', + }) + + authenticatedUser = await currentUser.toJson() + }) + + describe('invalid resource id', () => { + it('returns null', async () => { + await expect(mutate({ mutation: reportMutation, variables })).resolves.toMatchObject({ + data: { report: null }, + errors: undefined, + }) + }) + }) + + describe('valid resource', () => { + describe('reported resource is a user', () => { + it('returns type "User"', async () => { + await expect( + mutate({ + mutation: reportMutation, + variables: { ...variables, resourceId: 'abusive-user-id' }, + }), + ).resolves.toMatchObject({ + data: { + report: { + type: 'User', + }, + }, + errors: undefined, + }) + }) + + it('returns resource in user attribute', async () => { + await expect( + mutate({ + mutation: reportMutation, + variables: { ...variables, resourceId: 'abusive-user-id' }, + }), + ).resolves.toMatchObject({ + data: { + report: { + user: { + name: 'abusive-user', + }, + }, + }, + errors: undefined, + }) + }) + + it('returns the submitter', async () => { + await expect( + mutate({ + mutation: reportMutation, + variables: { ...variables, resourceId: 'abusive-user-id' }, + }), + ).resolves.toMatchObject({ + data: { + report: { + submitter: { + email: 'test@example.org', + }, + }, + }, + errors: undefined, + }) + }) + + it('returns a date', async () => { + await expect( + mutate({ + mutation: reportMutation, + variables: { ...variables, resourceId: 'abusive-user-id' }, + }), + ).resolves.toMatchObject({ + data: { + report: { + createdAt: expect.any(String), + }, + }, + errors: undefined, + }) + }) + + it('returns the reason category', async () => { + await expect( + mutate({ + mutation: reportMutation, + variables: { + ...variables, + resourceId: 'abusive-user-id', + reasonCategory: 'criminal_behavior_violation_german_law', + }, + }), + ).resolves.toMatchObject({ + data: { + report: { + reasonCategory: 'criminal_behavior_violation_german_law', + }, + }, + errors: undefined, + }) + }) + + it('gives an error if the reason category is not in enum "ReasonCategory"', async () => { + await expect( + mutate({ + mutation: reportMutation, + variables: { + ...variables, + resourceId: 'abusive-user-id', + reasonCategory: 'category_missing_from_enum_reason_category', + }, + }), + ).resolves.toMatchObject({ + data: undefined, + errors: [ + { + message: + 'Variable "$reasonCategory" got invalid value "category_missing_from_enum_reason_category"; Expected type ReasonCategory.', + }, + ], + }) + }) + + it('returns the reason description', async () => { + await expect( + mutate({ + mutation: reportMutation, + variables: { + ...variables, + resourceId: 'abusive-user-id', + reasonDescription: 'My reason!', + }, + }), + ).resolves.toMatchObject({ + data: { + report: { + reasonDescription: 'My reason!', + }, + }, + errors: undefined, + }) + }) + + it('sanitize the reason description', async () => { + await expect( + mutate({ + mutation: reportMutation, + variables: { + ...variables, + resourceId: 'abusive-user-id', + reasonDescription: 'My reason !', + }, + }), + ).resolves.toMatchObject({ + data: { + report: { + reasonDescription: 'My reason !', + }, + }, + errors: undefined, + }) + }) + }) + + describe('reported resource is a post', () => { + beforeEach(async () => { + await factory.create('Post', { + author: currentUser, + id: 'post-to-report-id', + title: 'This is a post that is going to be reported', + categoryIds, + }) + }) + + it('returns type "Post"', async () => { + await expect( + mutate({ + mutation: reportMutation, + variables: { + ...variables, + resourceId: 'post-to-report-id', + }, + }), + ).resolves.toMatchObject({ + data: { + report: { + type: 'Post', + }, + }, + errors: undefined, + }) + }) + + it('returns resource in post attribute', async () => { + await expect( + mutate({ + mutation: reportMutation, + variables: { + ...variables, + resourceId: 'post-to-report-id', + }, + }), + ).resolves.toMatchObject({ + data: { + report: { + post: { + title: 'This is a post that is going to be reported', + }, + }, + }, + errors: undefined, + }) + }) + + it('returns null in user attribute', async () => { + await expect( + mutate({ + mutation: reportMutation, + variables: { + ...variables, + resourceId: 'post-to-report-id', + }, + }), + ).resolves.toMatchObject({ + data: { + report: { + user: null, + }, + }, + errors: undefined, + }) + }) + }) + + describe('reported resource is a comment', () => { + let createPostVariables + beforeEach(async () => { + createPostVariables = { + id: 'p1', + title: 'post to comment on', + content: 'please comment on me', + categoryIds, + } + await factory.create('Post', { ...createPostVariables, author: currentUser }) + await factory.create('Comment', { + author: currentUser, + postId: 'p1', + id: 'comment-to-report-id', + content: 'Post comment to be reported.', + }) + }) + + it('returns type "Comment"', async () => { + await expect( + mutate({ + mutation: reportMutation, + variables: { + ...variables, + resourceId: 'comment-to-report-id', + }, + }), + ).resolves.toMatchObject({ + data: { + report: { + type: 'Comment', + }, + }, + errors: undefined, + }) + }) + + it('returns resource in comment attribute', async () => { + await expect( + mutate({ + mutation: reportMutation, + variables: { + ...variables, + resourceId: 'comment-to-report-id', + }, + }), + ).resolves.toMatchObject({ + data: { + report: { + comment: { + content: 'Post comment to be reported.', + }, + }, + }, + errors: undefined, + }) + }) + }) + + describe('reported resource is a tag', () => { + beforeEach(async () => { + await factory.create('Tag', { + id: 'tag-to-report-id', + }) + }) + + it('returns null', async () => { + await expect( + mutate({ + mutation: reportMutation, + variables: { + ...variables, + resourceId: 'tag-to-report-id', + }, + }), + ).resolves.toMatchObject({ + data: { report: null }, + errors: undefined, + }) + }) + }) + }) + }) + }) + describe('query for reported resource', () => { + const reportsQuery = gql` + query { + reports(orderBy: createdAt_desc) { + createdAt + reasonCategory + reasonDescription + submitter { + id + } + type + user { + id + } + post { + id + } + comment { + id + } + } + } + ` + + beforeEach(async () => { authenticatedUser = null - expect(query({ query: reportsQuery })).resolves.toMatchObject({ - data: { reports: null }, - errors: [{ message: 'Not Authorised!' }], - }) - }) - it('role "user" gets no reports', async () => { - authenticatedUser = await user.toJson() - expect(query({ query: reportsQuery })).resolves.toMatchObject({ - data: { reports: null }, - errors: [{ message: 'Not Authorised!' }], + moderator = await factory.create('User', { + id: 'moderator-1', + role: 'moderator', + email: 'moderator@example.org', + password: '1234', + }) + currentUser = await factory.create('User', { + id: 'current-user-id', + role: 'user', + email: 'current.user@example.org', + password: '1234', + }) + abusiveUser = await factory.create('User', { + id: 'abusive-user-1', + role: 'user', + name: 'abusive-user', + email: 'abusive-user@example.org', + }) + await instance.create('Category', { + id: 'cat9', + name: 'Democracy & Politics', + icon: 'university', }) - }) - it('role "moderator" gets reports', async () => { - const expected = { - // to check 'orderBy: createdAt_desc' is not possible here, because 'createdAt' does not differ - reports: expect.arrayContaining([ - expect.objectContaining({ - createdAt: expect.any(String), - reasonCategory: 'doxing', - reasonDescription: 'This user is harassing me with bigoted remarks', - submitter: expect.objectContaining({ - id: 'user1', - }), - type: 'User', - user: expect.objectContaining({ - id: 'auth1', - }), - post: null, - comment: null, - }), - expect.objectContaining({ - createdAt: expect.any(String), + await Promise.all([ + factory.create('Post', { + author: abusiveUser, + id: 'abusive-post-1', + categoryIds, + content: 'Interesting Knowledge', + }), + factory.create('Post', { + author: moderator, + id: 'post-2', + categoryIds, + content: 'More things to do β¦', + }), + factory.create('Post', { + author: currentUser, + id: 'post-3', + categoryIds, + content: 'I am at school β¦', + }), + ]) + await Promise.all([ + factory.create('Comment', { + author: currentUser, + id: 'abusive-comment-1', + postId: 'post-1', + }), + ]) + authenticatedUser = await currentUser.toJson() + await Promise.all([ + mutate({ + mutation: reportMutation, + variables: { + resourceId: 'abusive-post-1', reasonCategory: 'other', reasonDescription: 'This comment is bigoted', - submitter: expect.objectContaining({ - id: 'user1', - }), - type: 'Post', - user: null, - post: expect.objectContaining({ - id: 'p1', - }), - comment: null, - }), - expect.objectContaining({ - createdAt: expect.any(String), + }, + }), + mutate({ + mutation: reportMutation, + variables: { + resourceId: 'abusive-comment-1', reasonCategory: 'discrimination_etc', reasonDescription: 'This post is bigoted', - submitter: expect.objectContaining({ - id: 'user1', - }), - type: 'Comment', - user: null, - post: null, - comment: expect.objectContaining({ - id: 'c1', - }), - }), - ]), - } + }, + }), + mutate({ + mutation: reportMutation, + variables: { + resourceId: 'abusive-user-1', + reasonCategory: 'doxing', + reasonDescription: 'This user is harassing me with bigoted remarks', + }, + }), + ]) + authenticatedUser = null + }) + describe('unauthenticated', () => { + it('throws authorization error', async () => { + authenticatedUser = null + expect(query({ query: reportsQuery })).resolves.toMatchObject({ + data: { reports: null }, + errors: [{ message: 'Not Authorised!' }], + }) + }) + }) + describe('authenticated', () => { + it('role "user" gets no reports', async () => { + authenticatedUser = await currentUser.toJson() + expect(query({ query: reportsQuery })).resolves.toMatchObject({ + data: { reports: null }, + errors: [{ message: 'Not Authorised!' }], + }) + }) - authenticatedUser = await moderator.toJson() - const { data } = await query({ query: reportsQuery }) - expect(data).toEqual(expected) + it('role "moderator" gets reports', async () => { + const expected = { + // to check 'orderBy: createdAt_desc' is not possible here, because 'createdAt' does not differ + reports: expect.arrayContaining([ + expect.objectContaining({ + createdAt: expect.any(String), + reasonCategory: 'doxing', + reasonDescription: 'This user is harassing me with bigoted remarks', + submitter: expect.objectContaining({ + id: 'current-user-id', + }), + type: 'User', + user: expect.objectContaining({ + id: 'abusive-user-1', + }), + post: null, + comment: null, + }), + expect.objectContaining({ + createdAt: expect.any(String), + reasonCategory: 'other', + reasonDescription: 'This comment is bigoted', + submitter: expect.objectContaining({ + id: 'current-user-id', + }), + type: 'Post', + user: null, + post: expect.objectContaining({ + id: 'abusive-post-1', + }), + comment: null, + }), + expect.objectContaining({ + createdAt: expect.any(String), + reasonCategory: 'discrimination_etc', + reasonDescription: 'This post is bigoted', + submitter: expect.objectContaining({ + id: 'current-user-id', + }), + type: 'Comment', + user: null, + post: null, + comment: expect.objectContaining({ + id: 'abusive-comment-1', + }), + }), + ]), + } + authenticatedUser = await moderator.toJson() + const { data } = await query({ query: reportsQuery }) + expect(data).toEqual(expected) + }) }) }) }) diff --git a/backend/src/schema/types/type/Post.gql b/backend/src/schema/types/type/Post.gql index 5b11757d3..b4d98ec5c 100644 --- a/backend/src/schema/types/type/Post.gql +++ b/backend/src/schema/types/type/Post.gql @@ -1,3 +1,37 @@ +enum _PostOrdering { + id_asc + id_desc + activityId_asc + activityId_desc + objectId_asc + objectId_desc + title_asc + title_desc + slug_asc + slug_desc + content_asc + content_desc + contentExcerpt_asc + contentExcerpt_desc + image_asc + image_desc + visibility_asc + visibility_desc + deleted_asc + deleted_desc + disabled_asc + disabled_desc + createdAt_asc + createdAt_desc + updatedAt_asc + updatedAt_desc + language_asc + language_desc + pinned_asc + pinned_desc +} + + type Post { id: ID! activityId: String @@ -12,10 +46,15 @@ type Post { visibility: Visibility deleted: Boolean disabled: Boolean + pinned: Boolean disabledBy: User @relation(name: "DISABLED", direction: "IN") createdAt: String updatedAt: String language: String + pinnedAt: String @cypher( + statement: "MATCH (this)<-[pinned:PINNED]-(:User) WHERE NOT this.deleted = true AND NOT this.disabled = true RETURN pinned.createdAt" + ) + pinnedBy: User @relation(name:"PINNED", direction: "IN") relatedContributions: [Post]! @cypher( statement: """ @@ -40,7 +79,7 @@ type Post { @cypher( statement: "MATCH (this)<-[:SHOUTED]-(r:User) WHERE NOT r.deleted = true AND NOT r.disabled = true RETURN COUNT(DISTINCT r)" ) - + # Has the currently logged in user shouted that post? shoutedByCurrentUser: Boolean! @cypher( @@ -84,9 +123,12 @@ type Mutation { DeletePost(id: ID!): Post AddPostEmotions(to: _PostInput!, data: _EMOTEDInput!): EMOTED RemovePostEmotions(to: _PostInput!, data: _EMOTEDInput!): EMOTED + pinPost(id: ID!): Post + unpinPost(id: ID!): Post } type Query { PostsEmotionsCountByEmotion(postId: ID!, data: _EMOTEDInput!): Int! PostsEmotionsByCurrentUser(postId: ID!): [String] + profilePagePosts(filter: _PostFilter, first: Int, offset: Int, orderBy: [_PostOrdering]): [Post] } diff --git a/backend/src/schema/types/type/User.gql b/backend/src/schema/types/type/User.gql index 1f46dc6cd..cce0df058 100644 --- a/backend/src/schema/types/type/User.gql +++ b/backend/src/schema/types/type/User.gql @@ -1,182 +1,181 @@ type User { - id: ID! - actorId: String - name: String - email: String! @cypher(statement: "MATCH (this)-[: PRIMARY_EMAIL]->(e: EmailAddress) RETURN e.email") - slug: String! - avatar: String - coverImg: String - deleted: Boolean - disabled: Boolean - disabledBy: User @relation(name: "DISABLED", direction: "IN") - role: UserGroup! - publicKey: String - invitedBy: User @relation(name: "INVITED", direction: "IN") - invited: [User] @relation(name: "INVITED", direction: "OUT") + id: ID! + actorId: String + name: String + email: String! @cypher(statement: "MATCH (this)-[: PRIMARY_EMAIL]->(e: EmailAddress) RETURN e.email") + slug: String! + avatar: String + coverImg: String + deleted: Boolean + disabled: Boolean + disabledBy: User @relation(name: "DISABLED", direction: "IN") + role: UserGroup! + publicKey: String + invitedBy: User @relation(name: "INVITED", direction: "IN") + invited: [User] @relation(name: "INVITED", direction: "OUT") - location: Location @cypher(statement: "MATCH (this)-[: IS_IN]->(l: Location) RETURN l") - locationName: String - about: String - socialMedia: [SocialMedia]! @relation(name: "OWNED_BY", direction: "IN") + location: Location @cypher(statement: "MATCH (this)-[: IS_IN]->(l: Location) RETURN l") + locationName: String + about: String + socialMedia: [SocialMedia]! @relation(name: "OWNED_BY", direction: "IN") - # createdAt: DateTime - # updatedAt: DateTime - createdAt: String - updatedAt: String + # createdAt: DateTime + # updatedAt: DateTime + createdAt: String + updatedAt: String - termsAndConditionsAgreedVersion: String - termsAndConditionsAgreedAt: String + termsAndConditionsAgreedVersion: String + termsAndConditionsAgreedAt: String - allowEmbedIframes: Boolean + allowEmbedIframes: Boolean + locale: String + friends: [User]! @relation(name: "FRIENDS", direction: "BOTH") + friendsCount: Int! @cypher(statement: "MATCH (this)<-[: FRIENDS]->(r: User) RETURN COUNT(DISTINCT r)") - locale: String + following: [User]! @relation(name: "FOLLOWS", direction: "OUT") + followingCount: Int! @cypher(statement: "MATCH (this)-[: FOLLOWS]->(r: User) RETURN COUNT(DISTINCT r)") - friends: [User]! @relation(name: "FRIENDS", direction: "BOTH") - friendsCount: Int! @cypher(statement: "MATCH (this)<-[: FRIENDS]->(r: User) RETURN COUNT(DISTINCT r)") + followedBy: [User]! @relation(name: "FOLLOWS", direction: "IN") + followedByCount: Int! @cypher(statement: "MATCH (this)<-[: FOLLOWS]-(r: User) RETURN COUNT(DISTINCT r)") - following: [User]! @relation(name: "FOLLOWS", direction: "OUT") - followingCount: Int! @cypher(statement: "MATCH (this)-[: FOLLOWS]->(r: User) RETURN COUNT(DISTINCT r)") + # Is the currently logged in user following that user? + followedByCurrentUser: Boolean! @cypher( + statement: """ + MATCH (this)<-[: FOLLOWS]-(u: User { id: $cypherParams.currentUserId}) + RETURN COUNT(u) >= 1 + """ + ) + isBlocked: Boolean! @cypher( + statement: """ + MATCH (this)<-[: BLOCKED]-(u: User { id: $cypherParams.currentUserId}) + RETURN COUNT(u) >= 1 + """ + ) - followedBy: [User]! @relation(name: "FOLLOWS", direction: "IN") - followedByCount: Int! @cypher(statement: "MATCH (this)<-[: FOLLOWS]-(r: User) RETURN COUNT(DISTINCT r)") + # contributions: [WrittenPost]! + # contributions2(first: Int = 10, offset: Int = 0): [WrittenPost2]! + # @cypher( + # statement: "MATCH (this)-[w:WROTE]->(p:Post) RETURN p as Post, w.timestamp as timestamp" + # ) + contributions: [Post]! @relation(name: "WROTE", direction: "OUT") + contributionsCount: Int! @cypher( + statement: """ + MATCH (this)-[: WROTE]->(r: Post) + WHERE NOT r.deleted = true AND NOT r.disabled = true + RETURN COUNT(r) + """ + ) - # Is the currently logged in user following that user? - followedByCurrentUser: Boolean! @cypher( - statement: """ - MATCH (this)<-[: FOLLOWS]-(u: User { id: $cypherParams.currentUserId}) - RETURN COUNT(u) >= 1 - """ - ) - isBlocked: Boolean! @cypher( - statement: """ - MATCH (this)<-[: BLOCKED]-(u: User { id: $cypherParams.currentUserId}) - RETURN COUNT(u) >= 1 - """ - ) + comments: [Comment]! @relation(name: "WROTE", direction: "OUT") + commentedCount: Int! @cypher(statement: "MATCH (this)-[: WROTE]->(: Comment)-[: COMMENTS]->(p: Post) WHERE NOT p.deleted = true AND NOT p.disabled = true RETURN COUNT(DISTINCT(p))") - # contributions: [WrittenPost]! - # contributions2(first: Int = 10, offset: Int = 0): [WrittenPost2]! - # @cypher( - # statement: "MATCH (this)-[w:WROTE]->(p:Post) RETURN p as Post, w.timestamp as timestamp" - # ) - contributions: [Post]! @relation(name: "WROTE", direction: "OUT") - contributionsCount: Int! @cypher( - statement: """ - MATCH (this)-[: WROTE]->(r: Post) - WHERE NOT r.deleted = true AND NOT r.disabled = true - RETURN COUNT(r) - """ - ) + shouted: [Post]! @relation(name: "SHOUTED", direction: "OUT") + shoutedCount: Int! @cypher(statement: "MATCH (this)-[: SHOUTED]->(r: Post) WHERE NOT r.deleted = true AND NOT r.disabled = true RETURN COUNT(DISTINCT r)") - comments: [Comment]! @relation(name: "WROTE", direction: "OUT") - commentedCount: Int! @cypher(statement: "MATCH (this)-[: WROTE]->(: Comment)-[: COMMENTS]->(p: Post) WHERE NOT p.deleted = true AND NOT p.disabled = true RETURN COUNT(DISTINCT(p))") + categories: [Category]! @relation(name: "CATEGORIZED", direction: "OUT") - shouted: [Post]! @relation(name: "SHOUTED", direction: "OUT") - shoutedCount: Int! @cypher(statement: "MATCH (this)-[: SHOUTED]->(r: Post) WHERE NOT r.deleted = true AND NOT r.disabled = true RETURN COUNT(DISTINCT r)") + badges: [Badge]! @relation(name: "REWARDED", direction: "IN") + badgesCount: Int! @cypher(statement: "MATCH (this)<-[: REWARDED]-(r: Badge) RETURN COUNT(r)") - categories: [Category]! @relation(name: "CATEGORIZED", direction: "OUT") - - badges: [Badge]! @relation(name: "REWARDED", direction: "IN") - badgesCount: Int! @cypher(statement: "MATCH (this)<-[: REWARDED]-(r: Badge) RETURN COUNT(r)") - - emotions: [EMOTED] + emotions: [EMOTED] } input _UserFilter { - AND: [_UserFilter!] - OR: [_UserFilter!] - name_contains: String - about_contains: String - slug_contains: String - id: ID - id_not: ID - id_in: [ID!] - id_not_in: [ID!] - id_contains: ID - id_not_contains: ID - id_starts_with: ID - id_not_starts_with: ID - id_ends_with: ID - id_not_ends_with: ID - friends: _UserFilter - friends_not: _UserFilter - friends_in: [_UserFilter!] - friends_not_in: [_UserFilter!] - friends_some: _UserFilter - friends_none: _UserFilter - friends_single: _UserFilter - friends_every: _UserFilter - following: _UserFilter - following_not: _UserFilter - following_in: [_UserFilter!] - following_not_in: [_UserFilter!] - following_some: _UserFilter - following_none: _UserFilter - following_single: _UserFilter - following_every: _UserFilter - followedBy: _UserFilter - followedBy_not: _UserFilter - followedBy_in: [_UserFilter!] - followedBy_not_in: [_UserFilter!] - followedBy_some: _UserFilter - followedBy_none: _UserFilter - followedBy_single: _UserFilter - followedBy_every: _UserFilter + AND: [_UserFilter!] + OR: [_UserFilter!] + name_contains: String + about_contains: String + slug_contains: String + id: ID + id_not: ID + id_in: [ID!] + id_not_in: [ID!] + id_contains: ID + id_not_contains: ID + id_starts_with: ID + id_not_starts_with: ID + id_ends_with: ID + id_not_ends_with: ID + friends: _UserFilter + friends_not: _UserFilter + friends_in: [_UserFilter!] + friends_not_in: [_UserFilter!] + friends_some: _UserFilter + friends_none: _UserFilter + friends_single: _UserFilter + friends_every: _UserFilter + following: _UserFilter + following_not: _UserFilter + following_in: [_UserFilter!] + following_not_in: [_UserFilter!] + following_some: _UserFilter + following_none: _UserFilter + following_single: _UserFilter + following_every: _UserFilter + followedBy: _UserFilter + followedBy_not: _UserFilter + followedBy_in: [_UserFilter!] + followedBy_not_in: [_UserFilter!] + followedBy_some: _UserFilter + followedBy_none: _UserFilter + followedBy_single: _UserFilter + followedBy_every: _UserFilter + role_in: [UserGroup!] } type Query { - User( - id: ID - email: String - actorId: String - name: String - slug: String - avatar: String - coverImg: String - role: UserGroup - locationName: String - about: String - createdAt: String - updatedAt: String - friendsCount: Int - followingCount: Int - followedByCount: Int - followedByCurrentUser: Boolean - contributionsCount: Int - commentedCount: Int - shoutedCount: Int - badgesCount: Int - first: Int - offset: Int - orderBy: [_UserOrdering] - filter: _UserFilter - ): [User] + User( + id: ID + email: String + actorId: String + name: String + slug: String + avatar: String + coverImg: String + role: UserGroup + locationName: String + about: String + createdAt: String + updatedAt: String + friendsCount: Int + followingCount: Int + followedByCount: Int + followedByCurrentUser: Boolean + contributionsCount: Int + commentedCount: Int + shoutedCount: Int + badgesCount: Int + first: Int + offset: Int + orderBy: [_UserOrdering] + filter: _UserFilter + ): [User] - blockedUsers: [User] - currentUser: User + blockedUsers: [User] + currentUser: User } type Mutation { - UpdateUser ( - id: ID! - name: String - email: String - slug: String - avatar: String - coverImg: String - avatarUpload: Upload - locationName: String - about: String - termsAndConditionsAgreedVersion: String - termsAndConditionsAgreedAt: String - allowEmbedIframes: Boolean + UpdateUser ( + id: ID! + name: String + email: String + slug: String + avatar: String + coverImg: String + avatarUpload: Upload + locationName: String + about: String + termsAndConditionsAgreedVersion: String + termsAndConditionsAgreedAt: String + allowEmbedIframes: Boolean locale: String - ): User + ): User - DeleteUser(id: ID!, resource: [Deletable]): User + DeleteUser(id: ID!, resource: [Deletable]): User - block(id: ID!): User - unblock(id: ID!): User + block(id: ID!): User + unblock(id: ID!): User } diff --git a/backend/yarn.lock b/backend/yarn.lock index 4209e8d81..29b6094ae 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -1042,60 +1042,60 @@ resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570" integrity sha1-p3c2C1s5oaLlEG+OhY8v0tBgxXA= -"@sentry/core@5.7.0": - version "5.7.0" - resolved "https://registry.yarnpkg.com/@sentry/core/-/core-5.7.0.tgz#c2aa5341e703ec7cf2acc69e51971a0b1f7d102a" - integrity sha512-gQel0d7LBSWJGHc7gfZllYAu+RRGD9GcYGmkRfemurmDyDGQDf/sfjiBi8f9QxUc2iFTHnvIR5nMTyf0U3yl3Q== +"@sentry/core@5.7.1": + version "5.7.1" + resolved "https://registry.yarnpkg.com/@sentry/core/-/core-5.7.1.tgz#3eb2b7662cac68245931ee939ec809bf7a639d0e" + integrity sha512-AOn3k3uVWh2VyajcHbV9Ta4ieDIeLckfo7UMLM+CTk2kt7C89SayDGayJMSsIrsZlL4qxBoLB9QY4W2FgAGJrg== dependencies: - "@sentry/hub" "5.7.0" - "@sentry/minimal" "5.7.0" - "@sentry/types" "5.7.0" - "@sentry/utils" "5.7.0" + "@sentry/hub" "5.7.1" + "@sentry/minimal" "5.7.1" + "@sentry/types" "5.7.1" + "@sentry/utils" "5.7.1" tslib "^1.9.3" -"@sentry/hub@5.7.0": - version "5.7.0" - resolved "https://registry.yarnpkg.com/@sentry/hub/-/hub-5.7.0.tgz#f7c356202a9db1daae82ce7f48ebf1139e4e9d02" - integrity sha512-qNdYheJ6j4P9Sk0eqIINpJohImmu/+trCwFb4F8BGLQth5iGMVQD6D0YUrgjf4ZaQwfhw9tv4W6VEfF5tyASoA== +"@sentry/hub@5.7.1": + version "5.7.1" + resolved "https://registry.yarnpkg.com/@sentry/hub/-/hub-5.7.1.tgz#a52acd9fead7f3779d96e9965c6978aecc8b9cad" + integrity sha512-evGh323WR073WSBCg/RkhlUmCQyzU0xzBzCZPscvcoy5hd4SsLE6t9Zin+WACHB9JFsRQIDwNDn+D+pj3yKsig== dependencies: - "@sentry/types" "5.7.0" - "@sentry/utils" "5.7.0" + "@sentry/types" "5.7.1" + "@sentry/utils" "5.7.1" tslib "^1.9.3" -"@sentry/minimal@5.7.0": - version "5.7.0" - resolved "https://registry.yarnpkg.com/@sentry/minimal/-/minimal-5.7.0.tgz#832d26bcd862c6ea628d48ad199ac7f966a2d907" - integrity sha512-0sizE2prS9nmfLyVUKmVzFFFqRNr9iorSCCejwnlRe3crqKqjf84tuRSzm6NkZjIyYj9djuuo9l9XN12NLQ/4A== +"@sentry/minimal@5.7.1": + version "5.7.1" + resolved "https://registry.yarnpkg.com/@sentry/minimal/-/minimal-5.7.1.tgz#56afc537737586929e25349765e37a367958c1e1" + integrity sha512-nS/Dg+jWAZtcxQW8wKbkkw4dYvF6uyY/vDiz/jFCaux0LX0uhgXAC9gMOJmgJ/tYBLJ64l0ca5LzpZa7BMJQ0g== dependencies: - "@sentry/hub" "5.7.0" - "@sentry/types" "5.7.0" + "@sentry/hub" "5.7.1" + "@sentry/types" "5.7.1" tslib "^1.9.3" -"@sentry/node@^5.7.0": - version "5.7.0" - resolved "https://registry.yarnpkg.com/@sentry/node/-/node-5.7.0.tgz#153777f06b2fcd346edbff9adbb6b231c7e5fa0a" - integrity sha512-iqQbGAJDBlpQkp1rl9RkDCIfnukr4cOtHPgJPmLY19m/KXIHD2cdKhvbqoCvIPBTIAeSGQIvDT9jD5zT46eoqQ== +"@sentry/node@^5.7.1": + version "5.7.1" + resolved "https://registry.yarnpkg.com/@sentry/node/-/node-5.7.1.tgz#94e2fbac94f6cc061be3bc14b22813536c59698d" + integrity sha512-hVM10asFStrOhYZzMqFM7V1lrHkr1ydc2n/SFG0ZmIQxfTjCVElyXV/BJASIdqadM1fFIvvtD/EfgkTcZmub1g== dependencies: - "@sentry/core" "5.7.0" - "@sentry/hub" "5.7.0" - "@sentry/types" "5.7.0" - "@sentry/utils" "5.7.0" + "@sentry/core" "5.7.1" + "@sentry/hub" "5.7.1" + "@sentry/types" "5.7.1" + "@sentry/utils" "5.7.1" cookie "^0.3.1" https-proxy-agent "^3.0.0" lru_map "^0.3.3" tslib "^1.9.3" -"@sentry/types@5.7.0": - version "5.7.0" - resolved "https://registry.yarnpkg.com/@sentry/types/-/types-5.7.0.tgz#e8677e57b40c2c63cad42c02add12b238e647c10" - integrity sha512-bFRVortg713dE2yJXNFgNe6sNBVVSkpoELLkGPatdVQi0dYc6OggIIX4UZZvkynFx72GwYqO1NOrtUcJY2gmMg== +"@sentry/types@5.7.1": + version "5.7.1" + resolved "https://registry.yarnpkg.com/@sentry/types/-/types-5.7.1.tgz#4c4c1d4d891b6b8c2c3c7b367d306a8b1350f090" + integrity sha512-tbUnTYlSliXvnou5D4C8Zr+7/wJrHLbpYX1YkLXuIJRU0NSi81bHMroAuHWILcQKWhVjaV/HZzr7Y/hhWtbXVQ== -"@sentry/utils@5.7.0": - version "5.7.0" - resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-5.7.0.tgz#a6850aa4f5476fa26517cd5c6248f871d8d9939b" - integrity sha512-XmwQpLqea9mj8x1N7P/l4JvnEb0Rn5Py5OtBgl0ctk090W+GB1uM8rl9mkMf6698o1s1Z8T/tI/QY0yFA5uZXg== +"@sentry/utils@5.7.1": + version "5.7.1" + resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-5.7.1.tgz#cf37ad55f78e317665cd8680f202d307fa77f1d0" + integrity sha512-nhirUKj/qFLsR1i9kJ5BRvNyzdx/E2vorIsukuDrbo8e3iZ11JMgCOVrmC8Eq9YkHBqgwX4UnrPumjFyvGMZ2Q== dependencies: - "@sentry/types" "5.7.0" + "@sentry/types" "5.7.1" tslib "^1.9.3" "@sindresorhus/is@^0.14.0": @@ -3259,10 +3259,10 @@ eslint-plugin-import@~2.18.2: read-pkg-up "^2.0.0" resolve "^1.11.0" -eslint-plugin-jest@~22.19.0: - version "22.19.0" - resolved "https://registry.yarnpkg.com/eslint-plugin-jest/-/eslint-plugin-jest-22.19.0.tgz#0cf90946a8c927d40a2c64458c89bb635d0f2a0b" - integrity sha512-4zUc3rh36ds0SXdl2LywT4YWA3zRe8sfLhz8bPp8qQPIKvynTTkNGwmSCMpl5d9QiZE2JxSinGF+WD8yU+O0Lg== +eslint-plugin-jest@~22.20.0: + version "22.20.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-jest/-/eslint-plugin-jest-22.20.0.tgz#a3c3615c516fcbd20d50dbf395ea37361bd9e3b2" + integrity sha512-UwHGXaYprxwd84Wer8H7jZS+5C3LeEaU8VD7NqORY6NmPJrs+9Ugbq3wyjqO3vWtSsDaLar2sqEB8COmOZA4zw== dependencies: "@typescript-eslint/experimental-utils" "^1.13.0" @@ -3594,7 +3594,8 @@ extsprintf@^1.2.0: faker@Marak/faker.js#master: version "4.1.0" - resolved "https://codeload.github.com/Marak/faker.js/tar.gz/10bfb9f467b0ac2b8912ffc15690b50ef3244f09" + uid "9fd8d7d37b398842d0784a116a340f7aa6afb89b" + resolved "https://codeload.github.com/Marak/faker.js/tar.gz/9fd8d7d37b398842d0784a116a340f7aa6afb89b" fast-deep-equal@^2.0.1: version "2.0.1" diff --git a/package.json b/package.json index be86ba841..cc3268aa2 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "cypress-cucumber-preprocessor": "^1.16.2", "cypress-file-upload": "^3.4.0", "cypress-plugin-retries": "^1.3.0", - "date-fns": "^2.5.0", + "date-fns": "^2.5.1", "dotenv": "^8.2.0", "faker": "Marak/faker.js#master", "graphql-request": "^1.8.2", diff --git a/webapp/Dockerfile b/webapp/Dockerfile index d0122ee0e..20f19b5b6 100644 --- a/webapp/Dockerfile +++ b/webapp/Dockerfile @@ -1,4 +1,4 @@ -FROM node:12.12.0-alpine as base +FROM node:12.13.0-alpine as base LABEL Description="Web Frontend of the Social Network Human-Connection.org" Vendor="Human-Connection gGmbH" Version="0.0.1" Maintainer="Human-Connection gGmbH (developer@human-connection.org)" EXPOSE 3000 diff --git a/webapp/Dockerfile.maintenance b/webapp/Dockerfile.maintenance index 5f4df1dbf..b32bca8e1 100644 --- a/webapp/Dockerfile.maintenance +++ b/webapp/Dockerfile.maintenance @@ -1,4 +1,4 @@ -FROM node:12.12.0-alpine as build +FROM node:12.13.0-alpine as build LABEL Description="Web Frontend of the Social Network Human-Connection.org" Vendor="Human-Connection gGmbH" Version="0.0.1" Maintainer="Human-Connection gGmbH (developer@human-connection.org)" EXPOSE 3000 diff --git a/webapp/components/Comment/Comment.vue b/webapp/components/Comment/Comment.vue index a7be75b10..2da0df5fa 100644 --- a/webapp/components/Comment/Comment.vue +++ b/webapp/components/Comment/Comment.vue @@ -149,6 +149,7 @@ export default { }, editCommentMenu(showMenu) { this.openEditCommentMenu = showMenu + this.$emit('toggleNewCommentForm', !showMenu) }, updateComment(comment) { this.$emit('updateComment', comment) diff --git a/webapp/components/CommentList/CommentList.story.js b/webapp/components/CommentList/CommentList.story.js new file mode 100644 index 000000000..1f96b1ad0 --- /dev/null +++ b/webapp/components/CommentList/CommentList.story.js @@ -0,0 +1,45 @@ +import { storiesOf } from '@storybook/vue' +import { withA11y } from '@storybook/addon-a11y' +import HcCommentList from './CommentList.vue' +import helpers from '~/storybook/helpers' +import faker from 'faker' + +helpers.init() + +const commentMock = fields => { + return { + id: faker.random.uuid(), + title: faker.lorem.sentence(), + content: faker.lorem.paragraph(), + createdAt: faker.date.past(), + updatedAt: faker.date.recent(), + deleted: false, + disabled: false, + ...fields, + } +} + +const comments = [ + commentMock(), + commentMock(), + commentMock(), + commentMock(), + commentMock(), + commentMock(), + commentMock(), + commentMock(), + commentMock(), + commentMock(), +] + +storiesOf('CommentList', module) + .addDecorator(withA11y) + .addDecorator(helpers.layout) + .add('given 10 comments', () => ({ + components: { HcCommentList }, + store: helpers.store, + data: () => ({ + post: { comments }, + }), + template: ``, + })) diff --git a/webapp/components/CommentList/CommentList.vue b/webapp/components/CommentList/CommentList.vue index b1a79fd21..df008e9ee 100644 --- a/webapp/components/CommentList/CommentList.vue +++ b/webapp/components/CommentList/CommentList.vue @@ -25,6 +25,7 @@ :routeHash="routeHash" @deleteComment="updateCommentList" @updateComment="updateCommentList" + @toggleNewCommentForm="toggleNewCommentForm" /> @@ -51,6 +52,9 @@ export default { return comment.id === updatedComment.id ? updatedComment : comment }) }, + toggleNewCommentForm(showNewCommentForm) { + this.$emit('toggleNewCommentForm', showNewCommentForm) + }, }, } diff --git a/webapp/components/ContentMenu.vue b/webapp/components/ContentMenu.vue index 3b82486fe..521a8ed6e 100644 --- a/webapp/components/ContentMenu.vue +++ b/webapp/components/ContentMenu.vue @@ -55,24 +55,46 @@ export default { routes() { let routes = [] - if (this.isOwner && this.resourceType === 'contribution') { - routes.push({ - name: this.$t(`post.menu.edit`), - path: this.$router.resolve({ - name: 'post-edit-id', - params: { - id: this.resource.id, + if (this.resourceType === 'contribution') { + if (this.isOwner) { + routes.push({ + name: this.$t(`post.menu.edit`), + path: this.$router.resolve({ + name: 'post-edit-id', + params: { + id: this.resource.id, + }, + }).href, + icon: 'edit', + }) + routes.push({ + name: this.$t(`post.menu.delete`), + callback: () => { + this.openModal('delete') }, - }).href, - icon: 'edit', - }) - routes.push({ - name: this.$t(`post.menu.delete`), - callback: () => { - this.openModal('delete') - }, - icon: 'trash', - }) + icon: 'trash', + }) + } + + if (this.isAdmin) { + if (!this.resource.pinnedBy) { + routes.push({ + name: this.$t(`post.menu.pin`), + callback: () => { + this.$emit('pinPost', this.resource) + }, + icon: 'link', + }) + } else { + routes.push({ + name: this.$t(`post.menu.unpin`), + callback: () => { + this.$emit('unpinPost', this.resource) + }, + icon: 'unlink', + }) + } + } } if (this.isOwner && this.resourceType === 'comment') { @@ -155,6 +177,9 @@ export default { isModerator() { return this.$store.getters['auth/isModerator'] }, + isAdmin() { + return this.$store.getters['auth/isAdmin'] + }, }, methods: { openItem(route, toggleMenu) { diff --git a/webapp/components/FilterPosts/CategoriesFilterMenuItems.vue b/webapp/components/FilterPosts/CategoriesFilterMenuItems.vue index 7a35bf3cc..72835a660 100644 --- a/webapp/components/FilterPosts/CategoriesFilterMenuItems.vue +++ b/webapp/components/FilterPosts/CategoriesFilterMenuItems.vue @@ -67,13 +67,13 @@ export default { }, computed: { ...mapGetters({ - filteredCategoryIds: 'postsFilter/filteredCategoryIds', + filteredCategoryIds: 'posts/filteredCategoryIds', }), }, methods: { ...mapMutations({ - resetCategories: 'postsFilter/RESET_CATEGORIES', - toggleCategory: 'postsFilter/TOGGLE_CATEGORY', + resetCategories: 'posts/RESET_CATEGORIES', + toggleCategory: 'posts/TOGGLE_CATEGORY', }), }, } diff --git a/webapp/components/FilterPosts/FilterPosts.spec.js b/webapp/components/FilterPosts/FilterPosts.spec.js index 0cbd3e962..1f0ee920d 100644 --- a/webapp/components/FilterPosts/FilterPosts.spec.js +++ b/webapp/components/FilterPosts/FilterPosts.spec.js @@ -50,20 +50,20 @@ describe('FilterPosts.vue', () => { describe('mount', () => { mutations = { - 'postsFilter/TOGGLE_FILTER_BY_FOLLOWED': jest.fn(), - 'postsFilter/RESET_CATEGORIES': jest.fn(), - 'postsFilter/TOGGLE_CATEGORY': jest.fn(), - 'postsFilter/TOGGLE_EMOTION': jest.fn(), + 'posts/TOGGLE_FILTER_BY_FOLLOWED': jest.fn(), + 'posts/RESET_CATEGORIES': jest.fn(), + 'posts/TOGGLE_CATEGORY': jest.fn(), + 'posts/TOGGLE_EMOTION': jest.fn(), } getters = { - 'postsFilter/isActive': () => false, + 'posts/isActive': () => false, 'auth/isModerator': () => false, 'auth/user': () => { return { id: 'u34' } }, - 'postsFilter/filteredCategoryIds': jest.fn(() => []), - 'postsFilter/filteredByUsersFollowed': jest.fn(), - 'postsFilter/filteredByEmotions': jest.fn(() => []), + 'posts/filteredCategoryIds': jest.fn(() => []), + 'posts/filteredByUsersFollowed': jest.fn(), + 'posts/filteredByEmotions': jest.fn(() => []), } const openFilterPosts = () => { const store = new Vuex.Store({ mutations, getters }) @@ -94,18 +94,18 @@ describe('FilterPosts.vue', () => { const wrapper = openFilterPosts() environmentAndNatureButton = wrapper.findAll('button').at(2) environmentAndNatureButton.trigger('click') - expect(mutations['postsFilter/TOGGLE_CATEGORY']).toHaveBeenCalledWith({}, 'cat4') + expect(mutations['posts/TOGGLE_CATEGORY']).toHaveBeenCalledWith({}, 'cat4') }) it('sets category button attribute `primary` when corresponding category is filtered', () => { - getters['postsFilter/filteredCategoryIds'] = jest.fn(() => ['cat9']) + getters['posts/filteredCategoryIds'] = jest.fn(() => ['cat9']) const wrapper = openFilterPosts() democracyAndPoliticsButton = wrapper.findAll('button').at(4) expect(democracyAndPoliticsButton.attributes().class).toContain('ds-button-primary') }) it('sets "filter-by-followed-authors-only" button attribute `primary`', () => { - getters['postsFilter/filteredByUsersFollowed'] = jest.fn(() => true) + getters['posts/filteredByUsersFollowed'] = jest.fn(() => true) const wrapper = openFilterPosts() expect( wrapper.find({ name: 'filter-by-followed-authors-only' }).classes('ds-button-primary'), @@ -120,7 +120,7 @@ describe('FilterPosts.vue', () => { }) it('calls TOGGLE_FILTER_BY_FOLLOWED', () => { - expect(mutations['postsFilter/TOGGLE_FILTER_BY_FOLLOWED']).toHaveBeenCalledWith({}, 'u34') + expect(mutations['posts/TOGGLE_FILTER_BY_FOLLOWED']).toHaveBeenCalledWith({}, 'u34') }) }) @@ -129,11 +129,11 @@ describe('FilterPosts.vue', () => { const wrapper = openFilterPosts() happyEmotionButton = wrapper.findAll('button.emotions-buttons').at(1) happyEmotionButton.trigger('click') - expect(mutations['postsFilter/TOGGLE_EMOTION']).toHaveBeenCalledWith({}, 'happy') + expect(mutations['posts/TOGGLE_EMOTION']).toHaveBeenCalledWith({}, 'happy') }) it('sets the attribute `src` to colorized image', () => { - getters['postsFilter/filteredByEmotions'] = jest.fn(() => ['happy']) + getters['posts/filteredByEmotions'] = jest.fn(() => ['happy']) const wrapper = openFilterPosts() happyEmotionButton = wrapper.findAll('button.emotions-buttons').at(1) const happyEmotionButtonImage = happyEmotionButton.find('img') diff --git a/webapp/components/FilterPosts/FilterPosts.vue b/webapp/components/FilterPosts/FilterPosts.vue index 8a12ac633..58f0794d2 100644 --- a/webapp/components/FilterPosts/FilterPosts.vue +++ b/webapp/components/FilterPosts/FilterPosts.vue @@ -39,7 +39,7 @@ export default { computed: { ...mapGetters({ currentUser: 'auth/user', - filterActive: 'postsFilter/isActive', + filterActive: 'posts/isActive', }), chunk() { return chunk(this.categories, 2) diff --git a/webapp/components/FilterPosts/GeneralFilterMenuItems.vue b/webapp/components/FilterPosts/GeneralFilterMenuItems.vue index 0c8db9d22..96b050713 100644 --- a/webapp/components/FilterPosts/GeneralFilterMenuItems.vue +++ b/webapp/components/FilterPosts/GeneralFilterMenuItems.vue @@ -68,14 +68,14 @@ export default { }, computed: { ...mapGetters({ - filteredByUsersFollowed: 'postsFilter/filteredByUsersFollowed', - filteredByEmotions: 'postsFilter/filteredByEmotions', + filteredByUsersFollowed: 'posts/filteredByUsersFollowed', + filteredByEmotions: 'posts/filteredByEmotions', }), }, methods: { ...mapMutations({ - toggleFilteredByFollowed: 'postsFilter/TOGGLE_FILTER_BY_FOLLOWED', - toogleFilteredByEmotions: 'postsFilter/TOGGLE_EMOTION', + toggleFilteredByFollowed: 'posts/TOGGLE_FILTER_BY_FOLLOWED', + toogleFilteredByEmotions: 'posts/TOGGLE_EMOTION', }), iconPath(emotion) { if (this.filteredByEmotions.includes(emotion)) { diff --git a/webapp/components/LoginForm/LoginForm.story.js b/webapp/components/LoginForm/LoginForm.story.js new file mode 100644 index 000000000..618b2556c --- /dev/null +++ b/webapp/components/LoginForm/LoginForm.story.js @@ -0,0 +1,75 @@ +import { storiesOf } from '@storybook/vue' +import { withA11y } from '@storybook/addon-a11y' +import { action } from '@storybook/addon-actions' +import Vuex from 'vuex' +import helpers from '~/storybook/helpers' +import LoginForm from './LoginForm.vue' + +helpers.init() + +const createStore = ({ loginSuccess }) => { + return new Vuex.Store({ + modules: { + auth: { + namespaced: true, + state: () => ({ + pending: false, + }), + mutations: { + SET_PENDING(state, pending) { + state.pending = pending + }, + }, + getters: { + pending(state) { + return !!state.pending + }, + }, + actions: { + async login({ commit, dispatch }, args) { + action('Vuex action `auth/login`')(args) + return new Promise((resolve, reject) => { + commit('SET_PENDING', true) + setTimeout(() => { + commit('SET_PENDING', false) + if (loginSuccess) { + resolve(loginSuccess) + } else { + reject(new Error('Login unsuccessful')) + } + }, 1000) + }) + }, + }, + }, + }, + }) +} + +storiesOf('LoginForm', module) + .addDecorator(withA11y) + .addDecorator(helpers.layout) + .add('successful login', () => { + return { + components: { LoginForm }, + store: createStore({ loginSuccess: true }), + methods: { + handleSuccess() { + action('Login successful!')() + }, + }, + template: ``, + } + }) + .add('unsuccessful login', () => { + return { + components: { LoginForm }, + store: createStore({ loginSuccess: false }), + methods: { + handleSuccess() { + action('Login successful!')() + }, + }, + template: ``, + } + }) diff --git a/webapp/components/LoginForm/LoginForm.vue b/webapp/components/LoginForm/LoginForm.vue new file mode 100644 index 000000000..91693ed4b --- /dev/null +++ b/webapp/components/LoginForm/LoginForm.vue @@ -0,0 +1,121 @@ + + + + + {{ $t('quotes.african.quote') }} + - {{ $t('quotes.african.author') }} + + + + + + + + + + + + + + + + {{ $t('login.moreInfo') }} + + + + {{ $t('login.copy') }} + + + + + + {{ $t('login.forgotPassword') }} + + + {{ $t('login.login') }} + + + {{ $t('login.no-account') }} + {{ $t('login.register') }} + + + + + + + + + + + diff --git a/webapp/components/PostCard/index.spec.js b/webapp/components/PostCard/PostCard.spec.js similarity index 98% rename from webapp/components/PostCard/index.spec.js rename to webapp/components/PostCard/PostCard.spec.js index 26d0515d6..ab902f05a 100644 --- a/webapp/components/PostCard/index.spec.js +++ b/webapp/components/PostCard/PostCard.spec.js @@ -2,7 +2,7 @@ import { config, shallowMount, mount, createLocalVue, RouterLinkStub } from '@vu import Styleguide from '@human-connection/styleguide' import Vuex from 'vuex' import Filters from '~/plugins/vue-filters' -import PostCard from '.' +import PostCard from './PostCard.vue' const localVue = createLocalVue() diff --git a/webapp/components/PostCard/PostCard.story.js b/webapp/components/PostCard/PostCard.story.js index 1f9f70110..1e470ce11 100644 --- a/webapp/components/PostCard/PostCard.story.js +++ b/webapp/components/PostCard/PostCard.story.js @@ -1,6 +1,6 @@ import { storiesOf } from '@storybook/vue' import { withA11y } from '@storybook/addon-a11y' -import HcPostCard from '~/components/PostCard' +import HcPostCard from './PostCard.vue' import helpers from '~/storybook/helpers' helpers.init() @@ -76,3 +76,23 @@ storiesOf('Post Card', module) /> `, })) + .add('pinned by admin', () => ({ + components: { HcPostCard }, + store: helpers.store, + data: () => ({ + post: { + ...post, + pinnedBy: { + id: '4711', + name: 'Ad Min', + role: 'admin', + }, + }, + }), + template: ` + + `, + })) diff --git a/webapp/components/PostCard/index.vue b/webapp/components/PostCard/PostCard.vue similarity index 90% rename from webapp/components/PostCard/index.vue rename to webapp/components/PostCard/PostCard.vue index 85b19f105..f368fadbb 100644 --- a/webapp/components/PostCard/index.vue +++ b/webapp/components/PostCard/PostCard.vue @@ -1,7 +1,7 @@ - + + @@ -61,6 +62,8 @@ :resource="post" :modalsData="menuModalsData" :is-owner="isAuthor" + @pinPost="pinPost" + @unpinPost="unpinPost" /> @@ -114,6 +117,9 @@ export default { this.deletePostCallback, ) }, + isPinned() { + return this.post && this.post.pinnedBy + }, }, methods: { async deletePostCallback() { @@ -127,6 +133,12 @@ export default { this.$toast.error(err.message) } }, + pinPost(post) { + this.$emit('pinPost', post) + }, + unpinPost(post) { + this.$emit('unpinPost', post) + }, }, } @@ -167,4 +179,8 @@ export default { text-indent: -999999px; } } + +.post--pinned { + border: 1px solid $color-warning; +} diff --git a/webapp/components/Ribbon/index.vue b/webapp/components/Ribbon/index.vue index c92935352..c8c09c194 100644 --- a/webapp/components/Ribbon/index.vue +++ b/webapp/components/Ribbon/index.vue @@ -46,4 +46,12 @@ export default { border-color: $background-color-secondary transparent transparent $background-color-secondary; } } + +.ribbon--pinned { + background-color: $color-warning-active; + + &::before { + border-color: $color-warning transparent transparent $color-warning; + } +} diff --git a/webapp/components/notifications/NotificationMenu/NotificationMenu.vue b/webapp/components/notifications/NotificationMenu/NotificationMenu.vue index 9c3ed0bd7..26d8256bd 100644 --- a/webapp/components/notifications/NotificationMenu/NotificationMenu.vue +++ b/webapp/components/notifications/NotificationMenu/NotificationMenu.vue @@ -93,7 +93,7 @@ export default { return data.notifications }, error(error) { - this.$toast.error(error) + this.$toast.error(error.message) }, }, }, diff --git a/webapp/graphql/Fragments.js b/webapp/graphql/Fragments.js index e0c6e699e..37ec15435 100644 --- a/webapp/graphql/Fragments.js +++ b/webapp/graphql/Fragments.js @@ -57,6 +57,12 @@ export const postFragment = lang => gql` name icon } + pinnedBy { + id + name + role + } + pinnedAt } ` export const commentFragment = lang => gql` diff --git a/webapp/graphql/PostMutations.js b/webapp/graphql/PostMutations.js index fc672c40d..01227ea87 100644 --- a/webapp/graphql/PostMutations.js +++ b/webapp/graphql/PostMutations.js @@ -50,6 +50,11 @@ export default () => { content contentExcerpt language + pinnedBy { + id + name + role + } } } `, @@ -86,5 +91,39 @@ export default () => { } } `, + pinPost: gql` + mutation($id: ID!) { + pinPost(id: $id) { + id + title + slug + content + contentExcerpt + language + pinnedBy { + id + name + role + } + } + } + `, + unpinPost: gql` + mutation($id: ID!) { + unpinPost(id: $id) { + id + title + slug + content + contentExcerpt + language + pinnedBy { + id + name + role + } + } + } + `, } } diff --git a/webapp/graphql/PostQuery.js b/webapp/graphql/PostQuery.js index bca276f64..3de1178b0 100644 --- a/webapp/graphql/PostQuery.js +++ b/webapp/graphql/PostQuery.js @@ -35,6 +35,26 @@ export const filterPosts = i18n => { ` } +export const profilePagePosts = i18n => { + const lang = i18n.locale().toUpperCase() + return gql` + ${postFragment(lang)} + ${postCountsFragment} + + query profilePagePosts( + $filter: _PostFilter + $first: Int + $offset: Int + $orderBy: [_PostOrdering] + ) { + profilePagePosts(filter: $filter, first: $first, offset: $offset, orderBy: $orderBy) { + ...post + ...postCounts + } + } + ` +} + export const PostsEmotionsByCurrentUser = () => { return gql` query PostsEmotionsByCurrentUser($postId: ID!) { diff --git a/webapp/locales/de.json b/webapp/locales/de.json index 188f9e67d..53de815a6 100644 --- a/webapp/locales/de.json +++ b/webapp/locales/de.json @@ -50,6 +50,18 @@ } } }, + "store": { + "posts": { + "orderBy": { + "newest": { + "label": "Neueste" + }, + "oldest": { + "label": "Γlteste" + } + } + } + }, "maintenance": { "title": "Human Connection befindet sich in der Wartung", "explanation": "Zurzeit fΓΌhren wir einige geplante Wartungsarbeiten durch, bitte versuch es spΓ€ter erneut.", @@ -95,10 +107,6 @@ "code-of-conduct": "Verhaltenscodex", "back-to-login": "ZurΓΌck zur Anmeldung" }, - "sorting": { - "newest": "Neueste", - "oldest": "Γlteste" - }, "login": { "copy": "Wenn Du bereits ein Konto bei Human Connection hast, melde Dich bitte hier an.", "login": "Einloggen", @@ -112,7 +120,8 @@ "moreInfoURL": "https://human-connection.org", "moreInfoHint": "zur PrΓ€sentationsseite", "hello": "Hallo", - "success": "Du bist eingeloggt!" + "success": "Du bist eingeloggt!", + "failure": "Fehlerhafte E-Mail-Adresse oder Passwort." }, "editor": { "placeholder": "Schreib etwas Inspirierendes β¦", @@ -354,6 +363,7 @@ }, "post": { "name": "Beitrag", + "pinned": "Meldung", "moreInfo": { "name": "Mehr Info", "title": "Mehr Informationen", @@ -367,7 +377,11 @@ }, "menu": { "edit": "Beitrag bearbeiten", - "delete": "Beitrag lΓΆschen" + "delete": "Beitrag lΓΆschen", + "pin": "Post festpinnen", + "pinnedSuccessfully": "Post erfolgreich festgepinnt!", + "unpin": "Post nicht mehr festpinnen", + "unpinnedSuccessfully": "Post erfolgreich nicht mehr festgepinnt!" }, "comment": { "submit": "Kommentiere", diff --git a/webapp/locales/en.json b/webapp/locales/en.json index d2927ee38..0e9f123d1 100644 --- a/webapp/locales/en.json +++ b/webapp/locales/en.json @@ -51,6 +51,18 @@ } } }, + "store": { + "posts": { + "orderBy": { + "newest": { + "label": "Newest" + }, + "oldest": { + "label": "Oldest" + } + } + } + }, "maintenance": { "title": "Human Connection is under maintenance", "explanation": "At the moment we are doing some scheduled maintenance, please try again later.", @@ -96,10 +108,6 @@ "code-of-conduct": "Code of Conduct", "back-to-login": "Back to login page" }, - "sorting": { - "newest": "Newest", - "oldest": "Oldest" - }, "login": { "copy": "If you already have a human-connection account, please login.", "login": "Login", @@ -113,7 +121,8 @@ "moreInfoURL": "https://human-connection.org/en/", "moreInfoHint": "to the presentation page", "hello": "Hello", - "success": "You are logged in!" + "success": "You are logged in!", + "failure": "Incorrect email address or password." }, "editor": { "placeholder": "Leave your inspirational thoughts β¦", @@ -355,6 +364,7 @@ }, "post": { "name": "Post", + "pinned": "Announcement", "moreInfo": { "name": "More info", "title": "More information", @@ -368,7 +378,11 @@ }, "menu": { "edit": "Edit Post", - "delete": "Delete Post" + "delete": "Delete Post", + "pin": "Pin post", + "pinnedSuccessfully": "Post pinned successfully!", + "unpin": "Unpin post", + "unpinnedSuccessfully": "Post unpinned successfully!" }, "comment": { "submit": "Comment", diff --git a/webapp/package.json b/webapp/package.json index 93f8068db..2710eee8a 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -12,7 +12,7 @@ "scripts": { "dev": "nuxt", "dev:styleguide": "cross-env STYLEGUIDE_DEV=true yarn run dev", - "storybook": "start-storybook -p 3002 -c storybook/", + "storybook": "start-storybook -p 3002 -s ./static -c storybook/", "build": "nuxt build", "start": "nuxt start", "generate:maintenance": "nuxt generate -c nuxt.config.maintenance.js", @@ -72,7 +72,7 @@ "jsonwebtoken": "~8.5.1", "linkify-it": "~2.2.0", "node-fetch": "^2.6.0", - "nuxt": "~2.10.1", + "nuxt": "~2.10.2", "nuxt-dropzone": "^1.0.4", "nuxt-env": "~0.1.0", "stack-utils": "^1.0.2", @@ -97,12 +97,12 @@ "@babel/preset-env": "~7.6.3", "@storybook/addon-a11y": "^5.2.4", "@storybook/addon-actions": "^5.2.4", - "@storybook/vue": "~5.2.4", + "@storybook/vue": "~5.2.5", "@vue/cli-shared-utils": "~4.0.4", "@vue/eslint-config-prettier": "~5.0.0", "@vue/server-test-utils": "~1.0.0-beta.29", "@vue/test-utils": "~1.0.0-beta.29", - "async-validator": "^3.1.0", + "async-validator": "^3.2.0", "babel-core": "~7.0.0-bridge.0", "babel-eslint": "~10.0.3", "babel-jest": "~24.9.0", @@ -121,6 +121,7 @@ "eslint-plugin-promise": "~4.2.1", "eslint-plugin-standard": "~4.0.1", "eslint-plugin-vue": "~5.2.3", + "faker": "^4.1.0", "flush-promises": "^1.0.2", "fuse.js": "^3.4.5", "identity-obj-proxy": "^3.0.0", diff --git a/webapp/pages/index.spec.js b/webapp/pages/index.spec.js index 5e38897c1..0433957bb 100644 --- a/webapp/pages/index.spec.js +++ b/webapp/pages/index.spec.js @@ -24,15 +24,37 @@ describe('PostIndex', () => { let Wrapper let store let mocks + let mutations beforeEach(() => { + mutations = { + 'posts/SELECT_ORDER': jest.fn(), + } store = new Vuex.Store({ getters: { - 'postsFilter/postsFilter': () => ({}), + 'posts/filter': () => ({}), + 'posts/orderOptions': () => () => [ + { + key: 'store.posts.orderBy.oldest.label', + label: 'store.posts.orderBy.oldest.label', + icon: 'sort-amount-asc', + value: 'createdAt_asc', + }, + { + key: 'store.posts.orderBy.newest.label', + label: 'store.posts.orderBy.newest.label', + icon: 'sort-amount-desc', + value: 'createdAt_desc', + }, + ], + 'posts/selectedOrder': () => () => 'createdAt_desc', + 'posts/orderIcon': () => 'sort-amount-desc', + 'posts/orderBy': () => 'createdAt_desc', 'auth/user': () => { return { id: 'u23' } }, }, + mutations, }) mocks = { $t: key => key, @@ -103,12 +125,12 @@ describe('PostIndex', () => { }) }) - it('sets the post in the store when there are posts', () => { + it('calls store when using order by menu', () => { wrapper .findAll('li') .at(0) .trigger('click') - expect(wrapper.vm.sorting).toEqual('createdAt_desc') + expect(mutations['posts/SELECT_ORDER']).toHaveBeenCalledWith({}, 'createdAt_asc') }) it('updates offset when a user clicks on the load more button', () => { diff --git a/webapp/pages/index.vue b/webapp/pages/index.vue index 91acb288c..74558184e 100644 --- a/webapp/pages/index.vue +++ b/webapp/pages/index.vue @@ -10,8 +10,7 @@ v-model="selected" :options="sortingOptions" size="large" - v-bind:icon-right="sortingIcon" - @input="toggleOnlySorting" + :icon-right="sortingIcon" > @@ -21,6 +20,8 @@ :post="post" :width="{ base: '100%', xs: '100%', md: '50%', xl: '33%' }" @removePostFromList="deletePost" + @pinPost="pinPost" + @unpinPost="unpinPost" /> @@ -58,12 +59,13 @@ - - diff --git a/webapp/pages/post/_id/_slug/index.spec.js b/webapp/pages/post/_id/_slug/index.spec.js index 290c3391d..b5827db8d 100644 --- a/webapp/pages/post/_id/_slug/index.spec.js +++ b/webapp/pages/post/_id/_slug/index.spec.js @@ -31,10 +31,10 @@ describe('PostSlug', () => { $filters: { truncate: a => a, }, - // If you are mocking the router, then don't use VueRouter with localVue: https://vue-test-utils.vuejs.org/guides/using-with-vue-router.html $route: { hash: '', }, + // If you are mocking the router, then don't use VueRouter with localVue: https://vue-test-utils.vuejs.org/guides/using-with-vue-router.html $router: { history: { push: jest.fn(), diff --git a/webapp/pages/post/_id/_slug/index.vue b/webapp/pages/post/_id/_slug/index.vue index 0cb26b62e..0b020076e 100644 --- a/webapp/pages/post/_id/_slug/index.vue +++ b/webapp/pages/post/_id/_slug/index.vue @@ -18,6 +18,8 @@ :resource="post" :modalsData="menuModalsData" :is-owner="isAuthor(post.author ? post.author.id : null)" + @pinPost="pinPost" + @unpinPost="unpinPost" /> @@ -68,9 +70,13 @@ - + - + @@ -88,6 +94,7 @@ import HcCommentList from '~/components/CommentList/CommentList' import { postMenuModalsData, deletePostMutation } from '~/components/utils/PostHelpers' import PostQuery from '~/graphql/PostQuery' import HcEmotions from '~/components/Emotions/Emotions' +import PostMutations from '~/graphql/PostMutations' export default { name: 'PostSlug', @@ -116,6 +123,7 @@ export default { post: null, ready: false, title: 'loading', + showNewCommentForm: true, } }, watch: { @@ -156,6 +164,31 @@ export default { async createComment(comment) { this.post.comments.push(comment) }, + pinPost(post) { + this.$apollo + .mutate({ + mutation: PostMutations().pinPost, + variables: { id: post.id }, + }) + .then(() => { + this.$toast.success(this.$t('post.menu.pinnedSuccessfully')) + }) + .catch(error => this.$toast.error(error.message)) + }, + unpinPost(post) { + this.$apollo + .mutate({ + mutation: PostMutations().unpinPost, + variables: { id: post.id }, + }) + .then(() => { + this.$toast.success(this.$t('post.menu.unpinnedSuccessfully')) + }) + .catch(error => this.$toast.error(error.message)) + }, + toggleNewCommentForm(showNewCommentForm) { + this.showNewCommentForm = showNewCommentForm + }, }, apollo: { Post: { diff --git a/webapp/pages/post/_id/_slug/more-info.vue b/webapp/pages/post/_id/_slug/more-info.vue index a27f85527..07b4969d3 100644 --- a/webapp/pages/post/_id/_slug/more-info.vue +++ b/webapp/pages/post/_id/_slug/more-info.vue @@ -37,7 +37,7 @@
+ {{ $t('quotes.african.quote') }} + - {{ $t('quotes.african.author') }} +
{{ $t('quotes.african.quote') }}