Compare commits

...
77 Commits
Author SHA1 Message Date
Wyatt Johnson 4dbcba2849 fix: fixed issue with directive resolver 2019-10-25 09:06:00 -06:00
Wyatt Johnson ac96e5e312 chore: bump version (#2668) 2019-10-23 11:59:39 -04:00
Wyatt Johnson e91da1c9a3 fix: check if auth params exist in hash before clearing it (#2666) 2019-10-22 17:25:47 -04:00
Wyatt Johnson e816f8e019 fix: adjusted copy, added translation support (#2664) 2019-10-22 13:33:15 -04:00
Wyatt Johnson 121469ade5 feat: readme simplication (#2649) 2019-10-22 17:09:49 +00:00
Cristian Dean 1d6e9e38b0 pt-BR translation some missing phrases (#2661) 2019-10-22 10:04:49 -04:00
Wyatt Johnson 626d46d813 [CORL-726] Repeat Comment Fix (#2655)
* fix: strip tags when comparing for repeat

- share the stripped tag version of the comment
- share the logger to improve pipeline clarity

* fix: renamed variables based on review
2019-10-21 18:34:41 +00:00
Wyatt Johnson 24915be712 fix: repaired translation key name (#2657) 2019-10-21 17:15:52 +00:00
Tessa Thornton 044cbd39f1 Revert "ensure tab text doesn't get cut off if tab titles are long (#2619)" (#2654)
This reverts commit 8409dbb4ea.
2019-10-18 20:58:14 +00:00
Wyatt Johnson cd6a1db85b chore: version bump (#2653) 2019-10-18 19:08:57 +00:00
Wyatt Johnson 8bee5ae1f3 [CORL-721] SSO Logout Fix (#2652)
* fix: hide logout button when jti is undefined

* fix: improved local state init
2019-10-18 18:48:46 +00:00
Cristian Dean 153c910de7 Fix pt-BR moderate-comment-deleted-body translation (#2647) 2019-10-18 12:44:41 -04:00
Wyatt Johnson 990a70d6f9 [CORL-680] Persist Fragment Access Tokens in sessionStorage (#2643)
* feat: persist access tokens passed via fragment to sessionStorage

* fix: review comments
2019-10-18 16:08:43 +00:00
Allan Assis 9aa39bf842 Fix pt-BR translations (#2645) 2019-10-17 15:22:05 -04:00
Wyatt Johnson d169d3dd51 fix: allow unknown fields (#2640) 2019-10-16 18:52:33 -04:00
Wyatt Johnson b38a4f117b [CORL-667] Constraint Directive (#2641)
* feat: added constraints

* fix: fixes bug with index creation

- fixes #2612

* fix: added default resolver

* fix: added defaultTo to handle defaulting
2019-10-16 18:44:44 -04:00
Tessa Thornton ea71230b90 [CORL_653] throw error if inviting existing users (#2637)
* throw error if inviting existing users

* check for existing users before sending out any invites

* fix lints
2019-10-16 16:28:49 -04:00
Vinh 2dac5f29c1 Typescript-eslint bug with new or renamed files in IDE (#2642)
* fix: workaround for eslint typescript bug when creating or renaming files.

* fix: downgrade

* fix: do not lint during build

* fix: no need to permanently show eslint status in vscode.
2019-10-17 02:48:48 +07:00
Tessa Thornton 9df473178b [CORL-8] prevent repeat comments (#2632)
* prevent users from reposting the same comment

* fix tests

* fix lints

* update comments for users service

* make updates based on review
2019-10-16 15:51:34 +00:00
Tessa Thornton 1f7fdb0d00 change keyboard shortcuts for approve/reject (#2635) 2019-10-15 23:04:31 +00:00
Vinh 3bfcc509d2 [CORL-678] Transition to eslint (#2634)
* chore: setup eslint

* chore: tslint checks with types & check for import order

* chore: complete eslint transition

* fix: tests

* fix: linting after rebase, faster lint for lint-staged

* chore: remove line

* fix: lint rules

* feat: add a11y linter and fix errors

* fix: tests
2019-10-15 22:56:38 +00:00
Cristian Dean b0e0ba6633 Remove canChangeStatus condition (#2631) 2019-10-15 22:28:16 +00:00
Cristian Dean 0fbfdac846 Add CHANGE_STORY_STATUS permission to Moderators (#2630) 2019-10-15 22:16:08 +00:00
Wyatt Johnson 8d08382aea feat: skip username validation for SSO users (#2627) 2019-10-15 21:56:47 +00:00
Tessa Thornton 8409dbb4ea ensure tab text doesn't get cut off if tab titles are long (#2619) 2019-10-15 20:49:37 +00:00
Sami Safatli bae88f476e Fix Typo (#2629) 2019-10-09 11:07:28 -04:00
Wyatt Johnson 918ba10867 [CORL-681] Configurable Staff Badges (#2620)
* feat: added configurable staff badge

* fix: removed ambigous styles
2019-10-04 20:22:31 +00:00
Wyatt Johnson e7ab0b27f9 fix: fixed looping effect (#2626) 2019-10-04 19:53:14 +00:00
Wyatt Johnson 191687335b [CORL-682] OIDC Email and setEmail mutation (#2623)
* fix: make email optional for oidc, fix schema perms

- fixes #2622

* fix: adjust defaults for heroku
2019-10-04 17:00:25 +00:00
Tessa Thornton 33487be0ce [CORL-469] keyboard shortcuts (#2588)
* make comments selectable in queue

* manage focus

* switching focus works

* keyboard navigation sort of works

* scroll into view

* prev, next, approve, reject

* shortcuts for queue switching

* ban modal?

* focus search bar with ctrl f

* keyboards shortcuts modal

* zen mode

* single comment view

* ban users from moderate cards

* clean up ts and lints

* update snaps

* add strings

* update smaps

* remove single comment view shortcut

* add single comment view to moderation queue

* clean up types

* fix deps for useEffect/useCallback hooks

* feat: added toggable help
2019-10-03 23:22:24 +00:00
Sami Safatli e56793f2fb Updated email templates (#2621) 2019-10-03 20:28:28 +00:00
Wyatt Johnson 6fe4646755 [CORL-668] User Comment Rate Limiting (#2616)
* feat: added user comment rate limiting

* fix: change 30 second to 3 second

* fix: moved file

* fix: services cleanup

* fix: changes based on review
2019-10-03 19:33:52 +00:00
Tessa Thornton dd45f46b19 update email templates (#2615) 2019-10-03 17:03:03 +00:00
Tessa Thornton 4a1492e88d [CORL-90] moderator notes (#2601)
* add moderator notes and mutations

* add create moderator note mutation

* add components for moderator notes

* style moderator notes

* update styles

* prevent warning about optimistic update unused fields

* fix incorrect comment

* fix optimistic response shape

* fix create note payload type

* add migration for moderator notes

* updates from feedback

* add border width for firefox
2019-10-02 18:41:53 +00:00
Wyatt Johnson 3a4eae87ad [CORL-661] Encode Story ID (#2614)
* fix: encode story ID's for URL

* fix: use shared method
2019-10-02 16:21:49 +00:00
Tessa Thornton fa3d442b36 [CORL-624] add note about word list case-sensitivity (#2607)
* add note about word list case-sensitivity:

* fix: removed unused keys

* fix: removed unused components

* fix: linting and key fix
2019-10-01 22:24:56 +00:00
Tessa Thornton 6edb411175 [CORL-650] remove duplicate text in invite form (#2608)
* remove duplicate text in invite form

* fix: reused message key

- message change consituted a grammar change
  that wasn't reflected in other translated languages
2019-10-01 20:54:50 +00:00
Tessa Thornton 6dbc724f31 [CORL-619] update search results copy (#2606)
* update search results copy

* fix: removed unused messages
2019-10-01 20:43:50 +00:00
Cristian Dean d0df6bc849 Add missing translations pt-BR (#2604) 2019-10-01 19:37:25 +00:00
Vinh 808b355a27 [CORL-181] Comment Count Injection (#2581)
* feat: inject comment counts

* fix: tests

* feat: integrate stream embed with coral counts

* chore: refactor constants

* test: test for count bundle

* test: test live comment count integration with stream embed

* feat: use defer

* fix: snapshot

* feat: auto add count.js in when calling Coral.createStreamEmbed

* fix: tests

* fix: rm duplicate test

* chore: remove unuse file
2019-10-01 19:22:15 +00:00
Wyatt Johnson 53fa5f43e5 [CORL-325] Comment Search API (#2578)
* feat: added comment search support

* fix: updated readme

* Update README.md
2019-10-01 18:57:50 +00:00
Wyatt Johnson c045f52daa [CORL-649] Migrations (#2597)
* feat: added migration framework

* chore: added premod user status migration

* feat: enhanced error handling of migrations

* fix: added missing argument from abstract method

* fix: another templating blunder

* fix: removed debug code

* feat: enhanced migration tracking

* fix: remove skipping migrations

* feat: moved indexing to migration system

* fix: linting
2019-10-01 16:00:27 +00:00
Cristian Dean b3b26bd9f3 Remove X-Powered-By header (#2605) 2019-10-01 15:35:32 +00:00
Wyatt Johnson 0af2fec31b fix: include tenantID in query operation to improve index use (#2596) 2019-09-30 16:25:55 +00:00
Wyatt Johnson 139c1eb01f fix: fixed readme to reflect correct docker image tag (#2593) 2019-09-30 16:15:31 +00:00
Tessa Thornton 941a385d53 [CORL-51] show comment revisions (#2590)
* add comment revisions

* reverse history order

* add strings

* update fixtures

* fix fixtures
2019-09-30 09:18:34 -04:00
Tessa Thornton e61e62a238 [CORL-174] premod user (#2572)
* add types for setting user premod

* add types, fields, and mutations for user premod

* enforce premoderation on users marked premod

* add user premod to dropdown

* update specs

* fix tests

* update premod workflow to match v4

* allow user filtering by premod status

* add premod user history to account actions drawer

* update snaps

* fix permissions on new premod fields

* update strings

* fix tests

* clean up formatting and copy

* fix: nullable fixes pre-migration
2019-09-25 21:31:12 +00:00
Tessa Thornton a5c3e94751 [CORL-544] use react helmet to add favicon declarations to head (#2595)
* use react helmet to add favicon declarations to head

* rename adminHead to head

* fix: case
2019-09-25 20:22:32 +00:00
Wyatt Johnson 5c1eb57d3e chore: cleaned up package.json (#2591) 2019-09-24 19:54:05 +00:00
Wyatt Johnson 7b5b260a18 chore: version bump (#2589) 2019-09-24 18:08:05 +00:00
Kim Gardner 1c24060f94 Add no comments copy in danish (#2586) 2019-09-24 17:57:31 +00:00
Vinh 4e4986fc03 fix: some form fields are not nullable (#2577) 2019-09-23 21:36:24 +00:00
Cristian Dean 37e39ff8c5 Avoid compute metrics with 404 status code (#2587) 2019-09-23 21:26:47 +00:00
Wyatt Johnson fe2d78f1f7 [CORl-640] User Registration Race (#2583)
*  fix: fixes user registration endpoints

- fixes #2579

* feat: cleanup from review

- Added seperate create function
- Moved some validation around

* fix: linting
2019-09-23 21:11:16 +00:00
Kim Gardner 43844bca18 Danish translation fixes (#2582)
* Update da files for email and errors

* Update da translations

* Add errors translations
2019-09-23 15:03:44 -04:00
Wyatt Johnson 6fb772944c feat: expanded allowed characters in usernames to support accents (#2585) 2019-09-23 14:55:38 -04:00
Tessa Thornton b3064c89de [CORL-623] add live updates for comments moving from premod to approved (#2571)
* add live updates for comments moving from premod to approved

* rename disposables

* check if comment is system withheld as well as premod to publish event
2019-09-20 20:11:05 +00:00
Nick Funk e4bfbbc1f8 Update COMMENT_DETECTED_SPAM marker text to Spam Detected (#2576)
Since this is used by Akismet and other spam detection tools, this
wording matches what Wordpress users will expect when seeing comments
detected as spam.

CORL-395
2019-09-20 19:48:00 +00:00
Wyatt Johnson 25c7f89b31 [CORL-620] Persisted Query Improvements (#2567)
* fix: resolves #2566

* fix: improved retry logic
2019-09-19 18:49:57 +00:00
Wyatt Johnson 921461008e [CORL-621] Auth Fixes (#2569)
* fix: resolve error with redirects

- fixes #2529

* fix: apply validations to username for oidc

* fix: converted components to function components

* fix: snapshots
2019-09-18 14:01:06 -04:00
Wyatt Johnson 64f102e6d4 [CORL-540] Logging improvements (#2565)
* fix: enhanced errors around story creation

* feat: enhanced child loggers

* feat: logging enhancements
2019-09-18 13:07:42 -04:00
Nick Funk 741739bc16 Un-feature a comment when it is rejected (#2555)
Also decrements the featured count for the story if rejected using the
in-stream moderation.

CORL-554
2019-09-18 12:17:31 -04:00
Sami Safatli 2cc32d4332 Translation pt-BR (#2542) 2019-09-18 11:47:34 -04:00
Tessa Thornton 867f0993cc [CORL 581] toggleable timestamp (#2561)
* toggle between relative and absolute timestamps

* make one common timestamp component

* remove unused timestamp component from /admin
2019-09-17 17:11:52 -04:00
Nick Funk 24a27ca958 Show the viewer's username in "moderated by" on moderation cards (#2573)
If a mod is looking at a card they themselves moderated, we want their
username to show up under "moderated by". This helps them recognize cards
they themselves moderated.

CORL-615
2019-09-17 10:59:03 -06:00
Nick Funk d03b180a30 Set createdAt in optimistic response during SuspendUserMutation steps (#2575)
CORL-616
2019-09-17 09:51:43 -06:00
Tessa Thornton 72ef65577b [CORL-605] remove user agent margin on safari search bar (#2559)
* remove user agent padding on safari search bar

* remove margin on other text fields too

* add margin reset to TextField
2019-09-13 20:29:01 +00:00
Tessa Thornton 8ef366f132 move toxic comment detection ahead of recent history in moderation pipeline (#2562) 2019-09-13 20:21:23 +00:00
Tessa Thornton 3114727410 [CORL-606] fix incorrect translation string (#2560)
* fix incorrect translation string

* update snap
2019-09-12 16:59:22 +00:00
Tessa Thornton a89dc4675a [CORL-516] forgot password flow in admin (#2558)
* add forgot password page and link to admin

* add translations

* update snaps

* fix: install patch

* fix: adapted localizations

* fix: added email type to email field

* fix: updated snapshots
2019-09-12 16:30:19 +00:00
Vinh 2559d1c0e8 fix: turn off bidi characters in favor of <bidi> tags (#2557) 2019-09-12 16:10:17 +00:00
Tessa Thornton ea3c1e3b58 fix safari user drawer display bugs (#2554) 2019-09-12 16:01:58 +00:00
Wyatt Johnson 457644c45b fix: fixed heroku install button (#2551) 2019-09-10 19:40:18 +00:00
Nick Funk 1b9a72c2f0 Show text field selection border on Firefox (#2550)
CORL-515
2019-09-10 19:28:03 +00:00
dependabot[bot] 47834a82bb chore(deps): bump lodash.merge from 4.6.1 to 4.6.2 (#2549)
Bumps [lodash.merge](https://github.com/lodash/lodash) from 4.6.1 to 4.6.2.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/commits)

Signed-off-by: dependabot[bot] <support@github.com>
2019-09-10 16:14:30 +00:00
dependabot[bot] a8976a2141 chore(deps): bump lodash.template from 4.4.0 to 4.5.0 (#2548)
Bumps [lodash.template](https://github.com/lodash/lodash) from 4.4.0 to 4.5.0.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.4.0...4.5.0)

Signed-off-by: dependabot[bot] <support@github.com>
2019-09-10 15:57:40 +00:00
dependabot[bot] 718bd2758f chore(deps-dev): bump lodash-es from 4.17.11 to 4.17.14 (#2547)
Bumps [lodash-es](https://github.com/lodash/lodash) from 4.17.11 to 4.17.14.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.11...4.17.14)

Signed-off-by: dependabot[bot] <support@github.com>
2019-09-10 15:46:26 +00:00
872 changed files with 14610 additions and 6928 deletions
+3
View File
@@ -0,0 +1,3 @@
*.d.ts
*.graphql.ts
**/__generated__/**
+208
View File
@@ -0,0 +1,208 @@
const typescriptEslintRecommended = require('@typescript-eslint/eslint-plugin/dist/configs/eslint-recommended').default.overrides[0];
const typescriptRecommended = require('@typescript-eslint/eslint-plugin/dist/configs/recommended.json');
const typescriptRecommendedTypeChecking = require('@typescript-eslint/eslint-plugin/dist/configs/recommended-requiring-type-checking.json');
const typescriptEslintPrettier = require('eslint-config-prettier/@typescript-eslint');
const react = require('eslint-plugin-react').configs.recommended;
const jsxA11y = require('eslint-plugin-jsx-a11y').configs.recommended;
const reactPrettier = require('eslint-config-prettier/react');
const typescriptOverrides = {
files: ["*.ts", "*.tsx"],
parser: "@typescript-eslint/parser",
parserOptions: {
sourceType: "module",
ecmaFeatures: {
jsx: true,
},
},
plugins: [
"@typescript-eslint",
"@typescript-eslint/tslint",
"react",
"jsx-a11y",
],
settings: {
react: {
version: "detect",
}
},
rules: Object.assign(
typescriptEslintRecommended.rules,
typescriptRecommended.rules,
typescriptEslintPrettier.rules,
react.rules,
jsxA11y.rules,
reactPrettier.rules,
{
"@typescript-eslint/adjacent-overload-signatures": "error",
// TODO: (cvle) change `readonly` param to `array-simple` when upgraded typescript.
"@typescript-eslint/array-type": ["error", { "default": "array-simple", "readonly": "generic"}],
"@typescript-eslint/ban-types": "error",
"@typescript-eslint/camelcase": "off",
"@typescript-eslint/consistent-type-assertions": "error",
"@typescript-eslint/consistent-type-definitions": "error",
"@typescript-eslint/class-name-casing": "error",
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/explicit-member-accessibility": [
"error",
{
"overrides": {
"constructors": "off",
},
},
],
"@typescript-eslint/indent": "off",
"@typescript-eslint/interface-name-prefix": "error",
"@typescript-eslint/member-delimiter-style": "off",
"@typescript-eslint/no-empty-function": "error",
"@typescript-eslint/no-empty-interface": "error",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-misused-new": "error",
"@typescript-eslint/no-namespace": "error",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-parameter-properties": "off",
"@typescript-eslint/no-unused-vars": ["error", {"args": "none", "ignoreRestSiblings": true}],
"@typescript-eslint/no-use-before-define": "off", // TODO: (cvle) Should be on?
"@typescript-eslint/no-use-before-declare": "off",
"@typescript-eslint/no-var-requires": "error",
"@typescript-eslint/prefer-for-of": "error",
"@typescript-eslint/prefer-function-type": "error",
"@typescript-eslint/prefer-namespace-keyword": "error",
"@typescript-eslint/triple-slash-reference": "error",
"@typescript-eslint/type-annotation-spacing": "off",
"@typescript-eslint/unified-signatures": "error",
// (cvle) disabled, because the way we use labels in our code cause to many
// false positives.
"jsx-a11y/label-has-associated-control": "off",
"react/display-name": "error",
"react/prop-types": "off",
"react/no-unescaped-entities": "off",
}
),
};
let typescriptTypeCheckingOverrides = {
files: ["*.ts", "*.tsx"],
parserOptions: {
project: ["tsconfig.json", "./src/tsconfig.json", "./src/core/client/tsconfig.json"],
// TODO: (cvle) this is a workaround, see: https://github.com/typescript-eslint/typescript-eslint/issues/1091.
createDefaultProgram: true,
},
rules: Object.assign(
typescriptRecommendedTypeChecking.rules,
{
"@typescript-eslint/tslint/config": ["error", {
"rules": {
"ordered-imports": {
"options": {
"import-sources-order": "case-insensitive",
"module-source-path": "full",
"named-imports-order": "case-insensitive",
},
},
},
}],
"@typescript-eslint/require-await": "off",
"@typescript-eslint/no-misused-promises": "off",
"@typescript-eslint/unbound-method": "off", // 10.10.19: (cvle) seems to give false positive.
}
),
};
const jestOverrides = {
env: {
jest: true,
},
files: ["test/**/*.ts", "test/**/*.tsx"],
globals: {
"expectAndFail": "readonly",
"fail": "readonly",
},
};
// Setup the overrides.
const overrides = [jestOverrides, typescriptOverrides];
// Skip type information to make it faster!
if (process.env.FAST_LINT !== "true") {
overrides.push(typescriptTypeCheckingOverrides);
}
module.exports = {
overrides,
env: {
browser: true,
es6: true,
node: true,
},
extends: [
"eslint:recommended",
"plugin:jsdoc/recommended",
"plugin:prettier/recommended",
],
parserOptions: {
"ecmaVersion": 2018,
},
rules: {
"arrow-body-style": "off",
"arrow-parens": [
"off",
"as-needed",
],
"camelcase": "off",
"complexity": "off",
"constructor-super": "error",
"spaced-comment": ["error", "always"],
"curly": "error",
"dot-notation": "error",
"eol-last": "off",
"eqeqeq": "error",
"guard-for-in": "error",
"jsdoc/require-jsdoc": "off",
"jsdoc/require-returns": "off",
"jsdoc/require-param": "off",
"jsdoc/require-param-type": "off",
"jsdoc/require-returns-type": "off",
"linebreak-style": "off",
"max-classes-per-file": [
"error",
1,
],
"member-ordering": "off",
"new-parens": "off",
"newline-per-chained-call": "off",
"no-bitwise": "error",
"no-caller": "error",
"no-cond-assign": "error",
"no-console": "error",
"no-debugger": "error",
"no-empty": "error",
"no-eval": "error",
"no-extra-semi": "off",
"no-fallthrough": "error",
"no-invalid-this": "off",
"no-irregular-whitespace": "off",
"no-multiple-empty-lines": "off",
"no-new-wrappers": "error",
"no-prototype-builtins": "off",
"no-shadow": "error",
"no-throw-literal": "error",
"no-undef": "off",
"no-undef-init": "error",
"no-unsafe-finally": "error",
"no-unused-expressions": "error",
"no-unused-labels": "error",
"no-unused-vars": ["error", {"args": "none", "ignoreRestSiblings": true}],
"no-var": "error",
"object-shorthand": "error",
"one-var": "off",
"prefer-arrow-callback": "off",
"prefer-const": "error",
"quote-props": "off",
"radix": "error",
"require-atomic-updates": "off",
"space-before-function-paren": "off",
"sort-imports": "off",
"use-isnan": "error",
"valid-typeof": "off",
},
};
+3 -2
View File
@@ -1,8 +1,9 @@
{
"recommendations": [
"ms-vscode.vscode-typescript-tslint-plugin",
"dbaeumer.vscode-eslint",
"kumar-harsh.graphql-for-vscode",
"editorconfig.editorconfig",
"ms-azuretools.vscode-cosmosdb"
"ms-azuretools.vscode-cosmosdb",
"mike-co.import-sorter"
]
}
+41 -5
View File
@@ -10,12 +10,48 @@
"**/.DS_Store": true,
".vs": true
},
"tslint.exclude": "**/node_modules/**",
"tslint.jsEnable": true,
"tslint.enable": false,
"eslint.validate": [
{ "language": "javascript", "autoFix": true },
{ "language": "typescript", "autoFix": true },
{ "language": "typescriptreact", "autoFix": true }
],
"typescript.tsdk": "node_modules/typescript/lib",
"postcss.validate": false,
"javascript.preferences.importModuleSpecifier": "non-relative",
"editor.codeActionsOnSave": {
"source.fixAll.tslint": true
}
"importSorter.importStringConfiguration.quoteMark": "double",
"importSorter.importStringConfiguration.maximumNumberOfImportExpressionsPerLine.count": 80,
"importSorter.sortConfiguration.customOrderingRules.rules": [
{
"type": "importMember",
"regex": "^$",
"orderLevel": 70,
"disableSort": true
},
{
"regex": "__generated__",
"orderLevel": 40
},
{
"regex": "^coral-",
"orderLevel": 30
},
{
"regex": "\\.css$",
"orderLevel": 60,
"disableSort": true
},
{
"regex": "^[.]",
"orderLevel": 50
},
],
"importSorter.importStringConfiguration.maximumNumberOfImportExpressionsPerLine.type": "newLineEachExpressionAfterCountLimitExceptIfOnlyOne",
"importSorter.importStringConfiguration.trailingComma": "multiLine",
"importSorter.importStringConfiguration.tabSize": 2,
"eslint.enable": true,
"importSorter.generalConfiguration.exclude": [
"d\\.ts$",
"__generated__"
],
}
+12 -626
View File
@@ -9,646 +9,32 @@ Built with <3 by The Coral Project, a part of [Vox Media](https://product.voxmed
Preview Coral easily by running Coral via a Heroku App:
[![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy?template=https://github.com/coralproject/talk/tree/next)
[![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy?template=https://github.com/coralproject/talk)
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
## Table of Contents
- [Requirements](#requirements)
- [Running](#running)
- [Docker](#docker)
- [Source](#source)
- [Embed On Your Site](#embed-on-your-site)
- [Single Sign On](#single-sign-on)
- [External Integrations](#external-integrations)
- [Login Prompts](#login-prompts)
- [Development](#development)
- [Email](#email)
- [Design Language System (UI Components)](#design-language-system-ui-components)
- [GraphQL API](#graphql-api)
- [Making your first request](#making-your-first-request)
- [Understanding the response](#understanding-the-response)
- [Authorizing a request](#authorizing-a-request)
- [Bearer Token](#bearer-token)
- [Cookie](#cookie)
- [Persisted Queries](#persisted-queries)
- [Configuration](#configuration)
- [Documentation](#documentation)
- [Pre-Launch Guide](#pre-launch-guide)
- [More Resources](#more-resources)
- [License](#license)
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
## Requirements
## Documentation
- MongoDB >=3.6
- Redis >=3.2
- NodeJS >=10
- NPM >=6.7
You can get started with Coral using our [Documentation](https://docs.coralproject.net/talk/).
## Running
## Pre-Launch Guide
You can install Coral using Docker or via Source. We recommend Docker, as it
provides the easiest deployment solution going forward, as all the dependencies
are baked and shipped with the provided
[coralproject/talk:next](https://hub.docker.com/r/coralproject/talk) image.
When v5 releases to master, you'll be able to select it using
`coralproject/talk:5`.
Youve installed Talk on your server, and youre preparing to launch it on your site. The real community work starts now, before you go live. You have a unique opportunity pre-launch to set your community up for success. Read our [Talk Community Guide](https://coralproject.net/blog/youve-installed-talk-now-what/).
### Docker
## More Resources
The easiest way to get started with Coral is through our published Docker image
and provided example `docker-compose.yml` file. The following assumes that you
have Docker and Docker Compose installed on your local machine:
- Install Docker: https://docs.docker.com/install/
- Install Docker Compose: https://docs.docker.com/compose/install/ (this is typically included in the Docker Desktop editions already)
```bash
# Create directories to persist the data in MongoDB and Redis.
mkdir -p data/{mongo,redis}
# Create the `docker-compose.yml` file to get started.
cat > docker-compose.yml <<EOF
version: "2"
services:
talk:
image: coralproject/talk:next
restart: always
ports:
- "127.0.0.1:3000:5000"
depends_on:
- mongo
- redis
environment:
- MONGODB_URI=mongodb://mongo:27017/coral
- REDIS_URI=redis://redis:6379
- SIGNING_SECRET=<replace me with something secret>
mongo:
image: mongo:3.6
volumes:
- ./data/mongo:/data/db
redis:
image: redis:3.2
volumes:
- ./data/redis:/data
EOF
# Start up Coral using Docker.
docker-compose up -d
```
Then head on over to http://localhost:3000 to install Coral!
### Source
Coral requires NodeJS >=10, we recommend using `nvm` to help manage node
versions: https://github.com/creationix/nvm.
```bash
# Clone and cd into the Coral directory.
git clone https://github.com/coralproject/talk.git
cd talk
# Install dependencies.
npm install
# Build the application dependencies.
# This might take a while.
npm run build
```
This should output all the compiled application code to `./dist`.
Running Coral with default settings assumes that you have:
- MongoDB >=3.6 running on `127.0.0.1:27017`
- Redis >=3.2 running on `127.0.0.1:6379`
If you don't already have these databases running, you can execute the following
assuming you have Docker installed on your local machine:
```bash
docker run -d -p 27017:27017 --restart always --name mongo mongo:3.6
docker run -d -p 6379:6379 --restart always --name redis redis:3.2
```
Then start Coral with:
```bash
# Start the server in production mode.
npm run start
```
Then head on over to http://localhost:3000 to install Coral!
### Embed On Your Site
With Coral setup and running locally you can test embeding the comment stream with this sample embed script:
```
<div id="coral_thread"></div>
<script type="text/javascript">
(function() {
var talk = document.createElement('script'); talk.type = 'text/javascript'; talk.async = true;
var url = '{{ CORAL_DOMAIN_NAME }}';
talk.src = '//' + url + '/assets/js/embed.js';
talk.onload = function() {
Coral.createStreamEmbed({
id: "coral_thread",
autoRender: true,
rootURL: '//' + url,
});
};
(document.getElementsByTagName('head')[0] || document.getElementsByTagName('body')[0]).appendChild(talk);
})();
</script>
```
> **NOTE:** Replace the value of `{{ CORAL_DOMAIN_NAME }}` with the location of your running instance of Coral.
### Single Sign On
In order to allow seamless connection to an existing authentication system,
Coral utilizes the industry standard [JWT Token](https://jwt.io/) to connect. To
learn more about how to create a JWT token, see [this introduction](https://jwt.io/introduction/).
1. Visit: `https://{{ CORAL_DOMAIN_NAME }}/admin/configure/auth`
2. Scroll to the `Login with Single Sign On` section
3. Enable the Single Sign On Authentication Integration
4. Enable `Allow Registration`
5. Copy the string in the `Key` box
6. Click Save
> **NOTE:** Replace the value of `{{ CORAL_DOMAIN_NAME }}` with the location of your running instance of Coral.
You will then have to generate a JWT with the following claims:
- `jti` (_optional_) - A unique ID for this particular JWT token. We recommend
using a [UUID](https://en.wikipedia.org/wiki/Universally_unique_identifier)
for this value. Without this parameter, the logout functionality inside the
embed stream will not work and you will need to call logout on the embed
itself.
- `exp` (_optional_) - When the given SSO token should expire. This is
specified as a unix time stamp in seconds. Once the token has expired, a new
token should be generated and passed into Coral. Without this parameter, the
logout functionality inside the embed stream will not work and you will need
to call logout on the embed itself.
- `iat` (_optional_) - When the given SSO token was issued. This is required to
utilize the automatic user detail update system. If this time is newer than
the time we received the last update, the contents of the token will be used
to update the user.
- `user.id` (**required**) - the ID of the user from your authentication system.
This is required to connect the user in your system to allow a seamless
connection to Coral.
- `user.email` (**required**) - the email address of the user from your
authentication system. This is required to facilitate notification email's
about status changes on a user account such as bans or suspensions.
- `user.username` (**required**) - the username that should be used when being
presented inside Coral to moderators and other users.
- `user.badges` (_optional_) - array of strings to be displayed as badges beside
username inside Coral, visible to other users and moderators. For example, to indicate
a user's subscription status.
- `user.role` (_optional_) - one of "COMMENTER", "STAFF", "MODERATOR", "ADMIN". Will create/update
Coral user with this role.
An example of the claims for this token would be:
```json
{
"jti": "151c19fc-ad15-4f80-a49c-09f137789fbb",
"exp": 1572172094,
"iat": 1562172094,
"user": {
"id": "628bdc61-6616-4add-bfec-dd79156715d4",
"email": "bob@example.com",
"username": "bob"
}
}
```
With the claims provided, you can sign them with the `Key` obtained from the
Coral administration panel in the previous steps with a `HS256` algorithm. This
token can be provided in the above mentioned embed code by adding it to the
`createStreamEmbed` function:
```js
Coral.createStreamEmbed({
// Don't forget to include the parameters from the
// "Embed On Your Site" section.
accessToken: "{{ SSO_TOKEN }}",
});
```
Or by calling the `login/logout` method on the embed object:
```js
var embed = Coral.createStreamEmbed({
// Don't forget to include the parameters from the
// "Embed On Your Site" section.
});
// Login the current embed with the generated SSO token.
embed.login("{{ SSO_TOKEN }}");
// Logout the user.
embed.logout();
```
#### External Integrations
You can integrate directly with the Coral GraphQL API in order to facilitate
account updates for your users when using Coral SSO. The relevant mutations are
as follows:
- `updateUserUsername` lets you update a given user with a new username using
an admin token.
- `updateUserEmail` lets you update a given user with a new email address
using an admin token.
- `deleteUser` lets you delete a given account using an admin token.
Note that even with an admin token, you may not delete yourself via
this method, and instead must use the `requestAccountDeletion`
mutation instead. This differs from the `requestAccountDeletion` as
it does the operation immediately instead of scheduling it as
`requestAccountDeletion` does.
- `requestUserCommentsDownload` lets you retrieve a given account's comments download. This mutation will provide you with a `archiveURL` that can be used to download a ZIP file containing the user's comment export.
If you're unsure on how to call GraphQL API's, refer to the section here on [Making your first GraphQL request](#making-your-first-request).
#### Login Prompts
In order to handle login prompts (e.g. a user clicks on the sign in button) you can listen to the `loginPrompt` event.
```js
var embed = Coral.createStreamEmbed({
// Don't forget to include the parameters from the
// "Embed On Your Site" section.
events: function(events) {
events.on("loginPrompt", function() {
// Redirect user to a login page.
location.href = "http://example.com/login";
});
},
});
```
### Development
Running Coral for development is very similar to installing Coral via Source as
described above.
Coral requires NodeJS >=10, we recommend using `nvm` to help manage node
versions: https://github.com/creationix/nvm.
```bash
# Clone and cd into the Coral directory.
git clone https://github.com/coralproject/talk.git
cd talk
# Install dependencies.
npm install
```
Running Coral with default settings assumes that you have:
- MongoDB >=3.6 running on `127.0.0.1:27017`
- Redis >=3.2 running on `127.0.0.1:6379`
If you don't already have these databases running, you can execute the following
assuming you have Docker installed on your local machine:
```bash
docker run -d -p 27017:27017 --restart always --name mongo mongo:3.6
docker run -d -p 6379:6379 --restart always --name redis redis:3.2
```
We recommend installing [watchman](https://facebook.github.io/watchman/docs/install.html) for better watch
performance.
```bash
# On macOS, you can run the following with Homebrew.
brew update
brew install watchman
```
Then start Coral with:
```bash
# Run the server in development mode in order to facilitate auto-restarting and
# rebuilding when file changes are detected. This might take a while to fully run.
npm run watch
```
When the client code has been built, navigate to http://localhost:8080/install
to start the installation wizard. **Note: Ensure `localhost:8080` is used in the permitted domains list.**
To see the comment stream goto http://localhost:8080/.
To run linting and tests use the following commands:
```bash
# Run the linters.
npm run lint
# Run our unit and integration tests.
npm run test
```
#### Email
To test out the email sending functionality, you can run [inbucket](https://www.inbucket.org/)
which provides a test SMTP server that can visualize emails in the browser:
```bash
docker run -d --name inbucket --restart always -p 2500:2500 -p 9000:9000 inbucket/inbucket
```
You can then configure the email server on Coral
by setting the email settings in
`Configure -> Email` in the admin:
| Field | Value |
| -------------- | -------------------- |
| From Address | `community@test.com` |
| Secure | `No` |
| Host | `localhost` |
| Port | `2500` |
| Authentication | `No` |
Navigate to http://localhost:9000, click the "Monitor" tab. New emails received
on this screen.
#### Design Language System (UI Components)
We use [docz](https://docz.site) to document and develop our Design Language System. To start docz run:
```bash
# Make sure CSS types are generated.
# This is not required when `npm run watch` is already running.
npm run generate:css-types
# Run docz in development.
npm run docz -- dev
```
After compilation has finished you can access docz at http://localhost:3030/.
## GraphQL API
Our API is generally served via GraphQL at `/api/graphql` on your Coral installation. If you're running Coral locally, this would be https://localhost:8080/api/graphql.
You can enable the GraphiQL interface at https://localhost:3000/graphiql (Note the port number here is not 8080, this is because this route is directly served by the server, and not the webpack development server) when running in development to access a GraphQL playground to use with documentation provided in the sidebar on what edges are available to you. You can do this by setting `ENABLE_GRAPHIQL=true`. **(🚨 Note 🚨) we do not recommend using this in production environments as it disables many safety features used by the application**.
### Making your first request
To learn a bit about how to interact with Coral, we'll query for comments on a
page of Coral.
The GraphQL endpoint we have can be used with any HTTP client available, but our
examples below will use the common `curl` tool:
```sh
curl --request POST \
--url "http://localhost:8080/api/graphql" \
--header "content-type: application/json" \
--data '{"query":"query GetComments($url: String!) {story(url: $url) { id metadata { title } url comments { nodes { id body author { id username } } } } }","variables":{"url":"http://localhost:8080/"},"operationName":"GetComments"}'
```
When you unpack that, it's really quite simple. We're executing a `POST` request
to the `/api/graphql` route of the local Talk server with the GraphQL
request we want to make. It's composed of the `query`, `variables`, and
`operationName`.
```graphql
query GetComments($url: String!) {
story(url: $url) {
metadata {
title
}
url
comments {
nodes {
body
author {
username
}
}
}
}
}
```
We are grabbing the asset with the specified `$url`, and grabbing it's title and the comments under it.
We can then also specify our variables to the query being executed (in this
case, the url for the page where we have comments on our local install of Coral):
```json
{
"url": "http://localhost:8080/"
}
```
It's also sometimes common to have multiple queries within a query, which is
where the `operationName` comes into play, where we simply specify the named
query that we want to execute (in this case, `GetComments`).
To get a deeper understanding of GraphQL queries, read up on
[GraphQL Queries and Mutations](http://graphql.org/learn/queries/).
### Understanding the response
Once you completed the above GraphQL query with `curl`, you'll get a response
sort of like this:
```json
{
"data": {
"story": {
"metadata": {
"title": "Coral 5.0 Embed Stream"
},
"url": "http://localhost:8080/",
"comments": {
"nodes": [
{
"body": "First comment!",
"author": {
"username": "wyatt.johnson"
}
}
]
}
}
}
}
```
All of the parameters you requested should be available under the `data`
property. Any errors that you get would appear in a `errors` array at the top
level, like this:
```json
{
"errors": [
{
"message": "The specified story URL does not exist in the permitted domains list.",
"locations": [
{
"line": 2,
"column": 3
}
],
"path": ["story"],
"extensions": {
"code": "STORY_URL_NOT_PERMITTED",
"id": "e255e860-d3ab-11e9-acdf-b9e9700f06fa",
"type": "INVALID_REQUEST_ERROR",
"message": "The specified story URL does not exist in the permitted domains list."
}
}
],
"data": {
"story": null
}
}
```
You should know that any property that is marked with a `!` is considered
required, and non-nullable, which means you can always guarantee on it being
there in your request if there were no errors.
### Authorizing a request
Some queries you may notice seem to return an error of
`USER_NOT_ENTITLED`. It's likely the case that you are making a request to a
route that requires authorization. You can perform authorization a few ways in
Talk.
Essentially, you need to get access to a JWT token that you can use to authorize
your requests.
```sh
curl --request POST \
--url http://localhost:3000/api/auth/local \
--header 'content-type: application/json' \
--data '{ "email": "${EMAIL}", "password": "${PASSWORD}"}'
```
Which returns a response similar to:
```json
{
"token": "${TOKEN}"
}
```
Where `${EMAIL}` is the email address of an admin user, and `${PASSWORD}` is the password for that admin user. This will generate a short term token (valid for 90 days). To generate a long lived access token (or Personal Access Token), you have to exchange the token generated above to create a new long lived token:
```sh
curl --request POST \
--url "http://localhost:3000/api/graphql" \
--header 'authorization: Bearer ${TOKEN}' \
--header 'content-type: application/json' \
--data '{"query":"mutation CreateAccessToken { createToken(input: { clientMutationId: \"\", name: \"My PAT\" }) { signedToken }}","operationName":"CreateAccessToken"}'
```
Which returns a response similar to:
```json
{
"data": {
"createToken": {
"signedToken": "${TOKEN}"
}
}
}
```
Where `${TOKEN}` in the request being from the previous set of login steps and returning a new `signedToken` as `${TOKEN}` which can be used instead of the previous `${TOKEN}` value in the below examples.
Once you have your access token, you can substitute it as `${TOKEN}` in your
`curl` request as follows:
#### Bearer Token
```sh
curl --request POST \
--url "http://localhost:8080/api/graphql" \
--header "content-type: application/json" \
--header "Authorization: Bearer ${TOKEN}" \
--data '{"query":"query GetComments($url: String!) {story(url: $url) { id metadata { title } url comments { nodes { id body author { id username } } } } }","variables":{"url":"http://localhost:8080/"},"operationName":"GetComments"}'
```
#### Cookie
```sh
curl --request POST \
--url "http://localhost:8080/api/graphql" \
--header "content-type: application/json" \
--cookie "authorization=${TOKEN}"
--data '{"query":"query GetComments($url: String!) {story(url: $url) { id metadata { title } url comments { nodes { id body author { id username } } } } }","variables":{"url":"http://localhost:8080/"},"operationName":"GetComments"}'
```
### Persisted Queries
You might see an error like `RAW_QUERY_NOT_AUTHORIZED`. This means that you attempted to use either no token, or a token from a user without admin privileges. In Coral, we whitelist the GraphQL mutations and queries that are performed by the applications for security reasons meaning that only admin users can make arbitrary queries against the GraphQL API.
## Configuration
The following environment variables can be set to configure the Coral Server. You
can expose them in your shell via `export NODE_ENV=development` or by placing
the variables in a `.env` file in the root of the project in a simple
`NODE_ENV=development` format delimited by newlines.
- `NODE_ENV` - Can be one of `production` or `development`. All production
deployments should use `production`. Defaults to `production` when ran with
`npm run start` and `development` when run with `npm run start:development`.
- `PORT` - The port to listen for HTTP and Websocket requests. (Default `3000`)
- `MONGODB_URI` - The MongoDB database URI to connect to.
(Default `mongodb://127.0.0.1:27017/coral`)
- `REDIS_URI` - The Redis database URI to connect to.
(Default `redis://127.0.0.1:6379`)
- `REDIS_OPTIONS` - A JSON string with optional configuration options to be used
when connecting to Redis as specified in the [ioredis](https://github.com/luin/ioredis/blob/1dac50a63753c2afc969315cfe38faf0edc50bc5/API.md#new_Redis_new) documentation.
(Default: `{}`)
- `SIGNING_SECRET` - The shared secret to use to sign JSON Web Tokens (JWT) with
the selected signing algorithm. 🚨 **Don't forget to set this variable!** 🚨
(Default: `keyboard cat`)
- `SIGNING_ALGORITHM` - The signing algorithm to use for signing JWT's.
(Default `HS256`).
- `LOGGING_LEVEL` - The logging level that can be set to one of `fatal`,
`error`, `warn`, `info`, `debug`, or `trace`. (Default `info`)
- `STATIC_URI` - The URI that static assets can be accessed from. This URI can
be to a proxy that uses this Coral server on `PORT` as the upstream. Disabled
by default.
- `DISABLE_TENANT_CACHING` - When `true`, all tenants will be loaded from the
database when needed rather than keeping a in-memory copy in sync via
published events on Redis. (Default `false`)
- `DISABLE_MONGODB_AUTOINDEXING` - When `true`, Coral will not perform indexing
operations when it starts up. This can be desired when you've already
installed Coral on the target MongoDB, but want to improve start performance.
**You should not use this parameter unless you know what you're doing! Upgrades
may introduce additional indexes that the application relies on.**
(Default `false`)
- `LOCALE` - Specify the default locale to use for all requests without a locale
specified. (Default `en-US`)
- `ENABLE_GRAPHIQL` - When `true`, it will enable the GraphiQL interface at `/graphiql`. **(🚨 Note 🚨) we do not recommend using this in production environments as it disables many safety features used by the application**. (Default `false`)
- `CONCURRENCY` - The number of worker nodes to spawn to handle web traffic,
this should be tied to the number of CPU's available. (Default
`os.cpus().length`)
- `DEV_PORT` - The port where the Webpack Development server is running on.
(Default `8080`)
- `METRICS_USERNAME` - The username for _Basic Authentication_ at the `/metrics` and `/cluster_metrics`
endpoint.
- `METRICS_PASSWORD` - The password for _Basic Authentication_ at the `/metrics` and `/cluster_metrics`
endpoint.
- `CLUSTER_METRICS_PORT` - If `CONCURRENCY` is more than `1`, the metrics are provided at this port under `/cluster_metrics`. (Default `3001`)
- `DISABLE_LIVE_UPDATES` - When `true`, disables subscriptions for the comment
stream for all stories across all tenants (Default `false`)
- `WEBSOCKET_KEEP_ALIVE_TIMEOUT` - A duration in a parsable format (e.g. `30 seconds`
, `1 minute`) that should be used to send keep alive messages through the
websocket to keep the socket alive (Default `30 seconds`)
- `TRUST_PROXY` - When provided, it configures the "trust proxy" settings for Express (See https://expressjs.com/en/guide/behind-proxies.html)
- [Our Blog](https://coralproject.net/blog)
- [Community Guides for Journalism](https://guides.coralproject.net/)
- [More About Us](https://coralproject.net/)
## License
+4
View File
@@ -21,6 +21,10 @@
"ENABLE_GRAPHIQL": {
"description": "When true, this will enable the GraphiQL routes",
"value": "false"
},
"TRUST_PROXY": {
"description": "When set to 1, it instructs Coral to trust up to one proxy, this is needed for authentication support",
"value": "1"
}
},
"addons": [
+1
View File
@@ -29,6 +29,7 @@ module.exports = {
"^coral-account/(.*)$": "<rootDir>/src/core/client/account/$1",
"^coral-admin/(.*)$": "<rootDir>/src/core/client/admin/$1",
"^coral-auth/(.*)$": "<rootDir>/src/core/client/auth/$1",
"^coral-count/(.*)$": "<rootDir>/src/core/client/count/$1",
"^coral-ui/(.*)$": "<rootDir>/src/core/client/ui/$1",
"^coral-stream/(.*)$": "<rootDir>/src/core/client/stream/$1",
"^coral-framework/(.*)$": "<rootDir>/src/core/client/framework/$1",
+900 -741
View File
File diff suppressed because it is too large Load Diff
+32 -27
View File
@@ -1,6 +1,6 @@
{
"name": "@coralproject/talk",
"version": "5.0.0",
"version": "5.2.2",
"author": "The Coral Project",
"homepage": "https://coralproject.net/",
"sideEffects": [
@@ -20,10 +20,11 @@
],
"description": "A better commenting experience from Mozilla, The Washington Post, and The New York Times.",
"scripts": {
"build": "NODE_ENV=production npm-run-all generate-persist --parallel lint:client build:client build:server",
"build:development": "NODE_ENV=development npm-run-all generate --parallel lint:client build:client build:server",
"build": "NODE_ENV=production npm-run-all generate-persist --parallel build:client build:server",
"build:development": "NODE_ENV=development npm-run-all generate --parallel build:client build:server",
"build:client": "ts-node --transpile-only ./scripts/build.ts",
"build:server": "gulp server",
"migration:create": "ts-node --transpile-only ./scripts/migration/create.ts",
"doctoc": "doctoc --title='## Table of Contents' --github README.md",
"generate": "npm-run-all generate:css-types generate:schema generate:relay",
"generate-persist": "npm-run-all generate:css-types generate:schema generate:relay-persist",
@@ -41,9 +42,9 @@
"start:development": "NODE_ENV=development CONCURRENCY=${CONCURRENCY:-1} TS_NODE_PROJECT=./src/tsconfig.json ts-node-dev --inspect --transpile-only --no-notify -r tsconfig-paths/register ./src/index.ts",
"start:webpackDevServer": "ts-node --transpile-only ./scripts/start.ts",
"lint": "npm-run-all --parallel lint:* tscheck:*",
"lint:server": "tslint --project ./src/tsconfig.json",
"lint:client": "tslint --project ./src/core/client/tsconfig.json",
"lint:scripts": "tslint --project ./tsconfig.json",
"lint:server": "eslint 'src/**/*.{js,ts,tsx}' --ignore-pattern 'src/core/client/**'",
"lint:client": "eslint 'src/core/client/**/*.{js,ts,tsx}'",
"lint:scripts": "eslint 'scripts/**/*.{js,ts,tsx}'",
"lint:graphql": "graphql-schema-linter src/core/server/graph/tenant/schema/schema.graphql",
"lint-fix": "npm run lint:server -- --fix && npm run lint:client -- --fix && npm run lint:scripts -- --fix",
"test": "node scripts/test.js --env=jsdom",
@@ -56,8 +57,6 @@
"license": "Apache-2.0",
"dependencies": {
"@coralproject/bunyan-prettystream": "^0.1.4",
"@types/archiver": "^3.0.0",
"@types/cron": "^1.7.1",
"akismet-api": "^4.2.0",
"apollo-server-express": "^2.8.1",
"archiver": "^3.0.3",
@@ -66,7 +65,6 @@
"bull": "^3.8.1",
"bunyan": "^1.8.12",
"cheerio": "^1.0.0-rc.2",
"common-tags": "^1.8.0",
"consolidate": "0.14.0",
"content-security-policy-builder": "^2.0.0",
"convict": "^4.3.1",
@@ -82,11 +80,8 @@
"env-rewrite": "^1.0.2",
"express": "^4.16.3",
"express-static-gzip": "^0.3.2",
"farce": "^0.2.6",
"fluent": "^0.10.0",
"fluent-dom": "^0.4.1",
"found": "^0.4.0-alpha.17",
"found-relay": "^0.4.0-alpha.8",
"fs-extra": "^6.0.1",
"graphql": "^0.13.2",
"graphql-config": "^2.0.1",
@@ -104,7 +99,7 @@
"jsonwebtoken": "^8.3.0",
"juice": "^5.2.0",
"jwks-rsa": "^1.3.0",
"linkify-it": "^2.1.0",
"keymaster": "^1.6.2",
"linkifyjs": "^2.1.8",
"lodash": "^4.17.15",
"lru-cache": "^5.1.1",
@@ -132,15 +127,14 @@
"prom-client": "^11.3.0",
"proxy-agent": "^3.1.0",
"querystringify": "^2.1.0",
"react-relay-network-modern": "^4.0.4",
"react-helmet": "^5.2.1",
"source-map-support": "^0.5.12",
"stack-utils": "^1.0.2",
"striptags": "^3.1.1",
"throng": "^4.0.0",
"tlds": "^1.203.1",
"tsscmp": "^1.0.6",
"url-regex": "^5.0.0",
"uuid": "^3.3.2",
"uuid": "^3.3.3",
"verror": "^1.10.0"
},
"devDependencies": {
@@ -155,6 +149,7 @@
"@coralproject/rte": "^0.10.15",
"@intervolga/optimize-cssnano-plugin": "^1.0.6",
"@types/agent-base": "^4.2.0",
"@types/archiver": "^3.0.0",
"@types/basic-auth": "^1.1.2",
"@types/bcryptjs": "^2.4.1",
"@types/bull": "^3.5.12",
@@ -188,7 +183,6 @@
"@types/joi": "^13.0.8",
"@types/jsdom": "^12.2.3",
"@types/jsonwebtoken": "^7.2.7",
"@types/linkify-it": "^2.1.0",
"@types/linkifyjs": "^2.1.1",
"@types/lodash": "^4.14.118",
"@types/lru-cache": "^5.1.0",
@@ -213,6 +207,7 @@
"@types/react": "^16.8.15",
"@types/react-copy-to-clipboard": "^4.2.6",
"@types/react-dom": "^16.8.4",
"@types/react-helmet": "^5.0.10",
"@types/react-relay": "^1.3.14",
"@types/react-responsive": "^3.0.1",
"@types/react-test-renderer": "^16.8.1",
@@ -226,7 +221,6 @@
"@types/source-map-support": "^0.5.0",
"@types/stack-utils": "^1.0.1",
"@types/throng": "^4.0.2",
"@types/tlds": "^1.199.0",
"@types/uuid": "^3.4.4",
"@types/verror": "^1.10.3",
"@types/vinyl": "^2.0.2",
@@ -235,6 +229,9 @@
"@types/webpack-bundle-analyzer": "^2.13.1",
"@types/webpack-dev-server": "^3.1.5",
"@types/ws": "^5.1.2",
"@typescript-eslint/eslint-plugin": "2.3.3",
"@typescript-eslint/eslint-plugin-tslint": "2.3.3",
"@typescript-eslint/parser": "2.3.3",
"acorn": "^6.1.1",
"ansi-styles": "^3.2.0",
"autoprefixer": "^9.5.1",
@@ -254,6 +251,7 @@
"classnames": "^2.2.6",
"commander": "^2.20.0",
"comment-json": "^1.1.3",
"common-tags": "^1.8.0",
"compression-webpack-plugin": "^2.0.0",
"copy-webpack-plugin": "^5.0.3",
"cross-spawn": "^6.0.5",
@@ -265,13 +263,22 @@
"enzyme": "^3.9.0",
"enzyme-adapter-react-16": "^1.12.1",
"enzyme-to-json": "^3.3.5",
"eslint": "^6.5.1",
"eslint-config-prettier": "^6.3.0",
"eslint-plugin-jsdoc": "^15.9.7",
"eslint-plugin-jsx-a11y": "^6.2.3",
"eslint-plugin-prettier": "^3.1.1",
"eslint-plugin-react": "^7.15.1",
"eventemitter2": "^5.0.1",
"farce": "^0.2.6",
"final-form": "4.11.0",
"flat": "^4.1.0",
"fluent-intl-polyfill": "^0.1.0",
"fluent-langneg": "^0.1.1",
"fluent-react": "^0.8.4",
"fork-ts-checker-webpack-plugin": "^1.3.0",
"fork-ts-checker-webpack-plugin": "^1.5.0",
"found": "^0.4.0-alpha.17",
"found-relay": "^0.4.0-alpha.8",
"graphql-schema-linter": "^0.2.0",
"graphql-schema-typescript": "^1.2.9",
"gulp": "^4.0.2",
@@ -288,7 +295,7 @@
"jest-mock-console": "^1.0.0",
"lint-staged": "^8.1.6",
"loader-utils": "^1.2.3",
"lodash-es": "^4.17.11",
"lodash-es": "^4.17.14",
"marked": "^0.7.0",
"material-design-icons": "^3.0.1",
"mini-css-extract-plugin": "^0.6.0",
@@ -304,7 +311,7 @@
"postcss-nested": "^4.1.1",
"postcss-prepend-imports": "^1.0.1",
"postcss-preset-env": "^6.5.0",
"prettier": "^1.17.0",
"prettier": "^1.18.2",
"prop-types": "^15.6.2",
"pstree.remy": "^1.1.6",
"pym.js": "^1.3.2",
@@ -317,6 +324,7 @@
"react-final-form": "4.0.2",
"react-popper": "^1.3.2",
"react-relay": "^4.0.0",
"react-relay-network-modern": "^4.0.4",
"react-responsive": "^7.0.0",
"react-test-renderer": "^16.9.0-alpha.0",
"react-timeago": "^4.1.9",
@@ -346,10 +354,7 @@
"ts-node-dev": "^1.0.0-pre.37",
"tsconfig-paths": "^3.8.0",
"tsconfig-paths-webpack-plugin": "^3.2.0",
"tslint": "^5.16.0",
"tslint-config-prettier": "^1.18.0",
"tslint-plugin-prettier": "^2.0.1",
"tslint-react": "^4.0.0",
"tslint": "^5.20.0",
"typed-css-modules": "^0.4.2",
"typeface-manuale": "^0.0.71",
"typeface-source-sans-pro": "^0.0.54",
@@ -365,12 +370,12 @@
},
"husky": {
"hooks": {
"pre-commit": "lint-staged"
"pre-commit": "FAST_LINT=true lint-staged"
}
},
"lint-staged": {
"*.{j,t}s{,x}": [
"tslint"
"eslint"
],
"src/core/server/graph/tenant/schema/schema.graphql": [
"graphql-schema-linter"
+2 -3
View File
@@ -12,7 +12,7 @@
*/
"use strict";
// tslint:disable:no-console
/* eslint-disable no-console */
const address = require("address");
const url = require("url");
@@ -21,7 +21,6 @@ const clearConsole = require("react-dev-utils/clearConsole");
const formatWebpackMessages = require("react-dev-utils/formatWebpackMessages");
const typescriptFormatter = require("react-dev-utils/typescriptFormatter");
const forkTsCheckerWebpackPlugin = require("react-dev-utils/ForkTsCheckerWebpackPlugin");
const Stats = require("webpack/lib/Stats");
// (cvle): Changed to false as we are sharing the tty with other processes.
// const isInteractive = process.stdout.isTTY && false;
@@ -142,7 +141,7 @@ function createCompiler({
});
let isFirstCompile = true;
let tsMessagesPromises = [];
const tsMessagesPromises = [];
if (useTypeScript) {
compiler.compilers.forEach(singleCompiler => {
+1 -1
View File
@@ -17,7 +17,7 @@ import paths from "../config/paths";
import config from "../src/core/build/config";
import createWebpackConfig from "../src/core/build/createWebpackConfig";
// tslint:disable: no-console
/* eslint-disable no-console */
process.env.WEBPACK = "true";
process.env.NODE_ENV = process.env.NODE_ENV || "production";
+6 -6
View File
@@ -19,29 +19,29 @@ program
.parse(process.argv);
if (!program.schema) {
// tslint:disable-next-line:no-console
// eslint-disable-next-line no-console
console.error("Schema identifier not provided");
process.exit(1);
}
if (!program.src) {
// tslint:disable-next-line:no-console
// eslint-disable-next-line no-console
console.error("Src not provided");
process.exit(1);
}
if (!config.projects) {
// tslint:disable-next-line:no-console
// eslint-disable-next-line no-console
console.error("Missing projects key in .graphqconfig");
process.exit(1);
}
if (!config.projects[program.schema]) {
// tslint:disable-next-line:no-console
// eslint-disable-next-line no-console
console.error(`Project ${program.schema} not found in .graphqconfig`);
process.exit(1);
}
if (!config.projects[program.schema].schemaPath) {
// tslint:disable-next-line:no-console
// eslint-disable-next-line no-console
console.error(
`SchemaPath for project ${program.schema} not found in .graphqconfig`
);
@@ -74,7 +74,7 @@ if (persist) {
if (fs.existsSync(persist)) {
// Create the new filename.
const name = path.basename(program.src);
const generated = "./src/core/server/graph/tenant/persisted/__generated__";
const generated = "./src/core/server/graph/common/persisted/__generated__";
// Create the generated directory if it doesn't exist.
fs.ensureDirSync(generated);
+2 -3
View File
@@ -1,4 +1,3 @@
const { Linter, Configuration } = require("tslint");
const { generateTSTypesAsString } = require("graphql-schema-typescript");
const { getGraphQLConfig } = require("graphql-config");
const path = require("path");
@@ -69,12 +68,12 @@ if (require.main === module) {
main()
.then(files => {
for (const { fileName } of files) {
// tslint:disable-next-line:no-console
// eslint-disable-next-line no-console
console.log(`Generated ${fileName}`);
}
})
.catch(err => {
// tslint:disable-next-line:no-console
// eslint-disable-next-line no-console
console.error(err);
});
}
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env ts-node
/**
* This script can be invoked via:
*
* npm run migration:create <migration name>
*
* To create new database migrations.
*/
/* eslint-disable no-console */
import fs from "fs-extra";
import lodash from "lodash";
import path from "path";
const templateFilePath = path.resolve(
path.join(
__dirname,
"../../src/core/server/services/migrate/migration_sample.ts"
)
);
const argv = process.argv.slice(2);
if (argv.length !== 1) {
console.error("usage: npm run migration:create <migration name>");
process.exit(1);
}
// Get the name of the new migration.
const name = lodash.snakeCase(argv[0]);
// Get the version of the new migration.
const version = Date.now();
// Get the filePath of the new migration.
const filePath = path.resolve(
path.join(
__dirname,
`../../src/core/server/services/migrate/migrations/${version}_${name}.ts`
)
);
if (fs.existsSync(filePath)) {
console.error(`migration already exists at: ${filePath}`);
process.exit(1);
}
// Write the template out to the file.
fs.copyFileSync(templateFilePath, filePath);
console.log(`created new migration at: ${filePath}`);
+1 -1
View File
@@ -14,7 +14,7 @@ import createDevServerConfig from "../config/webpackDevServer.config";
import config from "../src/core/build/config";
import createWebpackConfig from "../src/core/build/createWebpackConfig";
// tslint:disable: no-console
/* eslint-disable no-console */
// Enforce environment to be development.
config.validate().set("env", "development");
+1 -1
View File
@@ -24,7 +24,7 @@ process.on("unhandledRejection", err => {
const paths = require("../config/paths.ts").default;
const jest = require("jest");
let argv = process.argv.slice(2);
const argv = process.argv.slice(2);
argv.push("--config", paths.appJestConfig);
// Watch unless on CI or in coverage mode
+3 -3
View File
@@ -21,8 +21,8 @@ export default class CommandExecutor implements Executor {
private args?: ReadonlyArray<string>;
private spawnMultiple: boolean;
private runOnInit: boolean;
private isRunning: boolean = false;
private shouldRespawn: boolean = false;
private isRunning = false;
private shouldRespawn = false;
private spawnProcessDebounced?: (() => void) & Cancelable;
constructor(cmd: string, opts: CommandExecutorOptions = {}) {
@@ -67,7 +67,7 @@ export default class CommandExecutor implements Executor {
child.on("close", (code: number) => {
this.isRunning = false;
if (code !== 0 && code !== null) {
// tslint:disable-next-line: no-console
// eslint-disable-next-line no-console
console.log(chalk.red(`Command exited with ${code}`));
}
if (this.shouldRespawn) {
+4 -4
View File
@@ -17,8 +17,8 @@ export default class LongRunningExecutor implements Executor {
private cmd: string;
private args?: ReadonlyArray<string>;
private process: ChildProcess | null = null;
private isRunning: boolean = false;
private shouldRestart: boolean = false;
private isRunning = false;
private shouldRestart = false;
private restartDebounced: (() => void) & Cancelable;
constructor(cmd: string, opts: LongRunningExecutorOptions = {}) {
@@ -37,11 +37,11 @@ export default class LongRunningExecutor implements Executor {
shell: !this.args,
});
this.process!.on("exit", (code: number) => {
this.process.on("exit", (code: number) => {
this.isRunning = false;
if (code !== 0 && code !== null) {
// tslint:disable-next-line: no-console
// eslint-disable-next-line no-console
console.log(chalk.red(`Command exited with ${code}`));
return;
}
+2 -2
View File
@@ -18,7 +18,7 @@ function canUseWatchman(): boolean {
try {
execSync("watchman --version", { stdio: ["ignore"] });
return true;
// tslint:disable-next-line:no-empty
// eslint-disable-next-line no-empty
} catch (e) {}
return false;
}
@@ -34,7 +34,7 @@ export default class SaneWatcher implements Watcher {
// Autodetect watchman.
if (this.watchman === undefined && canUseWatchman()) {
this.watchman = true;
// tslint:disable-next-line:no-console
// eslint-disable-next-line no-console
console.log(chalk.grey(`Watchman detected`));
}
}
+2 -2
View File
@@ -14,7 +14,7 @@ async function run(
throw new Error("Config file not specified");
}
// tslint:disable-next-line:no-var-requires
// eslint-disable-next-line @typescript-eslint/no-var-requires
let config: any = require(path.resolve(configFile));
if (config.__esModule) {
config = config.default;
@@ -31,7 +31,7 @@ const cmd = program
.parse(process.argv);
run(cmd.args, cmd.opts()).catch(err => {
// tslint:disable-next-line:no-console
// eslint-disable-next-line no-console
console.error(err);
process.exit(1);
});
+4 -4
View File
@@ -21,7 +21,7 @@ async function beginWatch(
await executor.onInit();
}
for await (const filePath of watcher.watch(rootDir, paths, { ignore })) {
// tslint:disable-next-line:no-console
// eslint-disable-next-line no-console
console.log(chalk.cyanBright(`Execute "${key}"`));
executor.execute(filePath);
}
@@ -72,7 +72,7 @@ function filterOnly(
}
return pickBy(watchers, (value, key) => {
if (resolved.indexOf(key) === -1) {
// tslint:disable-next-line:no-console
// eslint-disable-next-line no-console
console.log(chalk.grey(`Disabled watcher "${key}"`));
return false;
}
@@ -98,11 +98,11 @@ export default async function watch(config: Config, options: Options = {}) {
}
for (const key of Object.keys(watchersConfigs)) {
// tslint:disable-next-line:no-console
// eslint-disable-next-line no-console
console.log(chalk.cyanBright(`Start watcher "${key}"`));
const watcherConfig = watchersConfigs[key];
beginWatch(watcher, key, watcherConfig, rootDir).catch(err => {
// tslint:disable-next-line:no-console
// eslint-disable-next-line no-console
console.error(err);
process.exit(1);
});
+1 -1
View File
@@ -7,7 +7,7 @@
"use strict";
// tslint:disable:no-console
/* eslint-disable */
// This alternative WebpackDevServer combines the functionality of:
// https://github.com/webpack/webpack-dev-server/blob/webpack-1/client/index.js
+41 -2
View File
@@ -530,9 +530,9 @@ export default function createWebpackConfig(
// TODO: (cvle) this should work in build too but for some reasons it terminates the build afterwards.
// Preventing from running post build steps.
...ifWatch(
// We run tslint in a separate process to have a quicker build.
// We run eslint in a separate process to have a quicker build.
new ForkTsCheckerWebpackPlugin({
tslint: true,
eslint: true,
typescript: require.resolve("typescript"),
async: true,
// TODO: (cvle) For some reason if incremental build is turned on it does not find lint errors during initial build.
@@ -752,5 +752,44 @@ export default function createWebpackConfig(
),
]),
},
/* Webpack config for count */
{
...baseConfig,
optimization: {
...baseConfig.optimization,
// Ensure that we never split the count into chunks.
splitChunks: {
chunks: "async",
},
// We can turn on sideEffects here as we don't use
// css here and don't run into: https://github.com/webpack/webpack/issues/7094
sideEffects: true,
},
entry: [paths.appCountIndex],
output: {
...baseConfig.output,
// don't hash the count, cache-busting must be completed by the requester
// as this lives in a static template on the embed site.
filename: "assets/js/count.js",
},
plugins: filterPlugins([
...baseConfig.plugins!,
...ifWatch(
// Generates an `embed.html` file with the <script> injected.
new HtmlWebpackPlugin({
filename: "count.html",
template: paths.appCountHTML,
inject: "body",
})
),
...ifBuild(
new WebpackAssetsManifest({
output: "count-asset-manifest.json",
entrypoints: true,
integrity: true,
})
),
]),
},
];
}
-1
View File
@@ -59,7 +59,6 @@ function generateTarget(target, context) {
defaultLocale,
fallbackLocale,
pathToLocales,
resourcePath,
locales,
bundled,
} = context;
+3
View File
@@ -34,6 +34,9 @@ export default {
appAuthCallbackHTML: resolveSrc("core/client/auth-callback/index.html"),
appAuthCallbackIndex: resolveSrc("core/client/auth-callback/index.ts"),
appCountHTML: resolveSrc("core/client/count/index.html"),
appCountIndex: resolveSrc("core/client/count/index.ts"),
appInstallHTML: resolveSrc("core/client/install/index.html"),
appInstallLocalesTemplate: resolveSrc("core/client/install/locales.ts"),
appInstallIndex: resolveSrc("core/client/install/index.tsx"),
-6
View File
@@ -23,12 +23,6 @@ const flatKebabVariables = mapKeys(
(_, k) => kebabCase(k)
);
// These are the default css standard variables.
const cssVariables = pickBy(
flatKebabVariables,
(v, k) => !k.startsWith("breakpoints-")
);
// These are sass style variables used in media queries.
const mediaQueryVariables = mapValues(
pickBy(flatKebabVariables, (v, k) => k.startsWith("breakpoints-")),
+12 -11
View File
@@ -1,10 +1,10 @@
import TransitionControl from "coral-framework/testHelpers/TransitionControl";
import { BrowserProtocol, queryMiddleware } from "farce";
import { createFarceRouter, ElementsRenderer } from "found";
import { Resolver } from "found-relay";
import React, { FunctionComponent } from "react";
import { CoralContextConsumer } from "coral-framework/lib/bootstrap/CoralContext";
import TransitionControl from "coral-framework/testHelpers/TransitionControl";
import routeConfig from "../routeConfig";
import NotFound from "../routes/NotFound";
@@ -16,16 +16,17 @@ const Router = createFarceRouter({
historyProtocol: new BrowserProtocol(),
historyMiddlewares: [queryMiddleware],
routeConfig,
renderReady: ({ elements }) => (
<>
<ElementsRenderer elements={elements} />
{// this enables router transition control when writing tests.
process.env.NODE_ENV === "test" && <TransitionControl />}
</>
),
renderError: ({ error }) => (
<div>{error.status === 404 ? <NotFound /> : "Error"}</div>
),
renderReady: function FarceRouterReady({ elements }) {
return (
<>
<ElementsRenderer elements={elements} />
{process.env.NODE_ENV === "test" && <TransitionControl />}
</>
);
},
renderError: function FarceRouterError({ error }) {
return <div>{error.status === 404 ? <NotFound /> : "Error"}</div>;
},
});
const EntryContainer: FunctionComponent = () => (
+2 -1
View File
@@ -1,7 +1,8 @@
import { createManaged } from "coral-framework/lib/bootstrap";
import React, { FunctionComponent } from "react";
import ReactDOM from "react-dom";
import { createManaged } from "coral-framework/lib/bootstrap";
import App from "./App";
import { initLocalState } from "./local";
import localesData from "./locales";
+1
View File
@@ -6,4 +6,5 @@
*/
import { LocalesData } from "coral-framework/lib/i18n";
export default {} as LocalesData;
+2 -1
View File
@@ -1,6 +1,7 @@
import { HorizontalGutter, Typography } from "coral-ui/components";
import React, { FunctionComponent } from "react";
import { HorizontalGutter, Typography } from "coral-ui/components";
const NotFound: FunctionComponent = () => (
<HorizontalGutter>
<Typography variant="heading3">Not Found</Typography>
@@ -65,9 +65,11 @@ const DownloadRoute: FunctionComponent<Props> = ({ token }) => {
};
const enhanced = withRouteConfig<Props>({
render: ({ match, Component }) => (
<Component token={parseHashQuery(match.location.hash).downloadToken} />
),
render: function DownloadRouteRender({ match, Component }) {
return (
<Component token={parseHashQuery(match.location.hash).downloadToken} />
);
},
})(DownloadRoute);
export default enhanced;
@@ -1,7 +1,7 @@
import { Localized } from "fluent-react/compat";
import React, { FunctionComponent } from "react";
import { CallOut, Flex, Icon } from "coral-ui/components";
import { Localized } from "fluent-react/compat";
import styles from "./Sorry.css";
@@ -69,9 +69,11 @@ const ConfirmRoute: React.FunctionComponent<Props> = ({ token }) => {
};
const enhanced = withRouteConfig<Props>({
render: ({ match, Component }) => (
<Component token={parseHashQuery(match.location.hash).confirmToken} />
),
render: function ConfirmRouteRender({ match, Component }) {
return (
<Component token={parseHashQuery(match.location.hash).confirmToken} />
);
},
})(ConfirmRoute);
export default enhanced;
@@ -69,9 +69,11 @@ const UnsubscribeRoute: React.FunctionComponent<Props> = ({ token }) => {
};
const enhanced = withRouteConfig<Props>({
render: ({ match, Component }) => (
<Component token={parseHashQuery(match.location.hash).unsubscribeToken} />
),
render: function UnsubscribeRouteRender({ match, Component }) {
return (
<Component token={parseHashQuery(match.location.hash).unsubscribeToken} />
);
},
})(UnsubscribeRoute);
export default enhanced;
@@ -69,9 +69,9 @@ const ResetRoute: React.FunctionComponent<Props> = ({ token }) => {
};
const enhanced = withRouteConfig<Props>({
render: ({ match, Component }) => (
<Component token={parseHashQuery(match.location.hash).resetToken} />
),
render: function ResetRouteRender({ match, Component }) {
return <Component token={parseHashQuery(match.location.hash).resetToken} />;
},
})(ResetRoute);
export default enhanced;
@@ -81,6 +81,7 @@ Make sure it is unique and be sure to keep it secure.
<div
className="PasswordField-icon"
onClick={[Function]}
onKeyUp={[Function]}
role="button"
tabIndex={0}
title="Hide password"
@@ -1,5 +1,7 @@
import sinon from "sinon";
import { ERROR_CODES } from "coral-common/errors";
import { InvalidRequestError } from "coral-framework/lib/errors";
import { GQLResolver } from "coral-framework/schema";
import {
act,
@@ -10,8 +12,6 @@ import {
within,
} from "coral-framework/testHelpers";
import { ERROR_CODES } from "coral-common/errors";
import { InvalidRequestError } from "coral-framework/lib/errors";
import create from "./create";
const token = createAccessToken();
@@ -1,5 +1,7 @@
import sinon from "sinon";
import { ERROR_CODES } from "coral-common/errors";
import { InvalidRequestError } from "coral-framework/lib/errors";
import { GQLResolver } from "coral-framework/schema";
import {
act,
@@ -10,8 +12,6 @@ import {
within,
} from "coral-framework/testHelpers";
import { ERROR_CODES } from "coral-common/errors";
import { InvalidRequestError } from "coral-framework/lib/errors";
import create from "./create";
const token = createAccessToken();
+12 -11
View File
@@ -1,10 +1,10 @@
import TransitionControl from "coral-framework/testHelpers/TransitionControl";
import { BrowserProtocol, queryMiddleware } from "farce";
import { createFarceRouter, ElementsRenderer } from "found";
import { Resolver } from "found-relay";
import React, { FunctionComponent } from "react";
import { CoralContextConsumer } from "coral-framework/lib/bootstrap/CoralContext";
import TransitionControl from "coral-framework/testHelpers/TransitionControl";
import routeConfig from "../routeConfig";
import NotFound from "../routes/NotFound";
@@ -13,16 +13,17 @@ const Router = createFarceRouter({
historyProtocol: new BrowserProtocol(),
historyMiddlewares: [queryMiddleware],
routeConfig,
renderReady: ({ elements }) => (
<>
<ElementsRenderer elements={elements} />
{// this enables router transition control when writing tests.
process.env.NODE_ENV === "test" && <TransitionControl />}
</>
),
renderError: ({ error }) => (
<div>{error.status === 404 ? <NotFound /> : "Error"}</div>
),
renderReady: function FarceRouterReady({ elements }) {
return (
<>
<ElementsRenderer elements={elements} />
{process.env.NODE_ENV === "test" && <TransitionControl />}
</>
);
},
renderError: function FarceRouterError({ error }) {
return <div>{error.status === 404 ? <NotFound /> : "Error"}</div>;
},
});
const App: FunctionComponent = () => (
@@ -1,6 +1,8 @@
import { Localized } from "fluent-react/compat";
import React, { FunctionComponent } from "react";
import { Typography } from "coral-ui/components";
import ApprovedIcon from "./ApprovedIcon";
import DecisionItem from "./DecisionItem";
import DotDivider from "./DotDivider";
@@ -9,8 +11,6 @@ import GoToCommentLink from "./GoToCommentLink";
import Info from "./Info";
import Timestamp from "./Timestamp";
import { Typography } from "coral-ui/components";
interface Props {
href: string;
username: string;
@@ -1,9 +1,10 @@
import React from "react";
import { graphql, RelayPaginationProp } from "react-relay";
import { withPaginationContainer } from "coral-framework/lib/relay";
import { DecisionHistoryContainer_viewer as ViewerData } from "coral-admin/__generated__/DecisionHistoryContainer_viewer.graphql";
import { DecisionHistoryContainerPaginationQueryVariables } from "coral-admin/__generated__/DecisionHistoryContainerPaginationQuery.graphql";
import { withPaginationContainer } from "coral-framework/lib/relay";
import DecisionHistory from "./DecisionHistory";
@@ -45,7 +46,7 @@ export class DecisionHistoryContainer extends React.Component<
error => {
this.setState({ disableLoadMore: false });
if (error) {
// tslint:disable-next-line:no-console
// eslint-disable-next-line no-console
console.error(error);
}
}
@@ -1,9 +1,10 @@
import React from "react";
import { graphql } from "react-relay";
import { DecisionHistoryItemContainer_action as ActionData } from "coral-admin/__generated__/DecisionHistoryItemContainer_action.graphql";
import { withFragmentContainer } from "coral-framework/lib/relay";
import { DecisionHistoryItemContainer_action as ActionData } from "coral-admin/__generated__/DecisionHistoryItemContainer_action.graphql";
import ApprovedComment from "./ApprovedComment";
import RejectedComment from "./RejectedComment";
@@ -16,9 +17,7 @@ class DecisionHistoryItemContainer extends React.Component<
DecisionHistoryItemContainerProps
> {
public render() {
const href = `/admin/moderate/comment/${
this.props.action.revision.comment.id
}`;
const href = `/admin/moderate/comment/${this.props.action.revision.comment.id}`;
const username =
(this.props.action.revision.comment.author &&
this.props.action.revision.comment.author.username) ||
@@ -1,6 +1,7 @@
import { graphql, QueryRenderer } from "coral-framework/lib/relay";
import React, { Component } from "react";
import { graphql, QueryRenderer } from "coral-framework/lib/relay";
import { DecisionHistoryQuery as QueryTypes } from "coral-admin/__generated__/DecisionHistoryQuery.graphql";
import DecisionHistoryContainer from "./DecisionHistoryContainer";
@@ -1,6 +1,8 @@
import { Localized } from "fluent-react/compat";
import React, { FunctionComponent } from "react";
import { Typography } from "coral-ui/components";
import DecisionItem from "./DecisionItem";
import DotDivider from "./DotDivider";
import Footer from "./Footer";
@@ -9,8 +11,6 @@ import Info from "./Info";
import RejectedIcon from "./RejectedIcon";
import Timestamp from "./Timestamp";
import { Typography } from "coral-ui/components";
interface Props {
href: string;
username: string;
+1 -1
View File
@@ -1,7 +1,7 @@
import { removeFragmentRefs } from "coral-framework/testHelpers";
import React from "react";
import { createRenderer } from "react-test-renderer/shallow";
import { removeFragmentRefs } from "coral-framework/testHelpers";
import { PropTypesOf } from "coral-framework/types";
import Main from "./Main";
+2 -2
View File
@@ -1,10 +1,10 @@
import React from "react";
import { MainRouteQueryResponse } from "coral-admin/__generated__/MainRouteQuery.graphql";
import { graphql } from "coral-framework/lib/relay";
import { withRouteConfig } from "coral-framework/lib/router";
import { MainRouteQueryResponse } from "coral-admin/__generated__/MainRouteQuery.graphql";
import Main from "./Main";
interface Props {
@@ -1,6 +1,5 @@
import React from "react";
import { NavigationContainer_viewer as ViewerData } from "coral-admin/__generated__/NavigationContainer_viewer.graphql";
import { Ability, can } from "coral-admin/permissions";
import { graphql, withFragmentContainer } from "coral-framework/lib/relay";
import {
@@ -8,6 +7,8 @@ import {
withSignOutMutation,
} from "coral-framework/mutations";
import { NavigationContainer_viewer as ViewerData } from "coral-admin/__generated__/NavigationContainer_viewer.graphql";
import Navigation from "./Navigation";
interface Props {
@@ -1,12 +1,13 @@
import React from "react";
import { UserMenuContainer_viewer as ViewerData } from "coral-admin/__generated__/UserMenuContainer_viewer.graphql";
import { graphql, withFragmentContainer } from "coral-framework/lib/relay";
import {
SignOutMutation,
withSignOutMutation,
} from "coral-framework/mutations";
import { UserMenuContainer_viewer as ViewerData } from "coral-admin/__generated__/UserMenuContainer_viewer.graphql";
import UserMenu from "./UserMenu";
interface Props {
+30
View File
@@ -0,0 +1,30 @@
import React, { FunctionComponent } from "react";
import { Helmet } from "react-helmet";
import favicon120 from "./assets/favicon120.png";
import favicon128 from "./assets/favicon128.png";
import favicon152 from "./assets/favicon152.png";
import favicon180 from "./assets/favicon180.png";
import favicon196 from "./assets/favicon196.png";
import favicon228 from "./assets/favicon228.png";
import favicon32 from "./assets/favicon32.png";
import favicon57 from "./assets/favicon57.png";
import favicon76 from "./assets/favicon76.png";
import favicon96 from "./assets/favicon96.png";
const Head: FunctionComponent = () => (
<Helmet>
<link rel="icon" href={favicon32} sizes="32x32" />
<link rel="icon" href={favicon57} sizes="57x57" />
<link rel="icon" href={favicon76} sizes="76x76" />
<link rel="icon" href={favicon96} sizes="96x96" />
<link rel="icon" href={favicon128} sizes="128x128" />
<link rel="icon" href={favicon228} sizes="228x228" />
<link rel="shortcut icon" sizes="196x196" href={favicon196} />
<link rel="apple-touch-icon" href={favicon120} sizes="120x120" />
<link rel="apple-touch-icon" href={favicon152} sizes="152x152" />
<link rel="apple-touch-icon" href={favicon180} sizes="180x180" />
</Helmet>
);
export default Head;
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

@@ -11,7 +11,7 @@ interface Props {
}
class AutoLoadMoresContainer extends React.Component<Props> {
public componentWillReceiveProps(nextProps: Props) {
public UNSAFE_componentWillReceiveProps(nextProps: Props) {
if (nextProps.inView && !nextProps.disableLoadMore) {
nextProps.onLoadMore();
}
@@ -1,10 +1,10 @@
import React from "react";
import { createRenderer } from "react-test-renderer/shallow";
import ApproveButton from "./ApproveButton";
import { PropTypesOf } from "coral-framework/types";
import ApproveButton from "./ApproveButton";
it("renders correctly", () => {
const props: PropTypesOf<typeof ApproveButton> = {
invert: false,
@@ -0,0 +1,53 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import { BanCommentUserMutation as MutationTypes } from "coral-admin/__generated__/BanCommentUserMutation.graphql";
import {
commitMutationPromiseNormalized,
createMutation,
MutationInput,
} from "coral-framework/lib/relay";
const clientMutationId = 0;
const BanCommentUserMutation = createMutation(
"banUser",
(environment: Environment, input: MutationInput<MutationTypes>) => {
return commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation BanCommentUserMutation($input: BanUserInput!) {
banUser(input: $input) {
user {
id
status {
current
}
}
clientMutationId
}
}
`,
variables: {
input: {
...input,
clientMutationId: clientMutationId.toString(),
},
},
updater: store => {
const user = store.get(input.userID);
if (user) {
const comments = user.getLinkedRecords("comments");
if (comments) {
comments.forEach(comment => {
if (comment) {
comment.setLinkedRecord(user, "author");
}
});
}
}
},
});
}
);
export default BanCommentUserMutation;
@@ -0,0 +1,3 @@
.authorStatus {
padding-right: var(--spacing-2);
}
@@ -0,0 +1,45 @@
import { Localized } from "fluent-react/compat";
import React, { FunctionComponent } from "react";
import { CommentAuthorContainer_comment as CommentData } from "coral-admin/__generated__/CommentAuthorContainer_comment.graphql";
import { graphql, withFragmentContainer } from "coral-framework/lib/relay";
import { Tag } from "coral-ui/components";
import styles from "./CommentAuthorContainer.css";
interface Props {
comment: CommentData;
}
const CommentAuthorContainer: FunctionComponent<Props> = ({ comment }) => {
if (!comment.author || !comment.author.status.ban.active) {
return null;
}
return (
<>
<Localized id="commentAuthor-status-banned">
<div className={styles.authorStatus}>
<Tag color="error">BANNED</Tag>
</div>
</Localized>
</>
);
};
const enhanced = withFragmentContainer<Props>({
comment: graphql`
fragment CommentAuthorContainer_comment on Comment {
author {
id
username
status {
ban {
active
}
}
}
}
`,
})(CommentAuthorContainer);
export default enhanced;
@@ -1,10 +1,10 @@
import React from "react";
import { createRenderer } from "react-test-renderer/shallow";
import CommentContent from "./CommentContent";
import { PropTypesOf } from "coral-framework/types";
import CommentContent from "./CommentContent";
it("renders correctly", () => {
const props: PropTypesOf<typeof CommentContent> = {
suspectWords: ["worse"],
@@ -0,0 +1,68 @@
import React, { FunctionComponent } from "react";
import { graphql, withFragmentContainer } from "coral-framework/lib/relay";
import { HorizontalGutter, Timestamp } from "coral-ui/components";
import { CommentRevisionContainer_comment as CommentData } from "coral-admin/__generated__/CommentRevisionContainer_comment.graphql";
import { CommentRevisionContainer_settings as SettingsData } from "coral-admin/__generated__/CommentRevisionContainer_settings.graphql";
import CommentContent from "./CommentContent";
interface Props {
comment: CommentData;
settings: SettingsData;
}
const CommentRevisionContainer: FunctionComponent<Props> = ({
settings,
comment,
}) => {
return (
<HorizontalGutter>
{comment.revisionHistory
.concat()
.reverse()
.filter(c =>
comment && comment.revision && comment.revision.id
? comment.revision.id !== c.id
: true
)
.map(c => (
<div key={c.id}>
<Timestamp>{c.createdAt}</Timestamp>
<CommentContent
suspectWords={settings.wordList.suspect}
bannedWords={settings.wordList.banned}
>
{c.body ? c.body : ""}
</CommentContent>
</div>
))}
</HorizontalGutter>
);
};
const enhanced = withFragmentContainer<Props>({
comment: graphql`
fragment CommentRevisionContainer_comment on Comment {
revision {
id
}
revisionHistory {
id
body
createdAt
}
}
`,
settings: graphql`
fragment CommentRevisionContainer_settings on Settings {
wordList {
banned
suspect
}
}
`,
})(CommentRevisionContainer);
export default enhanced;
@@ -1,7 +1,6 @@
import { graphql } from "react-relay";
import { ConnectionHandler, Environment } from "relay-runtime";
import { FeatureCommentMutation } from "coral-admin/__generated__/FeatureCommentMutation.graphql";
import { getQueueConnection } from "coral-admin/helpers";
import { CoralContext } from "coral-framework/lib/bootstrap";
import {
@@ -11,6 +10,8 @@ import {
} from "coral-framework/lib/relay";
import { GQLCOMMENT_STATUS, GQLTAG } from "coral-framework/schema";
import { FeatureCommentMutation } from "coral-admin/__generated__/FeatureCommentMutation.graphql";
let clientMutationId = 0;
const FeatureCommentMutation = createMutation(
@@ -2,14 +2,15 @@ import { Localized } from "fluent-react/compat";
import React, { FunctionComponent } from "react";
import { graphql } from "react-relay";
import { FlagDetailsContainer_comment } from "coral-admin/__generated__/FlagDetailsContainer_comment.graphql";
import { FlagDetailsContainer_settings } from "coral-admin/__generated__/FlagDetailsContainer_settings.graphql";
import NotAvailable from "coral-admin/components/NotAvailable";
import { TOXICITY_THRESHOLD_DEFAULT } from "coral-common/constants";
import { withFragmentContainer } from "coral-framework/lib/relay";
import { GQLCOMMENT_FLAG_REASON } from "coral-framework/schema";
import { HorizontalGutter } from "coral-ui/components";
import { FlagDetailsContainer_comment } from "coral-admin/__generated__/FlagDetailsContainer_comment.graphql";
import { FlagDetailsContainer_settings } from "coral-admin/__generated__/FlagDetailsContainer_settings.graphql";
import FlagDetailsCategory from "./FlagDetailsCategory";
import FlagDetailsEntry from "./FlagDetailsEntry";
import ToxicityLabel from "./ToxicityLabel";
@@ -1,6 +1,7 @@
import React, { FunctionComponent } from "react";
import { BaseButton } from "coral-ui/components";
import styles from "./FlagDetailsEntry.css";
interface Props {
@@ -16,5 +16,5 @@
}
.detailsDivider {
border: 1px solid var(--palette-grey-lightest);
border-color: var(--palette-grey-lightest);
}
@@ -13,6 +13,9 @@ it("renders all markers", () => {
const props: PropTypesOf<typeof MarkersContainerN> = {
comment: {
status: "PREMOD",
editing: {
edited: false,
},
revision: {
actionCounts: {
flag: {
@@ -25,6 +28,7 @@ it("renders all markers", () => {
COMMENT_DETECTED_SUSPECT_WORD: 1,
COMMENT_REPORTED_OFFENSIVE: 2,
COMMENT_REPORTED_SPAM: 3,
COMMENT_DETECTED_REPEAT_POST: 1,
},
},
},
@@ -53,6 +57,9 @@ it("renders some markers", () => {
const props: PropTypesOf<typeof MarkersContainerN> = {
comment: {
status: "PREMOD",
editing: {
edited: false,
},
revision: {
actionCounts: {
flag: {
@@ -65,6 +72,7 @@ it("renders some markers", () => {
COMMENT_DETECTED_SUSPECT_WORD: 0,
COMMENT_REPORTED_OFFENSIVE: 2,
COMMENT_REPORTED_SPAM: 0,
COMMENT_DETECTED_REPEAT_POST: 0,
},
},
},
@@ -2,12 +2,14 @@ import { Localized } from "fluent-react/compat";
import React from "react";
import { graphql } from "react-relay";
import { MarkersContainer_comment } from "coral-admin/__generated__/MarkersContainer_comment.graphql";
import { MarkersContainer_settings } from "coral-admin/__generated__/MarkersContainer_settings.graphql";
import { withFragmentContainer } from "coral-framework/lib/relay";
import { Marker, MarkerCount } from "coral-ui/components";
import FlagDetailsContainer from "./FlagDetailsContainer";
import { MarkersContainer_comment } from "coral-admin/__generated__/MarkersContainer_comment.graphql";
import { MarkersContainer_settings } from "coral-admin/__generated__/MarkersContainer_settings.graphql";
import Markers from "./Markers";
import ModerateCardDetailsContainer from "./ModerateCardDetailsContainer";
interface MarkersContainerProps {
comment: MarkersContainer_comment;
@@ -30,7 +32,7 @@ const markers: Array<
c =>
(c.status === "PREMOD" && (
<Localized id="moderate-marker-preMod" key={keyCounter++}>
<Marker color="primary">Pre-Mod</Marker>
<Marker color="primary">Pre-mod</Marker>
</Localized>
)) ||
null,
@@ -46,7 +48,7 @@ const markers: Array<
(c.revision &&
c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_BANNED_WORD && (
<Localized id="moderate-marker-bannedWord" key={keyCounter++}>
<Marker color="error">Banned Word</Marker>
<Marker color="error">Banned word</Marker>
</Localized>
)) ||
null,
@@ -55,7 +57,7 @@ const markers: Array<
c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_SUSPECT_WORD && (
<Localized id="moderate-marker-suspectWord" key={keyCounter++}>
<Marker color="error" variant="filled">
Suspect Word
Suspect word
</Marker>
</Localized>
)) ||
@@ -63,8 +65,8 @@ const markers: Array<
c =>
(c.revision &&
c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_SPAM && (
<Localized id="moderate-marker-spam" key={keyCounter++}>
<Marker color="error">Spam</Marker>
<Localized id="moderate-marker-spamDetected" key={keyCounter++}>
<Marker color="error">Spam detected</Marker>
</Localized>
)) ||
null,
@@ -76,11 +78,19 @@ const markers: Array<
</Localized>
)) ||
null,
c =>
(c.revision &&
c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_REPEAT_POST && (
<Localized id="moderate-marker-repeatPost" key={keyCounter++}>
<Marker color="error">Repeat comment</Marker>
</Localized>
)) ||
null,
c =>
(c.revision &&
c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_RECENT_HISTORY && (
<Localized id="moderate-marker-recentHistory" key={keyCounter++}>
<Marker color="error">Recent History</Marker>
<Marker color="error">Recent history</Marker>
</Localized>
)) ||
null,
@@ -123,8 +133,10 @@ export class MarkersContainer extends React.Component<MarkersContainerProps> {
return (
<Markers
details={
doesHaveDetails ? (
<FlagDetailsContainer
doesHaveDetails || this.props.comment.editing.edited ? (
<ModerateCardDetailsContainer
hasDetails={!!doesHaveDetails}
hasRevisions={this.props.comment.editing.edited}
onUsernameClick={this.props.onUsernameClick}
comment={this.props.comment}
settings={this.props.settings}
@@ -141,8 +153,11 @@ export class MarkersContainer extends React.Component<MarkersContainerProps> {
const enhanced = withFragmentContainer<MarkersContainerProps>({
comment: graphql`
fragment MarkersContainer_comment on Comment {
...FlagDetailsContainer_comment
...ModerateCardDetailsContainer_comment
status
editing {
edited
}
revision {
actionCounts {
flag {
@@ -155,6 +170,7 @@ const enhanced = withFragmentContainer<MarkersContainerProps>({
COMMENT_DETECTED_SUSPECT_WORD
COMMENT_REPORTED_OFFENSIVE
COMMENT_REPORTED_SPAM
COMMENT_DETECTED_REPEAT_POST
}
}
}
@@ -168,7 +184,7 @@ const enhanced = withFragmentContainer<MarkersContainerProps>({
`,
settings: graphql`
fragment MarkersContainer_settings on Settings {
...FlagDetailsContainer_settings
...ModerateCardDetailsContainer_settings
}
`,
})(MarkersContainer);
@@ -8,7 +8,7 @@
}
.username {
margin-right: var(--mini-unit);
margin-right: var(--spacing-1);
padding: var(--spacing-1);
margin-left: calc(-1 * var(--spacing-1));
line-height: calc(16rem / var(--rem-base));
@@ -89,6 +89,10 @@
transition: background 100ms, box-shadow 100ms;
}
.root:focus {
outline: none;
}
.dangling {
background-color: var(--palette-grey-lightest);
box-shadow: none;
@@ -156,3 +160,21 @@
.deleted {
background: var(--palette-grey-lightest);
}
.timestamp {
color: var(--palette-grey-lighter);
}
.edited {
color: var(--palette-grey-lighter);
padding-left: var(--spacing-2)
}
.selected {
box-shadow: 1px 4px 15px rgba(0, 0, 0, 0.25);
}
.authorStatus {
padding-right: var(--spacing-2);
}
@@ -14,9 +14,10 @@ const baseProps: PropTypesOf<typeof ModerateCardN> = {
username: "Theon",
createdAt: "2018-11-29T16:01:51.897Z",
body: "content",
edited: false,
inReplyTo: null,
comment: {},
settings: {},
comment: {},
status: "undecided",
featured: false,
viewContextHref: "http://localhost/comment",
@@ -25,7 +26,9 @@ const baseProps: PropTypesOf<typeof ModerateCardN> = {
onApprove: noop,
onReject: noop,
onFeature: noop,
onBan: noop,
onUsernameClick: noop,
onFocusOrClick: noop,
showStory: false,
moderatedBy: null,
};
@@ -1,7 +1,15 @@
import cn from "classnames";
import { Localized } from "fluent-react/compat";
import React, { FunctionComponent, useCallback } from "react";
import key from "keymaster";
import { noop } from "lodash";
import React, {
FunctionComponent,
useCallback,
useEffect,
useRef,
} from "react";
import { HOTKEYS } from "coral-admin/constants";
import { PropTypesOf } from "coral-framework/types";
import {
BaseButton,
@@ -9,16 +17,17 @@ import {
Flex,
HorizontalGutter,
TextLink,
Timestamp,
Typography,
} from "coral-ui/components";
import ApproveButton from "./ApproveButton";
import CommentAuthorContainer from "./CommentAuthorContainer";
import CommentContent from "./CommentContent";
import FeatureButton from "./FeatureButton";
import InReplyTo from "./InReplyTo";
import MarkersContainer from "./MarkersContainer";
import RejectButton from "./RejectButton";
import Timestamp from "./Timestamp";
import Username from "./Username";
import styles from "./ModerateCard.css";
@@ -32,7 +41,8 @@ interface Props {
id: string;
username: string | null;
} | null;
comment: PropTypesOf<typeof MarkersContainer>["comment"];
comment: PropTypesOf<typeof MarkersContainer>["comment"] &
PropTypesOf<typeof CommentAuthorContainer>["comment"];
settings: PropTypesOf<typeof MarkersContainer>["settings"];
status: "approved" | "rejected" | "undecided";
featured: boolean;
@@ -48,8 +58,10 @@ interface Props {
onReject: () => void;
onFeature: () => void;
onUsernameClick: (id?: string) => void;
onFocusOrClick: () => void;
mini?: boolean;
hideUsername?: boolean;
selected?: boolean;
/**
* If set to true, it means this comment is about to be removed
* from the queue. This will trigger some styling changes to
@@ -57,6 +69,10 @@ interface Props {
*/
dangling?: boolean;
deleted?: boolean;
edited: boolean;
selectPrev?: () => void;
selectNext?: () => void;
onBan: () => void;
}
const ModerateCard: FunctionComponent<Props> = ({
@@ -82,10 +98,52 @@ const ModerateCard: FunctionComponent<Props> = ({
storyHref,
onModerateStory,
moderatedBy,
selected,
onFocusOrClick,
mini = false,
hideUsername = false,
deleted = false,
edited,
selectNext,
selectPrev,
onBan,
}) => {
const div = useRef<HTMLDivElement>(null);
useEffect(() => {
if (selected) {
if (selectNext) {
key(HOTKEYS.NEXT, id, selectNext);
}
if (selectPrev) {
key(HOTKEYS.PREV, id, selectPrev);
}
if (onBan) {
key(HOTKEYS.BAN, id, onBan);
}
key(HOTKEYS.APPROVE, id, onApprove);
key(HOTKEYS.REJECT, id, onReject);
// The the scope such that only events attached to the ${id} scope will
// be honored.
key.setScope(id);
return () => {
// Remove all events that are set in the ${id} scope.
key.deleteScope(id);
};
} else {
// Remove all events that were set in the ${id} scope.
key.deleteScope(id);
}
return noop;
}, [selected, id]);
useEffect(() => {
if (selected && div && div.current) {
div.current.focus();
}
}, [selected]);
const commentBody = deleted ? (
<Localized id="moderate-comment-deleted-body">
<Typography>
@@ -110,9 +168,14 @@ const ModerateCard: FunctionComponent<Props> = ({
styles.root,
{ [styles.borderless]: mini },
{ [styles.dangling]: dangling },
{ [styles.deleted]: deleted }
{ [styles.deleted]: deleted },
{ [styles.selected]: selected }
)}
ref={div}
tabIndex={0}
data-testid={`moderate-comment-${id}`}
id={`moderate-comment-${id}`}
onClick={onFocusOrClick}
>
<Flex>
<div className={styles.mainContainer}>
@@ -130,7 +193,15 @@ const ModerateCard: FunctionComponent<Props> = ({
<Username>{username}</Username>
</BaseButton>
)}
<CommentAuthorContainer comment={comment} />
<Timestamp>{createdAt}</Timestamp>
{edited && (
<Localized id="moderate-comment-edited">
<Typography variant="timestamp" className={styles.edited}>
(edited)
</Typography>
</Localized>
)}
<FeatureButton
featured={featured}
onClick={onFeature}
@@ -1,25 +1,31 @@
import { Match, Router, withRouter } from "found";
import React, { FunctionComponent, useCallback } from "react";
import React, { FunctionComponent, useCallback, useState } from "react";
import { graphql } from "react-relay";
import NotAvailable from "coral-admin/components/NotAvailable";
import BanModal from "coral-admin/components/UserStatus/BanModal";
import { getModerationLink } from "coral-admin/helpers";
import {
ApproveCommentMutation,
RejectCommentMutation,
} from "coral-admin/mutations";
import FadeInTransition from "coral-framework/components/FadeInTransition";
import {
MutationProp,
withFragmentContainer,
withMutation,
} from "coral-framework/lib/relay";
import { GQLUSER_STATUS } from "coral-framework/schema";
import { GQLTAG } from "coral-framework/schema";
import {
COMMENT_STATUS,
ModerateCardContainer_comment,
} from "coral-admin/__generated__/ModerateCardContainer_comment.graphql";
import { ModerateCardContainer_settings } from "coral-admin/__generated__/ModerateCardContainer_settings.graphql";
import { ModerateCardContainer_viewer } from "coral-admin/__generated__/ModerateCardContainer_viewer.graphql";
import NotAvailable from "coral-admin/components/NotAvailable";
import { getModerationLink } from "coral-admin/helpers";
import { ApproveCommentMutation } from "coral-admin/mutations";
import { RejectCommentMutation } from "coral-admin/mutations";
import FadeInTransition from "coral-framework/components/FadeInTransition";
import {
MutationProp,
withFragmentContainer,
withMutation,
} from "coral-framework/lib/relay";
import { GQLTAG } from "coral-framework/schema";
import BanCommentUserMutation from "./BanCommentUserMutation";
import FeatureCommentMutation from "./FeatureCommentMutation";
import ModerateCard from "./ModerateCard";
import ModeratedByContainer from "./ModeratedByContainer";
@@ -33,6 +39,7 @@ interface Props {
rejectComment: MutationProp<typeof RejectCommentMutation>;
featureComment: MutationProp<typeof FeatureCommentMutation>;
unfeatureComment: MutationProp<typeof UnfeatureCommentMutation>;
banUser: MutationProp<typeof BanCommentUserMutation>;
danglingLogic: (status: COMMENT_STATUS) => boolean;
match: Match;
router: Router;
@@ -40,6 +47,11 @@ interface Props {
mini?: boolean;
hideUsername?: boolean;
onUsernameClicked?: (userID: string) => void;
onSetSelected?: () => void;
selected?: boolean;
selectPrev?: () => void;
selectNext?: () => void;
loadNext?: (() => void) | null;
}
function getStatus(comment: ModerateCardContainer_comment) {
@@ -71,30 +83,43 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
unfeatureComment,
mini,
hideUsername,
selected,
selectPrev,
selectNext,
onUsernameClicked: usernameClicked,
onSetSelected: setSelected,
banUser,
loadNext,
}) => {
const handleApprove = useCallback(() => {
const [showBanModal, setShowBanModal] = useState(false);
const handleApprove = useCallback(async () => {
if (!comment.revision) {
return;
}
approveComment({
await approveComment({
commentID: comment.id,
commentRevisionID: comment.revision.id,
storyID: match.params.storyID,
});
if (loadNext) {
loadNext();
}
}, [approveComment, comment, match]);
const handleReject = useCallback(() => {
const handleReject = useCallback(async () => {
if (!comment.revision) {
return;
}
rejectComment({
await rejectComment({
commentID: comment.id,
commentRevisionID: comment.revision.id,
storyID: match.params.storyID,
});
if (loadNext) {
loadNext();
}
}, [rejectComment, comment, match]);
const handleFeature = useCallback(() => {
@@ -146,6 +171,35 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
[router, comment]
);
const onFocusOrClick = useCallback(() => {
if (setSelected) {
setSelected();
}
}, [selected, comment]);
const handleBanModalClose = useCallback(() => {
setShowBanModal(false);
}, []);
const openBanModal = useCallback(() => {
if (
!comment.author ||
comment.author.status.current.includes(GQLUSER_STATUS.BANNED)
) {
return;
}
setShowBanModal(true);
}, [comment]);
const handleBanConfirm = useCallback(
async (message: string) => {
if (comment.author) {
await banUser({ userID: comment.author.id, message });
}
setShowBanModal(false);
},
[comment]
);
return (
<>
<FadeInTransition active={Boolean(comment.enteredLive)}>
@@ -171,6 +225,10 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
onReject={handleReject}
onFeature={onFeature}
onUsernameClick={onUsernameClicked}
selected={selected}
selectPrev={selectPrev}
selectNext={selectNext}
onBan={openBanModal}
moderatedBy={
<ModeratedByContainer
onUsernameClicked={onUsernameClicked}
@@ -178,6 +236,7 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
comment={comment}
/>
}
onFocusOrClick={onFocusOrClick}
showStory={showStoryInfo}
storyTitle={
(comment.story.metadata && comment.story.metadata.title) || (
@@ -189,8 +248,19 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
mini={mini}
hideUsername={hideUsername}
deleted={comment.deleted ? comment.deleted : false}
edited={comment.editing.edited}
/>
</FadeInTransition>
<BanModal
username={
comment.author && comment.author.username
? comment.author.username
: ""
}
open={showBanModal}
onClose={handleBanModalClose}
onConfirm={handleBanConfirm}
/>
</>
);
};
@@ -202,6 +272,9 @@ const enhanced = withFragmentContainer<Props>({
author {
id
username
status {
current
}
}
statusLiveUpdated
createdAt
@@ -213,6 +286,9 @@ const enhanced = withFragmentContainer<Props>({
revision {
id
}
editing {
edited
}
parent {
author {
id
@@ -230,6 +306,7 @@ const enhanced = withFragmentContainer<Props>({
deleted
...MarkersContainer_comment
...ModeratedByContainer_comment
...CommentAuthorContainer_comment
}
`,
settings: graphql`
@@ -248,10 +325,12 @@ const enhanced = withFragmentContainer<Props>({
`,
})(
withRouter(
withMutation(ApproveCommentMutation)(
withMutation(RejectCommentMutation)(
withMutation(FeatureCommentMutation)(
withMutation(UnfeatureCommentMutation)(ModerateCardContainer)
withMutation(BanCommentUserMutation)(
withMutation(ApproveCommentMutation)(
withMutation(RejectCommentMutation)(
withMutation(FeatureCommentMutation)(
withMutation(UnfeatureCommentMutation)(ModerateCardContainer)
)
)
)
)
@@ -0,0 +1,4 @@
.button {
text-transform: uppercase;
font-size: 14px;
}
@@ -0,0 +1,92 @@
import { Localized } from "fluent-react/compat";
import React, { FunctionComponent, useState } from "react";
import { graphql } from "react-relay";
import { withFragmentContainer } from "coral-framework/lib/relay";
import { Flex, HorizontalGutter, Icon, Tab, TabBar } from "coral-ui/components";
import { ModerateCardDetailsContainer_comment as CommentData } from "coral-admin/__generated__/ModerateCardDetailsContainer_comment.graphql";
import { ModerateCardDetailsContainer_settings as SettingsData } from "coral-admin/__generated__/ModerateCardDetailsContainer_settings.graphql";
import CommentRevisionContainer from "./CommentRevisionContainer";
import FlagDetailsContainer from "./FlagDetailsContainer";
import styles from "./ModerateCardDetailsContainer.css";
interface Props {
comment: CommentData;
settings: SettingsData;
onUsernameClick: (id?: string) => void;
hasDetails: boolean;
hasRevisions: boolean;
}
const ModerateCardDetailsContainer: FunctionComponent<Props> = ({
comment,
onUsernameClick,
settings,
hasDetails,
hasRevisions,
}) => {
const [activeTab, setActiveTab] = useState<"DETAILS" | "HISTORY">(
hasDetails ? "DETAILS" : "HISTORY"
);
return (
<HorizontalGutter>
<TabBar
variant="secondary"
activeTab={activeTab}
onTabClick={id => setActiveTab(id as "DETAILS" | "HISTORY")}
>
{hasDetails && (
<Tab tabID="DETAILS" classes={styles}>
<Flex alignItems="center" itemGutter>
<Icon size="md">list</Icon>
<Localized id="moderateCardDetails-tab-details">
<span>Details</span>
</Localized>
</Flex>
</Tab>
)}
{hasRevisions && (
<Tab tabID="HISTORY" classes={styles}>
<Flex alignItems="center" itemGutter>
<Icon>edit</Icon>
<Localized id="moderateCardDetails-tab-edits">
<span>Edit history</span>
</Localized>
</Flex>
</Tab>
)}
</TabBar>
{activeTab === "DETAILS" && (
<FlagDetailsContainer
comment={comment}
settings={settings}
onUsernameClick={onUsernameClick}
/>
)}
{activeTab === "HISTORY" && (
<CommentRevisionContainer comment={comment} settings={settings} />
)}
</HorizontalGutter>
);
};
const enhanced = withFragmentContainer<Props>({
comment: graphql`
fragment ModerateCardDetailsContainer_comment on Comment {
...FlagDetailsContainer_comment
...CommentRevisionContainer_comment
}
`,
settings: graphql`
fragment ModerateCardDetailsContainer_settings on Settings {
...FlagDetailsContainer_settings
...CommentRevisionContainer_settings
}
`,
})(ModerateCardDetailsContainer);
export default enhanced;
@@ -0,0 +1,39 @@
import React from "react";
import { createRenderer } from "react-test-renderer/shallow";
import { removeFragmentRefs } from "coral-framework/testHelpers";
import { PropTypesOf } from "coral-framework/types";
import ModeratedByContainer from "./ModeratedByContainer";
const ModeratedByContainerN = removeFragmentRefs(ModeratedByContainer);
it("viewer's username shows on moderation cards moderated by viewer", () => {
const props: PropTypesOf<typeof ModeratedByContainerN> = {
comment: {
id: "comment-id",
statusLiveUpdated: false,
statusHistory: {
edges: [
{
moderator: {
id: "viewer",
username: "viewer",
},
},
],
},
},
viewer: {
id: "viewer",
username: "viewer",
},
onUsernameClicked: (id?: string | null) => {
return;
},
};
const renderer = createRenderer();
renderer.render(<ModeratedByContainerN {...props} />);
expect(renderer.getRenderOutput()).toMatchSnapshot();
});
@@ -2,11 +2,12 @@ import { Localized } from "fluent-react/compat";
import React, { useCallback } from "react";
import { graphql } from "react-relay";
import { ModeratedByContainer_comment } from "coral-admin/__generated__/ModeratedByContainer_comment.graphql";
import { ModeratedByContainer_viewer } from "coral-admin/__generated__/ModeratedByContainer_viewer.graphql";
import { withFragmentContainer } from "coral-framework/lib/relay";
import { BaseButton } from "coral-ui/components";
import { ModeratedByContainer_comment } from "coral-admin/__generated__/ModeratedByContainer_comment.graphql";
import { ModeratedByContainer_viewer } from "coral-admin/__generated__/ModeratedByContainer_viewer.graphql";
import styles from "./ModeratedByContainer.css";
interface Props {
@@ -29,7 +30,7 @@ const ModeratedByContainer: React.FunctionComponent<Props> = ({
<Localized id="moderate-comment-moderatedBySystem">System</Localized>
);
} else if (viewer.id === comment.statusHistory.edges[0].node.moderator.id) {
moderatedBy = null;
moderatedBy = viewer.username ? <>{viewer.username}</> : null;
} else {
moderatedBy = <>{comment.statusHistory.edges[0].node.moderator.username}</>;
id = comment.statusHistory.edges[0].node.moderator.id;
@@ -73,6 +74,7 @@ const enhanced = withFragmentContainer<Props>({
viewer: graphql`
fragment ModeratedByContainer_viewer on User {
id
username
}
`,
})(ModeratedByContainer);
@@ -1,10 +1,10 @@
import React from "react";
import { createRenderer } from "react-test-renderer/shallow";
import RejectButton from "./RejectButton";
import { PropTypesOf } from "coral-framework/types";
import RejectButton from "./RejectButton";
it("renders correctly", () => {
const props: PropTypesOf<typeof RejectButton> = {
invert: false,
@@ -1,4 +0,0 @@
.root {
composes: timestamp from "coral-ui/shared/typography.css";
color: var(--palette-grey-lighter);
}
@@ -1,15 +0,0 @@
import React, { FunctionComponent } from "react";
import { RelativeTime } from "coral-ui/components";
import styles from "./Timestamp.css";
export interface TimestampProps {
children: string;
}
const Timestamp: FunctionComponent<TimestampProps> = props => (
<RelativeTime className={styles.root} date={props.children} />
);
export default Timestamp;
@@ -7,6 +7,7 @@ import {
MutationInput,
} from "coral-framework/lib/relay";
import { GQLTAG } from "coral-framework/schema";
import { UnfeatureCommentMutation } from "coral-stream/__generated__/UnfeatureCommentMutation.graphql";
let clientMutationId = 0;
@@ -3,9 +3,12 @@
exports[`renders all markers 1`] = `
<Markers
details={
<Relay(FlagDetailsContainer)
<Relay(ModerateCardDetailsContainer)
comment={
Object {
"editing": Object {
"edited": false,
},
"revision": Object {
"actionCounts": Object {
"flag": Object {
@@ -13,6 +16,7 @@ exports[`renders all markers 1`] = `
"COMMENT_DETECTED_BANNED_WORD": 1,
"COMMENT_DETECTED_LINKS": 1,
"COMMENT_DETECTED_RECENT_HISTORY": 1,
"COMMENT_DETECTED_REPEAT_POST": 1,
"COMMENT_DETECTED_SPAM": 1,
"COMMENT_DETECTED_SUSPECT_WORD": 1,
"COMMENT_DETECTED_TOXIC": 1,
@@ -30,6 +34,8 @@ exports[`renders all markers 1`] = `
"status": "PREMOD",
}
}
hasDetails={true}
hasRevisions={false}
onUsernameClick={[Function]}
settings={
Object {
@@ -49,7 +55,7 @@ exports[`renders all markers 1`] = `
<withPropsOnChange(Marker)
color="primary"
>
Pre-Mod
Pre-mod
</withPropsOnChange(Marker)>
</Localized>
<Localized
@@ -67,7 +73,7 @@ exports[`renders all markers 1`] = `
<withPropsOnChange(Marker)
color="error"
>
Banned Word
Banned word
</withPropsOnChange(Marker)>
</Localized>
<Localized
@@ -77,16 +83,16 @@ exports[`renders all markers 1`] = `
color="error"
variant="filled"
>
Suspect Word
Suspect word
</withPropsOnChange(Marker)>
</Localized>
<Localized
id="moderate-marker-spam"
id="moderate-marker-spamDetected"
>
<withPropsOnChange(Marker)
color="error"
>
Spam
Spam detected
</withPropsOnChange(Marker)>
</Localized>
<Localized
@@ -98,13 +104,22 @@ exports[`renders all markers 1`] = `
Toxic
</withPropsOnChange(Marker)>
</Localized>
<Localized
id="moderate-marker-repeatPost"
>
<withPropsOnChange(Marker)
color="error"
>
Repeat comment
</withPropsOnChange(Marker)>
</Localized>
<Localized
id="moderate-marker-recentHistory"
>
<withPropsOnChange(Marker)
color="error"
>
Recent History
Recent history
</withPropsOnChange(Marker)>
</Localized>
<withPropsOnChange(Marker)
@@ -143,9 +158,12 @@ exports[`renders all markers 1`] = `
exports[`renders some markers 1`] = `
<Markers
details={
<Relay(FlagDetailsContainer)
<Relay(ModerateCardDetailsContainer)
comment={
Object {
"editing": Object {
"edited": false,
},
"revision": Object {
"actionCounts": Object {
"flag": Object {
@@ -153,6 +171,7 @@ exports[`renders some markers 1`] = `
"COMMENT_DETECTED_BANNED_WORD": 1,
"COMMENT_DETECTED_LINKS": 0,
"COMMENT_DETECTED_RECENT_HISTORY": 1,
"COMMENT_DETECTED_REPEAT_POST": 0,
"COMMENT_DETECTED_SPAM": 0,
"COMMENT_DETECTED_SUSPECT_WORD": 0,
"COMMENT_DETECTED_TOXIC": 1,
@@ -170,6 +189,8 @@ exports[`renders some markers 1`] = `
"status": "PREMOD",
}
}
hasDetails={true}
hasRevisions={false}
onUsernameClick={[Function]}
settings={
Object {
@@ -189,7 +210,7 @@ exports[`renders some markers 1`] = `
<withPropsOnChange(Marker)
color="primary"
>
Pre-Mod
Pre-mod
</withPropsOnChange(Marker)>
</Localized>
<Localized
@@ -198,7 +219,7 @@ exports[`renders some markers 1`] = `
<withPropsOnChange(Marker)
color="error"
>
Banned Word
Banned word
</withPropsOnChange(Marker)>
</Localized>
<Localized
@@ -216,7 +237,7 @@ exports[`renders some markers 1`] = `
<withPropsOnChange(Marker)
color="error"
>
Recent History
Recent history
</withPropsOnChange(Marker)>
</Localized>
<withPropsOnChange(Marker)
@@ -1,9 +1,12 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders approved correctly 1`] = `
<withPropsOnChange(Card)
<ForwardRef(forwardRef)
className="ModerateCard-root"
data-testid="moderate-comment-comment-id"
id="moderate-comment-comment-id"
onClick={[Function]}
tabIndex={0}
>
<ForwardRef(forwardRef)>
<div
@@ -23,7 +26,12 @@ exports[`renders approved correctly 1`] = `
Theon
</Username>
</ForwardRef(forwardRef)>
<Timestamp>
<Relay(CommentAuthorContainer)
comment={Object {}}
/>
<Timestamp
toggleAbsolute={true}
>
2018-11-29T16:01:51.897Z
</Timestamp>
<FeatureButton
@@ -109,13 +117,16 @@ exports[`renders approved correctly 1`] = `
</ForwardRef(forwardRef)>
</ForwardRef(forwardRef)>
</ForwardRef(forwardRef)>
</withPropsOnChange(Card)>
</ForwardRef(forwardRef)>
`;
exports[`renders correctly 1`] = `
<withPropsOnChange(Card)
<ForwardRef(forwardRef)
className="ModerateCard-root"
data-testid="moderate-comment-comment-id"
id="moderate-comment-comment-id"
onClick={[Function]}
tabIndex={0}
>
<ForwardRef(forwardRef)>
<div
@@ -135,7 +146,12 @@ exports[`renders correctly 1`] = `
Theon
</Username>
</ForwardRef(forwardRef)>
<Timestamp>
<Relay(CommentAuthorContainer)
comment={Object {}}
/>
<Timestamp
toggleAbsolute={true}
>
2018-11-29T16:01:51.897Z
</Timestamp>
<FeatureButton
@@ -221,13 +237,16 @@ exports[`renders correctly 1`] = `
</ForwardRef(forwardRef)>
</ForwardRef(forwardRef)>
</ForwardRef(forwardRef)>
</withPropsOnChange(Card)>
</ForwardRef(forwardRef)>
`;
exports[`renders dangling correctly 1`] = `
<withPropsOnChange(Card)
<ForwardRef(forwardRef)
className="ModerateCard-root ModerateCard-dangling"
data-testid="moderate-comment-comment-id"
id="moderate-comment-comment-id"
onClick={[Function]}
tabIndex={0}
>
<ForwardRef(forwardRef)>
<div
@@ -247,7 +266,12 @@ exports[`renders dangling correctly 1`] = `
Theon
</Username>
</ForwardRef(forwardRef)>
<Timestamp>
<Relay(CommentAuthorContainer)
comment={Object {}}
/>
<Timestamp
toggleAbsolute={true}
>
2018-11-29T16:01:51.897Z
</Timestamp>
<FeatureButton
@@ -333,13 +357,16 @@ exports[`renders dangling correctly 1`] = `
</ForwardRef(forwardRef)>
</ForwardRef(forwardRef)>
</ForwardRef(forwardRef)>
</withPropsOnChange(Card)>
</ForwardRef(forwardRef)>
`;
exports[`renders rejected correctly 1`] = `
<withPropsOnChange(Card)
<ForwardRef(forwardRef)
className="ModerateCard-root"
data-testid="moderate-comment-comment-id"
id="moderate-comment-comment-id"
onClick={[Function]}
tabIndex={0}
>
<ForwardRef(forwardRef)>
<div
@@ -359,7 +386,12 @@ exports[`renders rejected correctly 1`] = `
Theon
</Username>
</ForwardRef(forwardRef)>
<Timestamp>
<Relay(CommentAuthorContainer)
comment={Object {}}
/>
<Timestamp
toggleAbsolute={true}
>
2018-11-29T16:01:51.897Z
</Timestamp>
<FeatureButton
@@ -445,13 +477,16 @@ exports[`renders rejected correctly 1`] = `
</ForwardRef(forwardRef)>
</ForwardRef(forwardRef)>
</ForwardRef(forwardRef)>
</withPropsOnChange(Card)>
</ForwardRef(forwardRef)>
`;
exports[`renders reply correctly 1`] = `
<withPropsOnChange(Card)
<ForwardRef(forwardRef)
className="ModerateCard-root"
data-testid="moderate-comment-comment-id"
id="moderate-comment-comment-id"
onClick={[Function]}
tabIndex={0}
>
<ForwardRef(forwardRef)>
<div
@@ -471,7 +506,12 @@ exports[`renders reply correctly 1`] = `
Theon
</Username>
</ForwardRef(forwardRef)>
<Timestamp>
<Relay(CommentAuthorContainer)
comment={Object {}}
/>
<Timestamp
toggleAbsolute={true}
>
2018-11-29T16:01:51.897Z
</Timestamp>
<FeatureButton
@@ -567,13 +607,16 @@ exports[`renders reply correctly 1`] = `
</ForwardRef(forwardRef)>
</ForwardRef(forwardRef)>
</ForwardRef(forwardRef)>
</withPropsOnChange(Card)>
</ForwardRef(forwardRef)>
`;
exports[`renders story info 1`] = `
<withPropsOnChange(Card)
<ForwardRef(forwardRef)
className="ModerateCard-root"
data-testid="moderate-comment-comment-id"
id="moderate-comment-comment-id"
onClick={[Function]}
tabIndex={0}
>
<ForwardRef(forwardRef)>
<div
@@ -593,7 +636,12 @@ exports[`renders story info 1`] = `
Theon
</Username>
</ForwardRef(forwardRef)>
<Timestamp>
<Relay(CommentAuthorContainer)
comment={Object {}}
/>
<Timestamp
toggleAbsolute={true}
>
2018-11-29T16:01:51.897Z
</Timestamp>
<FeatureButton
@@ -708,13 +756,16 @@ exports[`renders story info 1`] = `
</ForwardRef(forwardRef)>
</ForwardRef(forwardRef)>
</ForwardRef(forwardRef)>
</withPropsOnChange(Card)>
</ForwardRef(forwardRef)>
`;
exports[`renders tombstoned when comment is deleted 1`] = `
<withPropsOnChange(Card)
<ForwardRef(forwardRef)
className="ModerateCard-root ModerateCard-deleted"
data-testid="moderate-comment-comment-id"
id="moderate-comment-comment-id"
onClick={[Function]}
tabIndex={0}
>
<ForwardRef(forwardRef)>
<div
@@ -734,7 +785,12 @@ exports[`renders tombstoned when comment is deleted 1`] = `
Theon
</Username>
</ForwardRef(forwardRef)>
<Timestamp>
<Relay(CommentAuthorContainer)
comment={Object {}}
/>
<Timestamp
toggleAbsolute={true}
>
2018-11-29T16:01:51.897Z
</Timestamp>
<FeatureButton
@@ -826,5 +882,5 @@ exports[`renders tombstoned when comment is deleted 1`] = `
</ForwardRef(forwardRef)>
</ForwardRef(forwardRef)>
</ForwardRef(forwardRef)>
</withPropsOnChange(Card)>
</ForwardRef(forwardRef)>
`;
@@ -0,0 +1,29 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`viewer's username shows on moderation cards moderated by viewer 1`] = `
<ForwardRef(Relay(ModeratedByContainer))
comment={
Object {
"id": "comment-id",
"statusHistory": Object {
"edges": Array [
Object {
"moderator": Object {
"id": "viewer",
"username": "viewer",
},
},
],
},
"statusLiveUpdated": false,
}
}
onUsernameClicked={[Function]}
viewer={
Object {
"id": "viewer",
"username": "viewer",
}
}
/>
`;
@@ -1,8 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders correctly 1`] = `
<ForwardRef(forwardRef)
className="Timestamp-root"
date="1995-12-17T03:24:00.000Z"
/>
`;
@@ -1,14 +1,19 @@
import React, { FunctionComponent } from "react";
import BanAction, { BanActionProps } from "./BanAction";
import PremodAction, { PremodActionProps } from "./PremodAction";
import SuspensionAction, { SuspensionActionProps } from "./SuspensionAction";
import UsernameChangeAction, {
UsernameChangeActionProps,
} from "./UsernameChangeAction";
export interface HistoryActionProps {
kind: "username" | "suspension" | "ban";
action: UsernameChangeActionProps | SuspensionActionProps | BanActionProps;
kind: "username" | "suspension" | "ban" | "premod";
action:
| UsernameChangeActionProps
| SuspensionActionProps
| BanActionProps
| PremodActionProps;
}
const AccountHistoryAction: FunctionComponent<HistoryActionProps> = ({
@@ -17,11 +22,15 @@ const AccountHistoryAction: FunctionComponent<HistoryActionProps> = ({
}) => {
switch (kind) {
case "username":
return <UsernameChangeAction {...action as UsernameChangeActionProps} />;
return (
<UsernameChangeAction {...(action as UsernameChangeActionProps)} />
);
case "suspension":
return <SuspensionAction {...action as SuspensionActionProps} />;
return <SuspensionAction {...(action as SuspensionActionProps)} />;
case "ban":
return <BanAction {...action as BanActionProps} />;
return <BanAction {...(action as BanActionProps)} />;
case "premod":
return <PremodAction {...(action as PremodActionProps)} />;
default:
return null;
}
@@ -0,0 +1,86 @@
import { pick } from "lodash";
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import { CreateModeratorNoteMutation as MutationTypes } from "coral-admin/__generated__/CreateModeratorNoteMutation.graphql";
import { getViewer } from "coral-framework/helpers";
import { CoralContext } from "coral-framework/lib/bootstrap";
import {
commitMutationPromiseNormalized,
createMutation,
lookup,
MutationInput,
} from "coral-framework/lib/relay";
import { GQLUser } from "coral-framework/schema";
let clientMutationId = 0;
const CreateModeratorNoteMutation = createMutation(
"createModeratorNote",
(
environment: Environment,
input: MutationInput<MutationTypes>,
{ uuidGenerator }: CoralContext
) => {
const viewer = getViewer(environment)!;
const notes =
lookup<GQLUser>(environment, input.userID)!.moderatorNotes.map(note => {
const createdBy = pick(note.createdBy, ["username", "id"]);
return {
...pick(note, ["id", "body", "createdAt"]),
createdBy,
};
}) || [];
const now = new Date();
return commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation CreateModeratorNoteMutation(
$input: CreateModeratorNoteInput!
) {
createModeratorNote(input: $input) {
user {
moderatorNotes {
id
body
createdBy {
username
id
}
createdAt
}
}
clientMutationId
}
}
`,
variables: {
input: {
...input,
clientMutationId: clientMutationId.toString(),
},
},
optimisticResponse: {
createModeratorNote: {
user: {
id: input.userID,
moderatorNotes: [
{
id: uuidGenerator(),
body: input.body,
createdAt: now.toISOString(),
createdBy: {
username: viewer.username,
id: viewer.id,
} as any,
},
...notes,
],
},
clientMutationId: (clientMutationId++).toString(),
},
},
});
}
);
export default CreateModeratorNoteMutation;
@@ -0,0 +1,72 @@
import { pick } from "lodash";
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import { DeleteModeratorNoteMutation as MutationTypes } from "coral-admin/__generated__/DeleteModeratorNoteMutation.graphql";
import { CoralContext } from "coral-framework/lib/bootstrap";
import {
commitMutationPromiseNormalized,
createMutation,
lookup,
MutationInput,
} from "coral-framework/lib/relay";
import { GQLUser } from "coral-framework/schema";
let clientMutationId = 0;
const DeleteModeratorNoteMutation = createMutation(
"deleteModeratorNote",
(
environment: Environment,
input: MutationInput<MutationTypes>,
{ uuidGenerator }: CoralContext
) => {
const notes =
lookup<GQLUser>(environment, input.userID)!.moderatorNotes.map(note => {
const createdBy = pick(note.createdBy, ["username", "id"]);
return {
...pick(note, ["id", "body", "createdAt"]),
createdBy,
};
}) || [];
return commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation DeleteModeratorNoteMutation(
$input: DeleteModeratorNoteInput!
) {
deleteModeratorNote(input: $input) {
user {
moderatorNotes {
id
body
createdBy {
username
id
}
createdAt
}
}
clientMutationId
}
}
`,
variables: {
input: {
...input,
clientMutationId: clientMutationId.toString(),
},
},
optimisticResponse: {
deleteModeratorNote: {
user: {
id: input.userID,
moderatorNotes: notes.filter(note => note.id !== input.id),
},
clientMutationId: (clientMutationId++).toString(),
},
},
});
}
);
export default DeleteModeratorNoteMutation;
@@ -0,0 +1,37 @@
.root {
}
.body {
background-color: #f2f2f2;
border-radius: 4px;
padding: var(--spacing-4);
}
.bodyType {
color: var(--palette-text-dark);
}
.leftBy {
padding-left: var(--spacing-4);
padding-right: var(--spacing-1);
position: relative;
color: var(--palette-grey-main);
}
.leftBy:before {
content: "";
width: 4px;
height: 4px;
background-color: var(--palette-grey-main);
position: absolute;
border-radius: 50%;
left: var(--spacing-1);
top: 50%;
}
.username {
font-family: var(--font-family-serif);
font-weight: var(--font-weight-medium);
color: var(--palette-grey-main);
}
@@ -0,0 +1,64 @@
import { Localized } from "fluent-react/compat";
import React, { FunctionComponent, useCallback } from "react";
import { Button, Flex, Icon, Timestamp, Typography } from "coral-ui/components";
import styles from "./ModeratorNote.css";
interface Props {
body: string;
moderator: string | null;
createdAt: string;
onDelete: ((id: string) => Promise<any>) | null;
id: string;
}
const ModeratorNote: FunctionComponent<Props> = ({
moderator,
createdAt,
body,
onDelete,
id,
}) => {
const deleteNote = useCallback(() => {
if (onDelete) {
onDelete(id);
}
}, [id]);
return (
<div>
<div className={styles.body}>
<Typography variant="bodyCopy" className={styles.bodyType}>
{body}
</Typography>
</div>
<Flex justifyContent="space-between">
<Flex alignItems="center">
<Timestamp>{createdAt}</Timestamp>
{moderator && (
<>
<Localized id="moderatorNote-left-by">
<Typography variant="timestamp" className={styles.leftBy}>
Left by:
</Typography>
</Localized>
<Typography className={styles.username} variant="timestamp">
{moderator}
</Typography>
</>
)}
</Flex>
{onDelete && (
<Localized id="moderatorNote-delete">
<Button size="small" color="primary" onClick={deleteNote}>
<Icon>delete</Icon>
<span>Delete</span>
</Button>
</Localized>
)}
</Flex>
</div>
);
};
export default ModeratorNote;
@@ -0,0 +1,19 @@
import { Localized } from "fluent-react/compat";
import React, { FunctionComponent } from "react";
export interface PremodActionProps {
action: "created" | "removed";
}
const PremodAction: FunctionComponent<PremodActionProps> = ({ action }) =>
action === "created" ? (
<Localized id="moderate-user-drawer-account-history-premod-set">
<span>Set always premoderate</span>
</Localized>
) : (
<Localized id="moderate-user-drawer-account-history-premod-removed">
<span>Removed always premoderate</span>
</Localized>
);
export default PremodAction;
@@ -1,9 +1,11 @@
import React, { FunctionComponent, useMemo } from "react";
import { graphql, withFragmentContainer } from "coral-framework/lib/relay";
import { GQLCOMMENT_STATUS } from "coral-framework/schema";
import { RecentHistoryContainer_settings } from "coral-admin/__generated__/RecentHistoryContainer_settings.graphql";
import { RecentHistoryContainer_user } from "coral-admin/__generated__/RecentHistoryContainer_user.graphql";
import { graphql, withFragmentContainer } from "coral-framework/lib/relay";
import { GQLCOMMENT_STATUS } from "coral-framework/schema";
import RecentHistory from "./RecentHistory";
const PUBLISHED_STATUSES = [GQLCOMMENT_STATUS.NONE, GQLCOMMENT_STATUS.APPROVED];
@@ -57,4 +57,12 @@
.scrollable {
overflow-x: hidden;
overflow-y: auto;
}
.redDot {
background-color: var(--palette-error-main);
width: 6px;
height: 6px;
border-radius: 50%;
margin-left: 2px;
}
@@ -2,21 +2,34 @@ import cn from "classnames";
import { Localized } from "fluent-react/compat";
import React, { FunctionComponent, useCallback, useState } from "react";
import { Icon, Tab, TabBar, TabContent, TabPane } from "coral-ui/components";
import {
Flex,
Icon,
Tab,
TabBar,
TabContent,
TabPane,
} from "coral-ui/components";
import UserDrawerAccountHistoryQuery from "./UserDrawerAccountHistoryQuery";
import UserDrawerNotesQuery from "./UserDrawerNotesQuery";
import UserHistoryDrawerAllCommentsQuery from "./UserHistoryDrawerAllCommentsQuery";
import UserHistoryDrawerRejectedCommentsQuery from "./UserHistoryDrawerRejectedCommentsQuery";
import styles from "./Tabs.css";
type UserTabs = "ALL_COMMENTS" | "REJECTED_COMMENTS" | "ACCOUNT_HISTORY";
type UserTabs =
| "ALL_COMMENTS"
| "REJECTED_COMMENTS"
| "ACCOUNT_HISTORY"
| "NOTES";
interface Props {
userID: string;
notesCount: number;
}
const UserHistoryTabs: FunctionComponent<Props> = ({ userID }) => {
const UserHistoryTabs: FunctionComponent<Props> = ({ userID, notesCount }) => {
const [currentTab, setCurrentTab] = useState<UserTabs>("ALL_COMMENTS");
const onTabChanged = useCallback(
@@ -62,6 +75,23 @@ const UserHistoryTabs: FunctionComponent<Props> = ({ userID }) => {
</Localized>
</div>
</Tab>
<Tab tabID="NOTES" onTabClick={onTabChanged}>
<div
className={cn(styles.tab, {
[styles.activeTab]: currentTab === "NOTES",
})}
>
<Icon size="sm" className={styles.tabIcon}>
subject
</Icon>
<Flex>
<Localized id="moderate-user-drawer-tab-notes">
<span>Notes</span>
</Localized>
{notesCount > 0 && <div className={styles.redDot} />}
</Flex>
</div>
</Tab>
<Tab tabID="ACCOUNT_HISTORY" onTabClick={onTabChanged}>
<div
className={cn(styles.tab, {
@@ -99,6 +129,13 @@ const UserHistoryTabs: FunctionComponent<Props> = ({ userID }) => {
</div>
</div>
</TabPane>
<TabPane tabID="NOTES">
<div className={styles.container}>
<div className={styles.scrollable}>
<UserDrawerNotesQuery userID={userID} />
</div>
</div>
</TabPane>
</TabContent>
</div>
);
@@ -1,12 +1,12 @@
import React, { FunctionComponent } from "react";
import { graphql } from "react-relay";
import { UserBadgesContainer_user as UserData } from "coral-admin/__generated__/UserBadgesContainer_user.graphql";
import withFragmentContainer from "coral-framework/lib/relay/withFragmentContainer";
import CLASSES from "coral-stream/classes";
import { Tag } from "coral-ui/components";
import { UserBadgesContainer_user as UserData } from "coral-admin/__generated__/UserBadgesContainer_user.graphql";
interface Props {
user: UserData;
}
@@ -1,8 +1,6 @@
import { Localized } from "fluent-react/compat";
import React, { FunctionComponent, useMemo } from "react";
import { UserDrawerAccountHistory_user } from "coral-admin/__generated__/UserDrawerAccountHistory_user.graphql";
import { useCoralContext } from "coral-framework/lib/bootstrap";
import { graphql, withFragmentContainer } from "coral-framework/lib/relay";
import {
@@ -16,6 +14,8 @@ import {
TableRow,
} from "coral-ui/components";
import { UserDrawerAccountHistory_user } from "coral-admin/__generated__/UserDrawerAccountHistory_user.graphql";
import AccountHistoryAction, {
HistoryActionProps,
} from "./AccountHistoryAction";
@@ -107,6 +107,18 @@ const UserDrawerAccountHistory: FunctionComponent<Props> = ({ user }) => {
});
});
// Merge in all the premod history items.
user.status.premod.history.forEach(record => {
history.push({
kind: "premod",
action: {
action: record.active ? "created" : "removed",
},
date: new Date(record.createdAt),
takenBy: record.createdBy ? record.createdBy.username : system,
});
});
user.status.username.history.forEach((record, i) => {
history.push({
kind: "username",
@@ -190,6 +202,15 @@ const enhanced = withFragmentContainer<any>({
createdAt
}
}
premod {
history {
active
createdBy {
username
}
createdAt
}
}
suspension {
history {
active
@@ -1,12 +1,12 @@
import { graphql, QueryRenderer } from "coral-framework/lib/relay";
import { Localized } from "fluent-react/compat";
import React, { FunctionComponent } from "react";
import { ReadyState } from "react-relay";
import { UserDrawerAccountHistoryQuery as QueryTypes } from "coral-admin/__generated__/UserDrawerAccountHistoryQuery.graphql";
import { graphql, QueryRenderer } from "coral-framework/lib/relay";
import { CallOut, Spinner } from "coral-ui/components";
import { UserDrawerAccountHistoryQuery as QueryTypes } from "coral-admin/__generated__/UserDrawerAccountHistoryQuery.graphql";
import UserDrawerAccountHistory from "./UserDrawerAccountHistory";
import styles from "./UserDrawerAccountHistoryQuery.css";

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